···2424 let doc = "Package name. If not specified, operates on all packages." in
2525 Arg.(value & pos 0 (some string) None & info [] ~docv:"PACKAGE" ~doc)
26262727-(* Load config from opamverse.toml and convert to Monopam.Config *)
2727+(* Load config from opamverse.toml *)
2828let load_config env =
2929 let fs = Eio.Stdenv.fs env in
3030- match Monopam.Verse_config.load ~fs () with
3131- | Error msg -> Error msg
3232- | Ok verse_config ->
3333- (* Convert Verse_config to Monopam.Config *)
3434- let opam_repo = Monopam.Verse_config.opam_repo_path verse_config in
3535- let checkouts = Monopam.Verse_config.src_path verse_config in
3636- let monorepo = Monopam.Verse_config.mono_path verse_config in
3737- let default_branch = Monopam.Verse_config.default_branch in
3838- let base_config =
3939- Monopam.Config.create ~opam_repo ~checkouts ~monorepo ~default_branch ()
4040- in
4141- (* Apply package overrides from verse config *)
4242- let config =
4343- List.fold_left
4444- (fun cfg (name, override) ->
4545- let open Monopam.Verse_config in
4646- Monopam.Config.with_package_override cfg ~name ?branch:override.branch
4747- ())
4848- base_config
4949- (Monopam.Verse_config.packages verse_config)
5050- in
5151- Ok config
3030+ Monopam.Config.load ~fs ()
52315332let with_config env f =
5433 match load_config env with
···12731252 in
12741253 Cmd.v info Term.(ret (const run $ path_arg $ url_arg $ logging_term))
1275125412551255+(* Confirmation prompt *)
12561256+let confirm prompt =
12571257+ Printf.printf "%s [y/N] %!" prompt;
12581258+ match In_channel.(input_line stdin) with
12591259+ | Some s -> String.lowercase_ascii (String.trim s) = "y"
12601260+ | None -> false
12611261+12761262(* Fork command *)
1277126312781264let fork_cmd =
···12841270 "Splits a monorepo subdirectory into its own git repository. This \
12851271 extracts the commit history for the subtree and creates a standalone \
12861272 repository in src/<name>/.";
12731273+ `S "FORK MODES";
12741274+ `P "The fork command handles two scenarios:";
12751275+ `I ("Subtree with history", "For subtrees added via $(b,git subtree add) or \
12761276+ $(b,monopam join), the command uses $(b,git subtree split) to extract \
12771277+ the full commit history into the new repository.");
12781278+ `I ("Fresh package", "For packages created directly in mono/ without subtree \
12791279+ history, the command copies the files and creates an initial commit. \
12801280+ This is useful for new packages you've developed locally.");
12871281 `S "WHAT IT DOES";
12881282 `P "The fork command:";
12891289- `I ("1.", "Validates mono/<name>/ exists as a subtree");
12901290- `I ("2.", "Uses $(b,git subtree split) to extract history");
12911291- `I ("3.", "Creates a new git repo at src/<name>/");
12921292- `I ("4.", "Pushes the extracted history to the new repo");
12931293- `I ("5.", "Updates sources.toml with $(b,origin = \"fork\")");
12941294- `I ("6.", "Auto-discovers packages from .opam files");
12831283+ `I ("1.", "Analyzes mono/<name>/ to detect fork mode");
12841284+ `I ("2.", "Builds an action plan and shows discovery details");
12851285+ `I ("3.", "Prompts for confirmation (use $(b,--yes) to skip)");
12861286+ `I ("4.", "Creates a new git repo at src/<name>/");
12871287+ `I ("5.", "Extracts history or copies files based on mode");
12881288+ `I ("6.", "Updates sources.toml with $(b,origin = \"fork\")");
12951289 `S "AFTER FORKING";
12961290 `P "After forking, the subtree will be tracked via src/<name>/:";
12971291 `I ("1.", "Make changes in mono/<name>/ as usual");
···13041298 `Pre "monopam fork my-lib git@github.com:me/my-lib.git";
13051299 `P "Preview what would be done:";
13061300 `Pre "monopam fork my-lib --dry-run";
13011301+ `P "Fork without confirmation:";
13021302+ `Pre "monopam fork my-lib --yes";
13071303 ]
13081304 in
13091305 let info = Cmd.info "fork" ~doc ~man in
···13191315 let doc = "Show what would be done without making changes" in
13201316 Arg.(value & flag & info [ "dry-run"; "n" ] ~doc)
13211317 in
13221322- let run name url dry_run () =
13181318+ let yes_arg =
13191319+ let doc = "Assume yes to all prompts (for automation)" in
13201320+ Arg.(value & flag & info [ "yes"; "y" ] ~doc)
13211321+ in
13221322+ let run name url dry_run yes () =
13231323 Eio_main.run @@ fun env ->
13241324 with_verse_config env @@ fun config ->
13251325 let fs = Eio.Stdenv.fs env in
13261326 let proc = Eio.Stdenv.process_mgr env in
13271327- match Monopam.Fork_join.fork ~proc ~fs ~config ~name ?push_url:url ~dry_run () with
13281328- | Ok result ->
13291329- if dry_run then begin
13301330- Fmt.pr "Would fork subtree '%s':@." result.name;
13311331- Fmt.pr " Packages: %a@." Fmt.(list ~sep:(any ", ") string) result.packages_created;
13321332- Fmt.pr " Destination: %a@." Fpath.pp result.src_path;
13331333- match url with
13341334- | Some u -> Fmt.pr " Push URL: %s@." u
13351335- | None -> ()
13361336- end else begin
13371337- Fmt.pr "%a@." Monopam.Fork_join.pp_fork_result result;
13381338- Fmt.pr "@.Next steps:@.";
13391339- Fmt.pr " 1. Review the new repo: cd src/%s@." result.name;
13401340- match url with
13411341- | Some _ -> Fmt.pr " 2. Push to remote: git push -u origin main@."
13421342- | None -> Fmt.pr " 2. Add a remote: git remote add origin <url>@."
13431343- end;
13441344- `Ok ()
13271327+ (* Build the plan *)
13281328+ match Monopam.Fork_join.plan_fork ~proc ~fs ~config ~name ?push_url:url ~dry_run () with
13451329 | Error e ->
13461330 Fmt.epr "Error: %a@." Monopam.Fork_join.pp_error_with_hint e;
13471331 `Error (false, "fork failed")
13321332+ | Ok plan ->
13331333+ (* Print discovery and actions *)
13341334+ Fmt.pr "Analyzing fork request for '%s'...@.@." name;
13351335+ Fmt.pr "Discovery:@.%a@." Monopam.Fork_join.pp_discovery plan.discovery;
13361336+ (match url with
13371337+ | Some u -> Fmt.pr " Remote URL: %s@." u
13381338+ | None -> ());
13391339+ Fmt.pr "@.Actions to perform:@.";
13401340+ List.iteri (fun i action ->
13411341+ Fmt.pr " %d. %a@." (i + 1) Monopam.Fork_join.pp_action action
13421342+ ) plan.actions;
13431343+ Fmt.pr "@.";
13441344+ (* Prompt for confirmation unless --yes or --dry-run *)
13451345+ let proceed =
13461346+ if dry_run then begin
13471347+ Fmt.pr "(dry-run mode - no changes will be made)@.";
13481348+ true
13491349+ end else if yes then
13501350+ true
13511351+ else
13521352+ confirm "Proceed?"
13531353+ in
13541354+ if not proceed then begin
13551355+ Fmt.pr "Cancelled.@.";
13561356+ `Ok ()
13571357+ end else begin
13581358+ (* Execute the plan *)
13591359+ match Monopam.Fork_join.execute_fork_plan ~proc ~fs plan with
13601360+ | Ok result ->
13611361+ if not dry_run then begin
13621362+ Fmt.pr "%a@." Monopam.Fork_join.pp_fork_result result;
13631363+ Fmt.pr "@.Next steps:@.";
13641364+ Fmt.pr " 1. Review the new repo: cd src/%s@." result.name;
13651365+ match url with
13661366+ | Some _ -> Fmt.pr " 2. Push to remote: git push -u origin main@."
13671367+ | None -> Fmt.pr " 2. Add a remote: git remote add origin <url>@."
13681368+ end;
13691369+ `Ok ()
13701370+ | Error e ->
13711371+ Fmt.epr "Error: %a@." Monopam.Fork_join.pp_error_with_hint e;
13721372+ `Error (false, "fork failed")
13731373+ end
13481374 in
13491349- Cmd.v info Term.(ret (const run $ name_arg $ url_arg $ dry_run_arg $ logging_term))
13751375+ Cmd.v info Term.(ret (const run $ name_arg $ url_arg $ dry_run_arg $ yes_arg $ logging_term))
1350137613511377(* Join command *)
13521378···13581384 `P
13591385 "Clones an external git repository and adds it as a subtree in the \
13601386 monorepo. This is the inverse of $(b,monopam fork).";
13871387+ `S "JOIN MODES";
13881388+ `P "The join command handles multiple scenarios:";
13891389+ `I ("URL join", "Clone from a git URL and add as subtree (default).");
13901390+ `I ("Local directory join", "Import from a local filesystem path. If the \
13911391+ path is a git repo, uses it directly. If not, initializes a new repo.");
13921392+ `I ("Verse join", "Join from a verse member's repository using $(b,--from).");
13611393 `S "WHAT IT DOES";
13621394 `P "The join command:";
13631363- `I ("1.", "Derives subtree name from URL (or uses --as)");
13641364- `I ("2.", "Validates mono/<name>/ does not exist");
13651365- `I ("3.", "Clones the repository to src/<name>/");
13661366- `I ("4.", "Uses $(b,git subtree add) to bring into monorepo");
13671367- `I ("5.", "Updates sources.toml with $(b,origin = \"join\")");
13681368- `I ("6.", "Auto-discovers packages from .opam files");
13951395+ `I ("1.", "Analyzes the source (URL or local path)");
13961396+ `I ("2.", "Builds an action plan and shows discovery details");
13971397+ `I ("3.", "Prompts for confirmation (use $(b,--yes) to skip)");
13981398+ `I ("4.", "Clones/copies the repository to src/<name>/");
13991399+ `I ("5.", "Uses $(b,git subtree add) to bring into monorepo");
14001400+ `I ("6.", "Updates sources.toml with $(b,origin = \"join\")");
13691401 `S "JOINING FROM VERSE";
13701402 `P "To join a package from a verse member, use $(b,--from):";
13711403 `Pre "monopam join --from avsm.bsky.social --url git@github.com:me/cohttp.git cohttp";
···13821414 `S Manpage.s_examples;
13831415 `P "Join a repository:";
13841416 `Pre "monopam join https://github.com/someone/some-lib";
14171417+ `P "Join from a local directory:";
14181418+ `Pre "monopam join /path/to/local/repo --as my-lib";
13851419 `P "Join with explicit name using --url:";
13861420 `Pre "monopam join --url https://tangled.org/handle/sortal sortal";
13871421 `P "Join with a custom name using --as:";
···13921426 `Pre "monopam join cohttp --from avsm.bsky.social --url git@github.com:me/cohttp.git";
13931427 `P "Preview what would be done:";
13941428 `Pre "monopam join https://github.com/someone/lib --dry-run";
14291429+ `P "Join without confirmation:";
14301430+ `Pre "monopam join https://github.com/someone/lib --yes";
13951431 ]
13961432 in
13971433 let info = Cmd.info "join" ~doc ~man in
13981434 let url_or_pkg_arg =
13991399- let doc = "Git URL to join, or subtree name (when using --url)" in
14001400- Arg.(required & pos 0 (some string) None & info [] ~docv:"URL|NAME" ~doc)
14351435+ let doc = "Git URL, local path, or subtree name (when using --url)" in
14361436+ Arg.(required & pos 0 (some string) None & info [] ~docv:"SOURCE" ~doc)
14011437 in
14021438 let as_arg =
14031439 let doc = "Override subtree directory name" in
···14191455 let doc = "Show what would be done without making changes" in
14201456 Arg.(value & flag & info [ "dry-run"; "n" ] ~doc)
14211457 in
14221422- let run url_or_pkg as_name upstream from fork_url dry_run () =
14581458+ let yes_arg =
14591459+ let doc = "Assume yes to all prompts (for automation)" in
14601460+ Arg.(value & flag & info [ "yes"; "y" ] ~doc)
14611461+ in
14621462+ let run url_or_pkg as_name upstream from fork_url dry_run yes () =
14231463 Eio_main.run @@ fun env ->
14241464 with_verse_config env @@ fun config ->
14251465 let fs = Eio.Stdenv.fs env in
···14271467 match from with
14281468 | Some handle ->
14291469 (* Join from verse member - requires --url for your fork *)
14701470+ (* Uses legacy API as it involves verse-specific operations *)
14301471 (match fork_url with
14311472 | None ->
14321473 Fmt.epr "Error: --url is required when using --from@.";
···14511492 Fmt.epr "Error: %a@." Monopam.Fork_join.pp_error_with_hint e;
14521493 `Error (false, "join failed"))
14531494 | None ->
14541454- (* Normal join from URL - use --url if provided, otherwise positional arg *)
14551455- let url = match fork_url with Some u -> u | None -> url_or_pkg in
14951495+ (* Normal join from URL or local path - use plan-based workflow *)
14961496+ let source = match fork_url with Some u -> u | None -> url_or_pkg in
14561497 let name = match fork_url with Some _ -> Some url_or_pkg | None -> as_name in
14571457- match Monopam.Fork_join.join ~proc ~fs ~config ~url ?name ?upstream ~dry_run () with
14581458- | Ok result ->
14591459- if dry_run then begin
14601460- Fmt.pr "Would join '%s':@." result.name;
14611461- Fmt.pr " Source: %s@." result.source_url;
14621462- Option.iter (fun u -> Fmt.pr " Upstream: %s@." u) result.upstream_url;
14631463- Fmt.pr " Packages: %a@." Fmt.(list ~sep:(any ", ") string) result.packages_added
14641464- end else begin
14651465- Fmt.pr "%a@." Monopam.Fork_join.pp_join_result result;
14661466- Fmt.pr "@.Next steps:@.";
14671467- Fmt.pr " 1. Run $(b,monopam sync) to synchronize@."
14681468- end;
14691469- `Ok ()
14981498+ (* Build the plan *)
14991499+ match Monopam.Fork_join.plan_join ~proc ~fs ~config ~source ?name ?upstream ~dry_run () with
14701500 | Error e ->
14711501 Fmt.epr "Error: %a@." Monopam.Fork_join.pp_error_with_hint e;
14721502 `Error (false, "join failed")
15031503+ | Ok plan ->
15041504+ (* Print discovery and actions *)
15051505+ let is_local = Monopam.Fork_join.is_local_path source in
15061506+ Fmt.pr "Analyzing join request...@.@.";
15071507+ Fmt.pr "Discovery:@.";
15081508+ Fmt.pr " Source: %s (%s)@." source
15091509+ (if is_local then "local directory" else "remote URL");
15101510+ Fmt.pr "%a" Monopam.Fork_join.pp_discovery plan.discovery;
15111511+ Fmt.pr "@.Actions to perform:@.";
15121512+ List.iteri (fun i action ->
15131513+ Fmt.pr " %d. %a@." (i + 1) Monopam.Fork_join.pp_action action
15141514+ ) plan.actions;
15151515+ Fmt.pr "@.";
15161516+ (* Prompt for confirmation unless --yes or --dry-run *)
15171517+ let proceed =
15181518+ if dry_run then begin
15191519+ Fmt.pr "(dry-run mode - no changes will be made)@.";
15201520+ true
15211521+ end else if yes then
15221522+ true
15231523+ else
15241524+ confirm "Proceed?"
15251525+ in
15261526+ if not proceed then begin
15271527+ Fmt.pr "Cancelled.@.";
15281528+ `Ok ()
15291529+ end else begin
15301530+ (* Execute the plan *)
15311531+ match Monopam.Fork_join.execute_join_plan ~proc ~fs plan with
15321532+ | Ok result ->
15331533+ if not dry_run then begin
15341534+ Fmt.pr "%a@." Monopam.Fork_join.pp_join_result result;
15351535+ Fmt.pr "@.Next steps:@.";
15361536+ Fmt.pr " 1. Run $(b,monopam sync) to synchronize@."
15371537+ end;
15381538+ `Ok ()
15391539+ | Error e ->
15401540+ Fmt.epr "Error: %a@." Monopam.Fork_join.pp_error_with_hint e;
15411541+ `Error (false, "join failed")
15421542+ end
14731543 in
14741474- Cmd.v info Term.(ret (const run $ url_or_pkg_arg $ as_arg $ upstream_arg $ from_arg $ fork_url_arg $ dry_run_arg $ logging_term))
15441544+ Cmd.v info Term.(ret (const run $ url_or_pkg_arg $ as_arg $ upstream_arg $ from_arg $ fork_url_arg $ dry_run_arg $ yes_arg $ logging_term))
1475154514761546(* Site command *)
14771547
+190-91
lib/config.ml
···11+(** Unified configuration for monopam.
22+33+ Configuration is stored in TOML format at ~/.config/monopam/opamverse.toml *)
44+55+let app_name = "monopam"
66+77+(** {1 Package Overrides} *)
88+19module Package_config = struct
210 type t = { branch : string option }
311···1119 |> finish))
1220end
13212222+(** {1 Paths Configuration} *)
2323+2424+type paths = {
2525+ mono : string; (** Monorepo directory (default: "mono") *)
2626+ src : string; (** Source checkouts directory (default: "src") *)
2727+ verse : string; (** Verse directory (default: "verse") *)
2828+}
2929+3030+let default_paths = { mono = "mono"; src = "src"; verse = "verse" }
3131+3232+(** {1 Main Configuration Type} *)
3333+1434type t = {
1515- opam_repo : Fpath.t;
1616- checkouts : Fpath.t;
1717- monorepo : Fpath.t;
1818- default_branch : string;
3535+ (* Workspace structure *)
3636+ root : Fpath.t;
3737+ paths : paths;
3838+ (* Identity *)
3939+ handle : string;
4040+ knot : string; (** Git push server hostname (e.g., "git.recoil.org") *)
4141+ (* Package overrides *)
1942 packages : (string * Package_config.t) list;
2043}
21444545+(** {1 Accessors} *)
4646+4747+let root t = t.root
4848+let handle t = t.handle
4949+let knot t = t.knot
5050+let paths t = t.paths
5151+let packages t = t.packages
5252+let package_config t name = List.assoc_opt name t.packages
5353+5454+(* Derived paths *)
5555+let default_branch = "main"
5656+let mono_path t = Fpath.(t.root / t.paths.mono)
5757+let src_path t = Fpath.(t.root / t.paths.src)
5858+let opam_repo_path t = Fpath.(t.root / "opam-repo")
5959+let verse_path t = Fpath.(t.root / t.paths.verse)
6060+6161+(* Aliases for backwards compatibility with old Config.Paths module *)
2262module Paths = struct
2323- let opam_repo t = t.opam_repo
2424- let checkouts t = t.checkouts
2525- let monorepo t = t.monorepo
6363+ let opam_repo = opam_repo_path
6464+ let checkouts = src_path
6565+ let monorepo = mono_path
2666end
27672828-let default_branch t = t.default_branch
2929-let package_config t name = List.assoc_opt name t.packages
6868+(** {1 XDG Paths} *)
6969+7070+let xdg_config_home () =
7171+ match Sys.getenv_opt "XDG_CONFIG_HOME" with
7272+ | Some dir when dir <> "" -> Fpath.v dir
7373+ | _ -> (
7474+ match Sys.getenv_opt "HOME" with
7575+ | Some home -> Fpath.(v home / ".config")
7676+ | None -> Fpath.v "/tmp")
7777+7878+let xdg_data_home () =
7979+ match Sys.getenv_opt "XDG_DATA_HOME" with
8080+ | Some dir when dir <> "" -> Fpath.v dir
8181+ | _ -> (
8282+ match Sys.getenv_opt "HOME" with
8383+ | Some home -> Fpath.(v home / ".local" / "share")
8484+ | None -> Fpath.v "/tmp")
8585+8686+let xdg_cache_home () =
8787+ match Sys.getenv_opt "XDG_CACHE_HOME" with
8888+ | Some dir when dir <> "" -> Fpath.v dir
8989+ | _ ->
9090+ match Sys.getenv_opt "HOME" with
9191+ | Some home -> Fpath.(v home / ".cache")
9292+ | None -> Fpath.v "/tmp"
9393+9494+let config_dir () = Fpath.(xdg_config_home () / app_name)
9595+let data_dir () = Fpath.(xdg_data_home () / app_name)
9696+let cache_dir () = Fpath.(xdg_cache_home () / app_name)
9797+let config_file () = Fpath.(config_dir () / "opamverse.toml")
9898+let registry_path () = Fpath.(data_dir () / "opamverse-registry")
9999+100100+(** {1 Construction} *)
101101+102102+(** Derive knot (git push server) from handle.
103103+ E.g., "anil.recoil.org" -> "git.recoil.org" *)
104104+let default_knot_from_handle handle =
105105+ match String.index_opt handle '.' with
106106+ | None -> "git." ^ handle (* fallback *)
107107+ | Some i ->
108108+ let domain = String.sub handle (i + 1) (String.length handle - i - 1) in
109109+ "git." ^ domain
301103131-let create ~opam_repo ~checkouts ~monorepo ?(default_branch = "main") () =
3232- { opam_repo; checkouts; monorepo; default_branch; packages = [] }
111111+let create ~root ~handle ?knot ?(packages = []) ?(paths = default_paths) () =
112112+ let knot = match knot with Some k -> k | None -> default_knot_from_handle handle in
113113+ { root; handle; knot; packages; paths }
3311434115let with_package_override t ~name ?branch:branch_opt () =
35116 let existing = List.assoc_opt name t.packages in
···40121 let pkg_config = Package_config.{ branch = new_branch } in
41122 let packages = (name, pkg_config) :: List.remove_assoc name t.packages in
42123 { t with packages }
124124+125125+(** {1 TOML Codecs} *)
4312644127let expand_tilde s =
45128 if String.length s > 0 && s.[0] = '~' then
···58141 match Fpath.of_string s with Ok p -> p | Error (`Msg m) -> failwith m)
59142 ~enc:Fpath.to_string Tomlt.string
601436161-let codec : t Tomlt.t =
144144+let paths_codec : paths Tomlt.t =
145145+ Tomlt.(
146146+ Table.(
147147+ obj (fun mono src verse ->
148148+ { mono = Option.value ~default:default_paths.mono mono;
149149+ src = Option.value ~default:default_paths.src src;
150150+ verse = Option.value ~default:default_paths.verse verse })
151151+ |> opt_mem "mono" string ~enc:(fun p -> Some p.mono)
152152+ |> opt_mem "src" string ~enc:(fun p -> Some p.src)
153153+ |> opt_mem "verse" string ~enc:(fun p -> Some p.verse)
154154+ |> finish))
155155+156156+(* TOML structure:
157157+ [workspace]
158158+ root = "~/tangled"
159159+160160+ [identity]
161161+ handle = "anil.recoil.org"
162162+ knot = "git.recoil.org"
163163+164164+ [paths]
165165+ mono = "mono"
166166+ src = "src"
167167+168168+ [packages.braid]
169169+ branch = "backport-fix"
170170+*)
171171+172172+type workspace_section = { w_root : Fpath.t }
173173+type identity_section = { i_handle : string; i_knot : string option }
174174+175175+let default_knot = "git.recoil.org"
176176+177177+let workspace_codec : workspace_section Tomlt.t =
178178+ Tomlt.(
179179+ Table.(
180180+ obj (fun w_root -> { w_root })
181181+ |> mem "root" fpath_codec ~enc:(fun w -> w.w_root)
182182+ |> finish))
183183+184184+let identity_codec : identity_section Tomlt.t =
62185 Tomlt.(
63186 Table.(
6464- obj (fun opam_repo checkouts monorepo default_branch packages ->
6565- {
6666- opam_repo;
6767- checkouts;
6868- monorepo;
6969- default_branch = Option.value ~default:"main" default_branch;
7070- packages;
7171- })
7272- |> mem "opam_repo" fpath_codec ~enc:(fun c -> c.opam_repo)
7373- |> mem "checkouts" fpath_codec ~enc:(fun c -> c.checkouts)
7474- |> mem "monorepo" fpath_codec ~enc:(fun c -> c.monorepo)
7575- |> opt_mem "default_branch" string ~enc:(fun c ->
7676- if c.default_branch = "main" then None else Some c.default_branch)
7777- |> keep_unknown
7878- ~enc:(fun c -> c.packages)
187187+ obj (fun i_handle i_knot -> { i_handle; i_knot })
188188+ |> mem "handle" string ~enc:(fun i -> i.i_handle)
189189+ |> opt_mem "knot" string ~enc:(fun i -> i.i_knot)
190190+ |> finish))
191191+192192+(* Codec for the [packages] table which contains subtree->override mappings *)
193193+let packages_table_codec : (string * Package_config.t) list Tomlt.t =
194194+ Tomlt.(
195195+ Table.(
196196+ obj (fun pkgs -> pkgs)
197197+ |> keep_unknown ~enc:(fun pkgs -> pkgs)
79198 (Mems.assoc Package_config.codec)
80199 |> finish))
81200201201+let codec : t Tomlt.t =
202202+ Tomlt.(
203203+ Table.(
204204+ obj (fun workspace identity packages paths ->
205205+ let packages = Option.value ~default:[] packages in
206206+ let paths = Option.value ~default:default_paths paths in
207207+ let knot = Option.value ~default:default_knot identity.i_knot in
208208+ { root = workspace.w_root; handle = identity.i_handle; knot; packages; paths })
209209+ |> mem "workspace" workspace_codec ~enc:(fun t -> { w_root = t.root })
210210+ |> mem "identity" identity_codec ~enc:(fun t -> { i_handle = t.handle; i_knot = Some t.knot })
211211+ |> opt_mem "packages" packages_table_codec
212212+ ~enc:(fun t -> if t.packages = [] then None else Some t.packages)
213213+ |> opt_mem "paths" paths_codec
214214+ ~enc:(fun t -> if t.paths = default_paths then None else Some t.paths)
215215+ |> finish))
216216+217217+(** {1 Validation} *)
218218+82219type validation_error =
83220 | Path_not_found of string * Fpath.t
84221 | Not_a_directory of string * Fpath.t
···103240 Hint: Use an absolute path starting with / or ~/"
104241 field Fpath.pp path
105242106106-let validate ~fs t =
107107- (* Get the root filesystem for checking absolute paths *)
108108- let root_fs =
109109- let dir, _ = (fs : _ Eio.Path.t) in
110110- (dir, "")
111111- in
112112- let check_absolute field path =
113113- if Fpath.is_abs path then Ok () else Error (Relative_path (field, path))
114114- in
115115- let check_dir field path =
116116- let eio_path = Eio.Path.(root_fs / Fpath.to_string path) in
117117- match Eio.Path.kind ~follow:true eio_path with
118118- | `Directory -> Ok ()
119119- | `Regular_file | `Symbolic_link | `Block_device | `Character_special
120120- | `Fifo | `Socket | `Unknown | `Not_found ->
121121- Error (Not_a_directory (field, path))
122122- | exception Eio.Io (Eio.Fs.E (Not_found _), _) ->
123123- Error (Path_not_found (field, path))
124124- | exception _ -> Error (Path_not_found (field, path))
125125- in
126126- let check_opam_repo path =
127127- let packages_dir = Fpath.(path / "packages") in
128128- let eio_path = Eio.Path.(root_fs / Fpath.to_string packages_dir) in
129129- match Eio.Path.kind ~follow:true eio_path with
130130- | `Directory -> Ok ()
131131- | _ -> Error (Not_an_opam_repo path)
132132- | exception _ -> Error (Not_an_opam_repo path)
133133- in
134134- let ( let* ) = Result.bind in
135135- (* Check all paths are absolute first *)
136136- let* () = check_absolute "opam_repo" t.opam_repo in
137137- let* () = check_absolute "checkouts" t.checkouts in
138138- let* () = check_absolute "monorepo" t.monorepo in
139139- (* Then check opam_repo exists and is valid *)
140140- let* () = check_dir "opam_repo" t.opam_repo in
141141- let* () = check_opam_repo t.opam_repo in
142142- Ok t
243243+(** {1 Loading and Saving} *)
143244144144-let load ~fs ~root_fs path =
145145- try
146146- let config = Tomlt_eio.decode_path_exn codec ~fs (Fpath.to_string path) in
147147- validate ~fs:root_fs config
148148- |> Result.map_error (fun e -> Fmt.str "%a" pp_validation_error e)
149149- with
150150- | Eio.Io _ as e -> Error (Printexc.to_string e)
151151- | Failure msg -> Error (Fmt.str "Invalid config: %s" msg)
152152-153153-let load_xdg ~xdg () =
154154- let config_dir = Xdge.config_dir xdg in
155155- let config_path = Eio.Path.(config_dir / "config.toml") in
156156- try
157157- let config =
158158- Tomlt_eio.decode_path_exn codec ~fs:config_dir (snd config_path)
159159- in
160160- let dir, _ = config_dir in
161161- validate ~fs:(dir, "") config
162162- |> Result.map_error (fun e -> Fmt.str "%a" pp_validation_error e)
163163- with
164164- | Eio.Io _ as e -> Error (Printexc.to_string e)
165165- | Failure msg -> Error (Fmt.str "Invalid config: %s" msg)
245245+let load ~fs () =
246246+ let path = config_file () in
247247+ let path_str = Fpath.to_string path in
248248+ let eio_path = Eio.Path.(fs / path_str) in
249249+ match Eio.Path.kind ~follow:true eio_path with
250250+ | `Regular_file -> (
251251+ try Ok (Tomlt_eio.decode_path_exn codec ~fs path_str) with
252252+ | Failure msg -> Error (Printf.sprintf "Invalid config: %s" msg)
253253+ | exn -> Error (Printf.sprintf "Error loading config: %s" (Printexc.to_string exn)))
254254+ | _ -> Error (Printf.sprintf "Config file not found: %s" path_str)
255255+ | exception _ -> Error (Printf.sprintf "Config file not found: %s" path_str)
166256167167-let save ~fs t path =
257257+let save ~fs t =
258258+ let dir = config_dir () in
259259+ let path = config_file () in
168260 try
261261+ (* Ensure XDG config directory exists *)
262262+ let dir_path = Eio.Path.(fs / Fpath.to_string dir) in
263263+ (try Eio.Path.mkdirs ~perm:0o755 dir_path with Eio.Io _ -> ());
169264 Tomlt_eio.encode_path codec t ~fs (Fpath.to_string path);
170265 Ok ()
171266 with Eio.Io _ as e -> Error (Printexc.to_string e)
172267268268+(** {1 Pretty Printing} *)
269269+173270let pp ppf t =
174271 Fmt.pf ppf
175175- "@[<v>@[<hov 2>paths:@ opam_repo=%a@ checkouts=%a@ monorepo=%a@]@,\
176176- default_branch=%s@,\
272272+ "@[<v>@[<hov 2>workspace:@ root=%a@]@,\
273273+ @[<hov 2>identity:@ handle=%s@ knot=%s@]@,\
274274+ @[<hov 2>paths:@ mono=%s@ src=%s@ verse=%s@]@,\
177275 packages=%d@]"
178178- Fpath.pp t.opam_repo Fpath.pp t.checkouts Fpath.pp t.monorepo
179179- t.default_branch (List.length t.packages)
276276+ Fpath.pp t.root t.handle t.knot
277277+ t.paths.mono t.paths.src t.paths.verse
278278+ (List.length t.packages)
+117-67
lib/config.mli
···11-(** Configuration management for monopam.
11+(** Unified configuration for monopam.
2233- Configuration is stored in TOML format and loaded from XDG standard
44- locations or a user-specified path. The config file specifies paths to the
55- opam overlay, individual checkouts, and the monorepo, along with optional
66- per-package overrides. *)
33+ Configuration is stored in TOML format at [~/.config/monopam/opamverse.toml].
44+55+ The config stores:
66+ - Workspace root and custom paths
77+ - User identity (handle, knot)
88+ - Per-package overrides
99+1010+ Standard paths derived from root:
1111+ - [mono/] - user's monorepo
1212+ - [src/] - git checkouts for subtrees
1313+ - [opam-repo/] - opam overlay repository
1414+ - [verse/] - other members' monorepos *)
715816(** {1 Types} *)
917···1624 (** [branch t] returns the branch override for this package, if set. *)
1725end
18262727+(** Configurable paths within the workspace.
2828+2929+ By default, paths are:
3030+ - [mono = "mono"] - monorepo directory
3131+ - [src = "src"] - source checkouts directory
3232+ - [verse = "verse"] - verse directory
3333+3434+ Set [mono = "."] to have packages at the root level. *)
3535+type paths = {
3636+ mono : string; (** Monorepo directory (default: "mono") *)
3737+ src : string; (** Source checkouts directory (default: "src") *)
3838+ verse : string; (** Verse directory (default: "verse") *)
3939+}
4040+4141+val default_paths : paths
4242+(** Default paths configuration. *)
4343+1944type t
2045(** The main configuration. *)
21462222-(** {1 Paths Configuration} *)
4747+(** {1 Accessors} *)
23482424-(** Path-related accessors. *)
2525-module Paths : sig
2626- val opam_repo : t -> Fpath.t
2727- (** [opam_repo t] returns the path to the opam overlay repository. *)
2828-2929- val checkouts : t -> Fpath.t
3030- (** [checkouts t] returns the parent directory where individual package
3131- checkouts are stored. *)
4949+val root : t -> Fpath.t
5050+(** [root t] returns the workspace root directory. *)
32513333- val monorepo : t -> Fpath.t
3434- (** [monorepo t] returns the path to the monorepo directory. *)
3535-end
5252+val handle : t -> string
5353+(** [handle t] returns the user's handle. *)
36543737-(** {1 Options} *)
5555+val knot : t -> string
5656+(** [knot t] returns the git push server hostname (e.g., "git.recoil.org").
5757+ Used for converting tangled URLs to SSH push URLs. *)
38583939-val default_branch : t -> string
4040-(** [default_branch t] returns the default git branch to track.
5959+val paths : t -> paths
6060+(** [paths t] returns the paths configuration. *)
41614242- Defaults to "main" if not specified. *)
6262+val packages : t -> (string * Package_config.t) list
6363+(** [packages t] returns the list of package overrides. *)
43644465val package_config : t -> string -> Package_config.t option
4566(** [package_config t name] returns package-specific configuration overrides for
4667 the named package, if any exist. *)
47684848-(** {1 Validation} *)
6969+(** {1 Derived Paths} *)
49705050-(** Errors that can occur when validating configuration paths. *)
5151-type validation_error =
5252- | Path_not_found of string * Fpath.t (** A configured path does not exist *)
5353- | Not_a_directory of string * Fpath.t
5454- (** A configured path is not a directory *)
5555- | Not_an_opam_repo of Fpath.t
5656- (** The opam_repo path is not a valid opam repository (missing packages/
5757- directory) *)
5858- | Invalid_path of string * string (** A path string could not be parsed *)
5959- | Relative_path of string * Fpath.t
6060- (** A configured path is relative but must be absolute *)
7171+val default_branch : string
7272+(** Default git branch, always ["main"]. *)
61736262-val pp_validation_error : validation_error Fmt.t
6363-(** [pp_validation_error] formats validation errors. *)
7474+val mono_path : t -> Fpath.t
7575+(** [mono_path t] returns the path to the user's monorepo. *)
64766565-(** {1 Loading and Saving} *)
7777+val src_path : t -> Fpath.t
7878+(** [src_path t] returns the path to git checkouts. *)
66796767-val load :
6868- fs:_ Eio.Path.t -> root_fs:_ Eio.Path.t -> Fpath.t -> (t, string) result
6969-(** [load ~fs ~root_fs path] loads configuration from the specified TOML file.
8080+val opam_repo_path : t -> Fpath.t
8181+(** [opam_repo_path t] returns the path to the opam overlay. *)
70827171- Validates that paths exist and are valid. Supports tilde expansion for paths
7272- (e.g., [~/src/...]).
8383+val verse_path : t -> Fpath.t
8484+(** [verse_path t] returns the path to tracked members' monorepos. *)
73857474- @param fs The filesystem path for locating the config file
7575- @param root_fs The root filesystem for validating absolute paths in config
8686+(** {1 Backwards Compatibility} *)
76877777- Returns [Error msg] if the file cannot be read, parsed, or if validation
7878- fails. *)
8888+(** Path accessors using old naming convention. *)
8989+module Paths : sig
9090+ val opam_repo : t -> Fpath.t
9191+ (** Alias for [opam_repo_path]. *)
79928080-val load_xdg : xdg:Xdge.t -> unit -> (t, string) result
8181-(** [load_xdg ~xdg ()] loads configuration from XDG standard locations.
9393+ val checkouts : t -> Fpath.t
9494+ (** Alias for [src_path]. *)
82958383- Searches for "config.toml" in the monopam XDG config directory. Validates
8484- that paths exist and are valid. Supports tilde expansion.
9696+ val monorepo : t -> Fpath.t
9797+ (** Alias for [mono_path]. *)
9898+end
85998686- Returns [Error msg] if no config file is found, parsing fails, or if
8787- validation fails.
100100+(** {1 XDG Paths} *)
881018989- @param xdg The Xdge context for "monopam" application *)
102102+val config_dir : unit -> Fpath.t
103103+(** [config_dir ()] returns the XDG config directory for monopam
104104+ (~/.config/monopam). *)
105105+106106+val data_dir : unit -> Fpath.t
107107+(** [data_dir ()] returns the XDG data directory for monopam
108108+ (~/.local/share/monopam). *)
109109+110110+val cache_dir : unit -> Fpath.t
111111+(** [cache_dir ()] returns the XDG cache directory for monopam
112112+ (~/.cache/monopam). *)
113113+114114+val config_file : unit -> Fpath.t
115115+(** [config_file ()] returns the path to the config file
116116+ (~/.config/monopam/opamverse.toml). *)
901179191-val save : fs:_ Eio.Path.t -> t -> Fpath.t -> (unit, string) result
9292-(** [save ~fs t path] writes the configuration to the specified path. *)
118118+val registry_path : unit -> Fpath.t
119119+(** [registry_path ()] returns the path to the cloned registry git repo
120120+ (~/.local/share/monopam/opamverse-registry). *)
9312194122(** {1 Construction} *)
9512396124val create :
9797- opam_repo:Fpath.t ->
9898- checkouts:Fpath.t ->
9999- monorepo:Fpath.t ->
100100- ?default_branch:string ->
125125+ root:Fpath.t ->
126126+ handle:string ->
127127+ ?knot:string ->
128128+ ?packages:(string * Package_config.t) list ->
129129+ ?paths:paths ->
101130 unit ->
102131 t
103103-(** [create ~opam_repo ~checkouts ~monorepo ?default_branch ()] creates a new
104104- configuration with the specified paths.
132132+(** [create ~root ~handle ?knot ?packages ?paths ()] creates a new configuration.
105133106106- @param opam_repo Path to the opam overlay repository
107107- @param checkouts Parent directory for individual git checkouts
108108- @param monorepo Path to the monorepo
109109- @param default_branch Default branch to track (default: "main") *)
134134+ @param root Workspace root directory (absolute path)
135135+ @param handle User's handle
136136+ @param knot Git push server hostname. If not provided, derived from handle
137137+ @param packages Optional list of package overrides
138138+ @param paths Optional custom paths configuration *)
110139111140val with_package_override : t -> name:string -> ?branch:string -> unit -> t
112141(** [with_package_override t ~name ?branch ()] returns a new config
113113- with overrides for the named package.
142142+ with overrides for the named package. *)
114143115115- @param branch Override the git branch for this package
144144+(** {1 Validation} *)
116145117117- Note: For dev-repo URL overrides, use [sources.toml] in the monorepo root. *)
146146+type validation_error =
147147+ | Path_not_found of string * Fpath.t
148148+ | Not_a_directory of string * Fpath.t
149149+ | Not_an_opam_repo of Fpath.t
150150+ | Invalid_path of string * string
151151+ | Relative_path of string * Fpath.t
152152+153153+val pp_validation_error : validation_error Fmt.t
154154+(** [pp_validation_error] formats validation errors. *)
155155+156156+(** {1 Loading and Saving} *)
157157+158158+val load : fs:_ Eio.Path.t -> unit -> (t, string) result
159159+(** [load ~fs ()] loads the configuration from the XDG config file.
160160+161161+ @param fs Eio filesystem *)
162162+163163+val save : fs:_ Eio.Path.t -> t -> (unit, string) result
164164+(** [save ~fs config] saves the configuration to the XDG config file.
165165+166166+ @param fs Eio filesystem
167167+ @param config Configuration to save *)
118168119169(** {1 Pretty Printing} *)
120170
+482
lib/fork_join.ml
···88 | Subtree_already_exists of string
99 | No_opam_files of string
1010 | Verse_error of Verse.error
1111+ | User_cancelled
1212+1313+(** {1 Action Types} *)
1414+1515+(** An action to be performed during fork/join *)
1616+type action =
1717+ | Check_remote_exists of string (** URL - informational check *)
1818+ | Create_directory of Fpath.t
1919+ | Git_init of Fpath.t
2020+ | Git_clone of { url: string; dest: Fpath.t; branch: string }
2121+ | Git_subtree_split of { repo: Fpath.t; prefix: string }
2222+ | Git_subtree_add of { repo: Fpath.t; prefix: string; url: Uri.t; branch: string }
2323+ | Git_add_remote of { repo: Fpath.t; name: string; url: string }
2424+ | Git_push_ref of { repo: Fpath.t; target: string; ref_spec: string }
2525+ | Git_checkout of { repo: Fpath.t; branch: string }
2626+ | Git_branch_rename of { repo: Fpath.t; new_name: string } (** Rename current branch *)
2727+ | Copy_directory of { src: Fpath.t; dest: Fpath.t }
2828+ | Git_add_all of Fpath.t
2929+ | Git_commit of { repo: Fpath.t; message: string }
3030+ | Update_sources_toml of { path: Fpath.t; name: string; entry: Sources_registry.entry }
3131+3232+(** Discovery information gathered during planning *)
3333+type discovery = {
3434+ mono_exists: bool;
3535+ src_exists: bool;
3636+ has_subtree_history: bool; (** Can we git subtree split? *)
3737+ remote_accessible: bool option; (** None = not checked, Some = result *)
3838+ opam_files: string list;
3939+ local_path_is_repo: bool option; (** For join from local dir *)
4040+}
4141+4242+(** A complete action plan *)
4343+type 'a action_plan = {
4444+ discovery: discovery;
4545+ actions: action list;
4646+ result: 'a; (** What we'll return on success *)
4747+ dry_run: bool;
4848+}
11491250let pp_error ppf = function
1351 | Config_error msg -> Fmt.pf ppf "Configuration error: %s" msg
···1755 | Subtree_already_exists name -> Fmt.pf ppf "Subtree already exists in monorepo: mono/%s" name
1856 | No_opam_files name -> Fmt.pf ppf "No .opam files found in subtree: %s" name
1957 | Verse_error e -> Fmt.pf ppf "Verse error: %a" Verse.pp_error e
5858+ | User_cancelled -> Fmt.pf ppf "Operation cancelled by user"
20592160let error_hint = function
2261 | Config_error _ ->
···3372 | No_opam_files name ->
3473 Some (Fmt.str "Add a .opam file to mono/%s before forking" name)
3574 | Verse_error e -> Verse.error_hint e
7575+ | User_cancelled -> None
7676+7777+(** {1 Pretty Printers for Actions and Discovery} *)
7878+7979+let pp_action ppf = function
8080+ | Check_remote_exists url ->
8181+ Fmt.pf ppf "Check remote accessible: %s" url
8282+ | Create_directory path ->
8383+ Fmt.pf ppf "Create directory: %a" Fpath.pp path
8484+ | Git_init path ->
8585+ Fmt.pf ppf "Initialize git repository: %a" Fpath.pp path
8686+ | Git_clone { url; dest; branch } ->
8787+ Fmt.pf ppf "Clone %s (branch: %s) to %a" url branch Fpath.pp dest
8888+ | Git_subtree_split { repo = _; prefix } ->
8989+ Fmt.pf ppf "Split subtree history for '%s'" prefix
9090+ | Git_subtree_add { repo = _; prefix; url; branch } ->
9191+ Fmt.pf ppf "Add subtree '%s' from %s (branch: %s)" prefix (Uri.to_string url) branch
9292+ | Git_add_remote { repo = _; name; url } ->
9393+ Fmt.pf ppf "Add remote '%s' -> %s" name url
9494+ | Git_push_ref { repo = _; target; ref_spec } ->
9595+ Fmt.pf ppf "Push %s to %s" ref_spec target
9696+ | Git_checkout { repo = _; branch } ->
9797+ Fmt.pf ppf "Checkout branch '%s'" branch
9898+ | Git_branch_rename { repo = _; new_name } ->
9999+ Fmt.pf ppf "Rename current branch to '%s'" new_name
100100+ | Copy_directory { src; dest } ->
101101+ Fmt.pf ppf "Copy files from %a to %a" Fpath.pp src Fpath.pp dest
102102+ | Git_add_all path ->
103103+ Fmt.pf ppf "Stage all changes in %a" Fpath.pp path
104104+ | Git_commit { repo = _; message } ->
105105+ Fmt.pf ppf "Create commit: %s" message
106106+ | Update_sources_toml { path = _; name; entry = _ } ->
107107+ Fmt.pf ppf "Update sources.toml for '%s'" name
108108+109109+let pp_discovery ppf d =
110110+ Fmt.pf ppf "@[<v>";
111111+ Fmt.pf ppf " mono/<name>/: %s@,"
112112+ (if d.mono_exists then "exists" else "does not exist");
113113+ Fmt.pf ppf " src/<name>/: %s@,"
114114+ (if d.src_exists then "exists" else "does not exist");
115115+ Fmt.pf ppf " Subtree history: %s@,"
116116+ (if d.has_subtree_history then "present" else "none (fresh package)");
117117+ (match d.remote_accessible with
118118+ | None -> ()
119119+ | Some true -> Fmt.pf ppf " Remote accessible: yes@,"
120120+ | Some false -> Fmt.pf ppf " Remote accessible: no@,");
121121+ (match d.local_path_is_repo with
122122+ | None -> ()
123123+ | Some true -> Fmt.pf ppf " Is git repo: yes@,"
124124+ | Some false -> Fmt.pf ppf " Is git repo: no@,");
125125+ if d.opam_files <> [] then
126126+ Fmt.pf ppf " Packages found: %a@," Fmt.(list ~sep:(any ", ") string) d.opam_files;
127127+ Fmt.pf ppf "@]"
128128+129129+let pp_action_plan : type a. a Fmt.t -> a action_plan Fmt.t = fun pp_result ppf plan ->
130130+ Fmt.pf ppf "@[<v>Discovery:@,%a@,@,Actions to perform:@," pp_discovery plan.discovery;
131131+ List.iteri (fun i action ->
132132+ Fmt.pf ppf " %d. %a@," (i + 1) pp_action action
133133+ ) plan.actions;
134134+ if plan.dry_run then
135135+ Fmt.pf ppf "@,(dry-run mode - no changes will be made)@,";
136136+ Fmt.pf ppf "@,Expected result:@, %a@]" pp_result plan.result
3613737138let pp_error_with_hint ppf e =
38139 pp_error ppf e;
···130231 match String.rindex_opt path '/' with
131232 | Some i -> String.sub path (i + 1) (String.length path - i - 1)
132233 | None -> path
234234+235235+(** {1 Detection Functions} *)
236236+237237+(** Determine if input is a local path or URL *)
238238+let is_local_path s =
239239+ (* It's a URL if it starts with a scheme or looks like SSH URL *)
240240+ not (String.starts_with ~prefix:"http://" s ||
241241+ String.starts_with ~prefix:"https://" s ||
242242+ String.starts_with ~prefix:"git://" s ||
243243+ String.starts_with ~prefix:"git@" s ||
244244+ String.starts_with ~prefix:"ssh://" s ||
245245+ String.starts_with ~prefix:"git+" s)
246246+247247+(** Copy a directory tree recursively *)
248248+let copy_directory ~fs ~src ~dest =
249249+ let src_eio = Eio.Path.(fs / Fpath.to_string src) in
250250+ let dest_eio = Eio.Path.(fs / Fpath.to_string dest) in
251251+ let rec copy_rec src_path dest_path =
252252+ match Eio.Path.kind ~follow:false src_path with
253253+ | `Directory ->
254254+ (try Eio.Path.mkdirs ~perm:0o755 dest_path with Eio.Io _ -> ());
255255+ List.iter (fun name ->
256256+ (* Skip .git directory to avoid copying git internals *)
257257+ if name <> ".git" then begin
258258+ let src_child = Eio.Path.(src_path / name) in
259259+ let dest_child = Eio.Path.(dest_path / name) in
260260+ copy_rec src_child dest_child
261261+ end
262262+ ) (Eio.Path.read_dir src_path)
263263+ | `Regular_file ->
264264+ let content = Eio.Path.load src_path in
265265+ Eio.Path.save ~create:(`Or_truncate 0o644) dest_path content
266266+ | `Symbolic_link ->
267267+ (* Read symlink target and recreate it *)
268268+ let target = Eio.Path.read_link src_path in
269269+ (try Unix.symlink target (snd dest_path) with _ -> ())
270270+ | _ -> () (* Skip other file types *)
271271+ | exception _ -> ()
272272+ in
273273+ copy_rec src_eio dest_eio
274274+275275+(** {1 Plan Builders} *)
276276+277277+(** Build a fork plan - handles both subtree and fresh package scenarios *)
278278+let plan_fork ~proc ~fs ~config ~name ?push_url ?(dry_run = false) () =
279279+ let monorepo = Verse_config.mono_path config in
280280+ let checkouts = Verse_config.src_path config in
281281+ let prefix = name in
282282+ let subtree_path = Fpath.(monorepo / prefix) in
283283+ let src_path = Fpath.(checkouts / name) in
284284+285285+ (* Gather discovery information *)
286286+ let mono_exists = Git.Subtree.exists ~fs ~repo:monorepo ~prefix in
287287+ let src_exists = is_directory ~fs src_path in
288288+ let has_subtree_hist =
289289+ if mono_exists then Git.has_subtree_history ~proc ~fs ~repo:monorepo ~prefix ()
290290+ else false
291291+ in
292292+ let opam_files =
293293+ if mono_exists then find_opam_files ~fs subtree_path
294294+ else []
295295+ in
296296+297297+ let discovery = {
298298+ mono_exists;
299299+ src_exists;
300300+ has_subtree_history = has_subtree_hist;
301301+ remote_accessible = None; (* Could check if push_url is accessible *)
302302+ opam_files;
303303+ local_path_is_repo = None;
304304+ } in
305305+306306+ (* Validation *)
307307+ if not mono_exists then
308308+ Error (Subtree_not_found name)
309309+ else if src_exists then
310310+ Error (Src_already_exists name)
311311+ else if opam_files = [] then
312312+ Error (No_opam_files name)
313313+ else begin
314314+ (* Build actions based on whether we have subtree history *)
315315+ let actions =
316316+ if has_subtree_hist then begin
317317+ (* Subtree fork (existing behavior) *)
318318+ let base_actions = [
319319+ Create_directory checkouts;
320320+ Git_subtree_split { repo = monorepo; prefix };
321321+ Git_init src_path;
322322+ Git_add_remote { repo = src_path; name = "mono"; url = Fpath.to_string monorepo };
323323+ Git_push_ref { repo = monorepo; target = Fpath.to_string src_path; ref_spec = "SPLIT_COMMIT:refs/heads/main" };
324324+ Git_checkout { repo = src_path; branch = "main" };
325325+ ] in
326326+ let remote_actions = match push_url with
327327+ | Some url -> [
328328+ Git_add_remote { repo = src_path; name = "origin"; url };
329329+ Update_sources_toml {
330330+ path = Fpath.(monorepo / "sources.toml");
331331+ name;
332332+ entry = Sources_registry.{
333333+ url = normalize_git_url url;
334334+ upstream = None;
335335+ branch = Some "main";
336336+ reason = None;
337337+ origin = Some Fork;
338338+ };
339339+ };
340340+ ]
341341+ | None -> []
342342+ in
343343+ base_actions @ remote_actions
344344+ end else begin
345345+ (* Fresh package fork (NEW behavior) *)
346346+ let base_actions = [
347347+ Create_directory checkouts;
348348+ Create_directory src_path;
349349+ Git_init src_path;
350350+ Copy_directory { src = subtree_path; dest = src_path };
351351+ Git_add_all src_path;
352352+ Git_commit { repo = src_path; message = Fmt.str "Initial commit of %s" name };
353353+ ] in
354354+ let remote_actions = match push_url with
355355+ | Some url -> [
356356+ Git_add_remote { repo = src_path; name = "origin"; url };
357357+ Update_sources_toml {
358358+ path = Fpath.(monorepo / "sources.toml");
359359+ name;
360360+ entry = Sources_registry.{
361361+ url = normalize_git_url url;
362362+ upstream = None;
363363+ branch = Some "main";
364364+ reason = None;
365365+ origin = Some Fork;
366366+ };
367367+ };
368368+ ]
369369+ | None -> []
370370+ in
371371+ base_actions @ remote_actions
372372+ end
373373+ in
374374+375375+ let result = {
376376+ name;
377377+ split_commit = if has_subtree_hist then "(will be computed)" else "(fresh package)";
378378+ src_path;
379379+ push_url;
380380+ packages_created = opam_files;
381381+ } in
382382+383383+ Ok { discovery; actions; result; dry_run }
384384+ end
385385+386386+(** Build a join plan - handles both URL and local path *)
387387+let plan_join ~proc ~fs ~config ~source ?name ?upstream ?(dry_run = false) () =
388388+ let is_local = is_local_path source in
389389+ let name = match name with Some n -> n | None -> name_from_url source in
390390+ let monorepo = Verse_config.mono_path config in
391391+ let checkouts = Verse_config.src_path config in
392392+ let prefix = name in
393393+ let src_path = Fpath.(checkouts / name) in
394394+395395+ (* Gather discovery information *)
396396+ let subtree_exists = Git.Subtree.exists ~fs ~repo:monorepo ~prefix in
397397+ let src_exists = is_directory ~fs src_path in
398398+ let local_is_repo =
399399+ if is_local then begin
400400+ match Fpath.of_string source with
401401+ | Ok path -> Some (Git.is_repo ~proc ~fs path)
402402+ | Error _ -> Some false
403403+ end else None
404404+ in
405405+406406+ let discovery = {
407407+ mono_exists = subtree_exists;
408408+ src_exists;
409409+ has_subtree_history = false;
410410+ remote_accessible = None;
411411+ opam_files = []; (* Will be discovered after join *)
412412+ local_path_is_repo = local_is_repo;
413413+ } in
414414+415415+ (* Validation *)
416416+ if subtree_exists then
417417+ Error (Subtree_already_exists name)
418418+ else begin
419419+ let branch = Verse_config.default_branch in
420420+ let actions =
421421+ if is_local then begin
422422+ (* Join from local directory *)
423423+ match Fpath.of_string source with
424424+ | Error (`Msg msg) -> raise (Invalid_argument msg)
425425+ | Ok local_path ->
426426+ let has_repo = Option.value ~default:false local_is_repo in
427427+ if has_repo then
428428+ (* Local git repo - use it directly *)
429429+ [
430430+ Create_directory checkouts;
431431+ Copy_directory { src = local_path; dest = src_path };
432432+ Git_subtree_add { repo = monorepo; prefix; url = Uri.of_string (Fpath.to_string src_path); branch };
433433+ ]
434434+ else
435435+ (* Local directory without git - init and commit first *)
436436+ [
437437+ Create_directory checkouts;
438438+ Create_directory src_path;
439439+ Git_init src_path;
440440+ Copy_directory { src = local_path; dest = src_path };
441441+ Git_add_all src_path;
442442+ Git_commit { repo = src_path; message = Fmt.str "Initial commit of %s" name };
443443+ Git_branch_rename { repo = src_path; new_name = branch }; (* Ensure branch is named correctly *)
444444+ Git_subtree_add { repo = monorepo; prefix; url = Uri.of_string (Fpath.to_string src_path); branch };
445445+ ]
446446+ end else begin
447447+ (* Join from URL (existing behavior) *)
448448+ let url_uri = Uri.of_string source in
449449+ let base_actions = [
450450+ Create_directory checkouts;
451451+ Git_clone { url = source; dest = src_path; branch };
452452+ Git_subtree_add { repo = monorepo; prefix; url = url_uri; branch };
453453+ ] in
454454+ let sources_actions = match upstream with
455455+ | Some _ ->
456456+ [Update_sources_toml {
457457+ path = Fpath.(monorepo / "sources.toml");
458458+ name;
459459+ entry = Sources_registry.{
460460+ url = normalize_git_url source;
461461+ upstream = Option.map normalize_git_url upstream;
462462+ branch = Some branch;
463463+ reason = None;
464464+ origin = Some Join;
465465+ };
466466+ }]
467467+ | None -> []
468468+ in
469469+ base_actions @ sources_actions
470470+ end
471471+ in
472472+473473+ (* Peek at opam files if local *)
474474+ let opam_preview =
475475+ if is_local then
476476+ match Fpath.of_string source with
477477+ | Ok path -> find_opam_files ~fs path
478478+ | Error _ -> []
479479+ else []
480480+ in
481481+482482+ let result = {
483483+ name;
484484+ source_url = source;
485485+ upstream_url = upstream;
486486+ packages_added = opam_preview;
487487+ from_handle = None;
488488+ } in
489489+490490+ Ok { discovery = { discovery with opam_files = opam_preview }; actions; result; dry_run }
491491+ end
492492+493493+(** {1 Plan Execution} *)
494494+495495+(** State tracked during plan execution *)
496496+type exec_state = {
497497+ mutable split_commit: string option;
498498+}
499499+500500+(** Execute a single action *)
501501+let execute_action ~proc ~fs ~state action =
502502+ match action with
503503+ | Check_remote_exists _url ->
504504+ (* Informational only - always succeeds *)
505505+ Ok ()
506506+ | Create_directory path ->
507507+ ensure_dir ~fs path;
508508+ Ok ()
509509+ | Git_init path ->
510510+ Git.init ~proc ~fs path |> Result.map_error (fun e -> Git_error e)
511511+ | Git_clone { url; dest; branch } ->
512512+ Git.clone ~proc ~fs ~url:(Uri.of_string url) ~branch dest
513513+ |> Result.map_error (fun e -> Git_error e)
514514+ | Git_subtree_split { repo; prefix } ->
515515+ Git.Subtree.split ~proc ~fs ~repo ~prefix ()
516516+ |> Result.map (fun commit -> state.split_commit <- Some commit)
517517+ |> Result.map_error (fun e -> Git_error e)
518518+ | Git_subtree_add { repo; prefix; url; branch } ->
519519+ Git.Subtree.add ~proc ~fs ~repo ~prefix ~url ~branch ()
520520+ |> Result.map_error (fun e -> Git_error e)
521521+ | Git_add_remote { repo; name; url } ->
522522+ Git.add_remote ~proc ~fs ~name ~url repo
523523+ |> Result.map_error (fun e -> Git_error e)
524524+ | Git_push_ref { repo; target; ref_spec } ->
525525+ (* Replace SPLIT_COMMIT placeholder with actual commit if available *)
526526+ let ref_spec =
527527+ match state.split_commit with
528528+ | Some commit -> String.concat "" (String.split_on_char 'S' (String.concat commit (String.split_on_char 'S' ref_spec)))
529529+ |> fun s -> if String.starts_with ~prefix:"PLIT_COMMIT" s then
530530+ Option.value ~default:ref_spec state.split_commit ^ String.sub s 11 (String.length s - 11)
531531+ else s
532532+ | None -> ref_spec
533533+ in
534534+ (* Better replacement: look for SPLIT_COMMIT literal *)
535535+ let ref_spec =
536536+ match state.split_commit with
537537+ | Some commit ->
538538+ if String.length ref_spec >= 12 && String.sub ref_spec 0 12 = "SPLIT_COMMIT" then
539539+ commit ^ String.sub ref_spec 12 (String.length ref_spec - 12)
540540+ else ref_spec
541541+ | None -> ref_spec
542542+ in
543543+ Git.push_ref ~proc ~fs ~repo ~target ~ref_spec ()
544544+ |> Result.map_error (fun e -> Git_error e)
545545+ | Git_checkout { repo; branch } ->
546546+ Git.checkout ~proc ~fs ~branch repo
547547+ |> Result.map_error (fun e -> Git_error e)
548548+ | Git_branch_rename { repo; new_name } ->
549549+ Git.branch_rename ~proc ~fs ~new_name repo
550550+ |> Result.map_error (fun e -> Git_error e)
551551+ | Copy_directory { src; dest } ->
552552+ copy_directory ~fs ~src ~dest;
553553+ Ok ()
554554+ | Git_add_all path ->
555555+ Git.add_all ~proc ~fs path
556556+ |> Result.map_error (fun e -> Git_error e)
557557+ | Git_commit { repo; message } ->
558558+ Git.commit ~proc ~fs ~message repo
559559+ |> Result.map_error (fun e -> Git_error e)
560560+ | Update_sources_toml { path; name; entry } ->
561561+ let sources =
562562+ match Sources_registry.load ~fs:(fs :> _ Eio.Path.t) path with
563563+ | Ok s -> s
564564+ | Error _ -> Sources_registry.empty
565565+ in
566566+ let sources = Sources_registry.add sources ~subtree:name entry in
567567+ (match Sources_registry.save ~fs:(fs :> _ Eio.Path.t) path sources with
568568+ | Ok () -> Ok ()
569569+ | Error msg -> Error (Config_error (Fmt.str "Failed to update sources.toml: %s" msg)))
570570+571571+(** Execute a complete fork action plan *)
572572+let execute_fork_plan ~proc ~fs plan =
573573+ if plan.dry_run then
574574+ Ok plan.result
575575+ else begin
576576+ let state = { split_commit = None } in
577577+ let rec run_actions = function
578578+ | [] -> Ok ()
579579+ | action :: rest ->
580580+ match execute_action ~proc ~fs ~state action with
581581+ | Error e -> Error e
582582+ | Ok () -> run_actions rest
583583+ in
584584+ match run_actions plan.actions with
585585+ | Error e -> Error e
586586+ | Ok () ->
587587+ (* Update result with actual split commit if available *)
588588+ let result : fork_result =
589589+ match state.split_commit with
590590+ | Some commit -> { plan.result with split_commit = commit }
591591+ | None -> plan.result
592592+ in
593593+ Ok result
594594+ end
595595+596596+(** Execute a complete join action plan *)
597597+let execute_join_plan ~proc ~fs plan =
598598+ if plan.dry_run then
599599+ Ok plan.result
600600+ else begin
601601+ let state = { split_commit = None } in
602602+ let rec run_actions = function
603603+ | [] -> Ok ()
604604+ | action :: rest ->
605605+ match execute_action ~proc ~fs ~state action with
606606+ | Error e -> Error e
607607+ | Ok () -> run_actions rest
608608+ in
609609+ match run_actions plan.actions with
610610+ | Error e -> Error e
611611+ | Ok () -> Ok plan.result
612612+ end
613613+614614+(** {1 Legacy API (using plans internally)} *)
133615134616let fork ~proc ~fs ~config ~name ?push_url ?(dry_run = false) () =
135617 let monorepo = Verse_config.mono_path config in
+145-14
lib/fork_join.mli
···44 - Fork: Split a monorepo subtree into its own repository in src/
55 - Join: Bring an external repository into the monorepo as a subtree
6677- Both operations update sources.toml to track the origin of each source. *)
77+ Both operations update sources.toml to track the origin of each source.
88+99+ The module supports an action-based workflow where commands:
1010+ 1. Analyze current state
1111+ 2. Build a list of actions with reasoning
1212+ 3. Display the plan with discovery details
1313+ 4. Prompt for confirmation (or skip with [--yes])
1414+ 5. Execute actions sequentially *)
815916(** {1 Error Types} *)
1017···1623 | Subtree_already_exists of string (** Subtree already exists in monorepo *)
1724 | No_opam_files of string (** No .opam files found in subtree *)
1825 | Verse_error of Verse.error (** Error from verse operations *)
2626+ | User_cancelled (** User declined to proceed *)
19272028val pp_error : error Fmt.t
2129(** [pp_error] formats errors. *)
···2634val error_hint : error -> string option
2735(** [error_hint e] returns a hint string for the given error, if available. *)
28362929-(** {1 Fork Operations} *)
3737+(** {1 Action Types} *)
3838+3939+(** An action to be performed during fork/join *)
4040+type action =
4141+ | Check_remote_exists of string (** URL - informational check *)
4242+ | Create_directory of Fpath.t
4343+ | Git_init of Fpath.t
4444+ | Git_clone of { url: string; dest: Fpath.t; branch: string }
4545+ | Git_subtree_split of { repo: Fpath.t; prefix: string }
4646+ | Git_subtree_add of { repo: Fpath.t; prefix: string; url: Uri.t; branch: string }
4747+ | Git_add_remote of { repo: Fpath.t; name: string; url: string }
4848+ | Git_push_ref of { repo: Fpath.t; target: string; ref_spec: string }
4949+ | Git_checkout of { repo: Fpath.t; branch: string }
5050+ | Git_branch_rename of { repo: Fpath.t; new_name: string } (** Rename current branch *)
5151+ | Copy_directory of { src: Fpath.t; dest: Fpath.t }
5252+ | Git_add_all of Fpath.t
5353+ | Git_commit of { repo: Fpath.t; message: string }
5454+ | Update_sources_toml of { path: Fpath.t; name: string; entry: Sources_registry.entry }
5555+5656+(** Discovery information gathered during planning *)
5757+type discovery = {
5858+ mono_exists: bool; (** Does mono/<name>/ exist? *)
5959+ src_exists: bool; (** Does src/<name>/ exist? *)
6060+ has_subtree_history: bool; (** Can we git subtree split? *)
6161+ remote_accessible: bool option; (** None = not checked, Some = result *)
6262+ opam_files: string list; (** Package names found from .opam files *)
6363+ local_path_is_repo: bool option; (** For join from local dir *)
6464+}
6565+6666+(** A complete action plan *)
6767+type 'a action_plan = {
6868+ discovery: discovery;
6969+ actions: action list;
7070+ result: 'a; (** What we'll return on success *)
7171+ dry_run: bool;
7272+}
7373+7474+val pp_action : action Fmt.t
7575+(** [pp_action] formats a single action. *)
7676+7777+val pp_discovery : discovery Fmt.t
7878+(** [pp_discovery] formats discovery information. *)
7979+8080+val pp_action_plan : 'a Fmt.t -> 'a action_plan Fmt.t
8181+(** [pp_action_plan pp_result] formats a complete action plan. *)
8282+8383+(** {1 Detection Functions} *)
8484+8585+val is_local_path : string -> bool
8686+(** [is_local_path s] returns true if [s] looks like a local filesystem path
8787+ rather than a URL. *)
8888+8989+(** {1 Result Types} *)
30903191(** Result of a fork operation. *)
3292type fork_result = {
···40100val pp_fork_result : fork_result Fmt.t
41101(** [pp_fork_result] formats a fork result. *)
42102103103+(** Result of a join operation. *)
104104+type join_result = {
105105+ name : string; (** Subtree/repository name *)
106106+ source_url : string; (** URL the repository was cloned from *)
107107+ upstream_url : string option; (** Original upstream if this is a fork *)
108108+ packages_added : string list; (** Package names from .opam files *)
109109+ from_handle : string option; (** Verse handle if joined from verse *)
110110+}
111111+112112+val pp_join_result : join_result Fmt.t
113113+(** [pp_join_result] formats a join result. *)
114114+115115+(** {1 Plan Builders} *)
116116+117117+val plan_fork :
118118+ proc:_ Eio.Process.mgr ->
119119+ fs:Eio.Fs.dir_ty Eio.Path.t ->
120120+ config:Verse_config.t ->
121121+ name:string ->
122122+ ?push_url:string ->
123123+ ?dry_run:bool ->
124124+ unit ->
125125+ (fork_result action_plan, error) result
126126+(** [plan_fork ~proc ~fs ~config ~name ?push_url ?dry_run ()] builds a fork plan.
127127+128128+ This analyzes the current state and builds a list of actions to:
129129+ - For subtrees with history: split subtree, create repo, push history
130130+ - For fresh packages: create repo, copy files, initial commit
131131+132132+ The plan can be displayed to the user and executed with [execute_fork_plan].
133133+134134+ @param name Name of the subtree to fork (directory name under mono/)
135135+ @param push_url Optional remote URL to add as origin for pushing
136136+ @param dry_run If true, mark plan as dry-run (execute will skip actions) *)
137137+138138+val plan_join :
139139+ proc:_ Eio.Process.mgr ->
140140+ fs:Eio.Fs.dir_ty Eio.Path.t ->
141141+ config:Verse_config.t ->
142142+ source:string ->
143143+ ?name:string ->
144144+ ?upstream:string ->
145145+ ?dry_run:bool ->
146146+ unit ->
147147+ (join_result action_plan, error) result
148148+(** [plan_join ~proc ~fs ~config ~source ?name ?upstream ?dry_run ()] builds a join plan.
149149+150150+ This analyzes the source (URL or local path) and builds a list of actions to:
151151+ - For URLs: clone repo, add subtree
152152+ - For local directories: copy/init repo, add subtree
153153+154154+ The plan can be displayed to the user and executed with [execute_join_plan].
155155+156156+ @param source Git URL or local filesystem path to join
157157+ @param name Override the subtree directory name (default: derived from source)
158158+ @param upstream Original upstream URL if this is your fork
159159+ @param dry_run If true, mark plan as dry-run (execute will skip actions) *)
160160+161161+(** {1 Plan Execution} *)
162162+163163+val execute_fork_plan :
164164+ proc:_ Eio.Process.mgr ->
165165+ fs:Eio.Fs.dir_ty Eio.Path.t ->
166166+ fork_result action_plan ->
167167+ (fork_result, error) result
168168+(** [execute_fork_plan ~proc ~fs plan] executes a fork action plan.
169169+170170+ Returns the fork result with the actual split commit (if applicable).
171171+ If the plan is marked as dry-run, returns the plan's result without
172172+ executing any actions. *)
173173+174174+val execute_join_plan :
175175+ proc:_ Eio.Process.mgr ->
176176+ fs:Eio.Fs.dir_ty Eio.Path.t ->
177177+ join_result action_plan ->
178178+ (join_result, error) result
179179+(** [execute_join_plan ~proc ~fs plan] executes a join action plan.
180180+181181+ If the plan is marked as dry-run, returns the plan's result without
182182+ executing any actions. *)
183183+184184+(** {1 Fork Operations} *)
185185+43186val fork :
44187 proc:_ Eio.Process.mgr ->
45188 fs:Eio.Fs.dir_ty Eio.Path.t ->
···66209 @param dry_run If true, validate and report what would be done *)
6721068211(** {1 Join Operations} *)
6969-7070-(** Result of a join operation. *)
7171-type join_result = {
7272- name : string; (** Subtree/repository name *)
7373- source_url : string; (** URL the repository was cloned from *)
7474- upstream_url : string option; (** Original upstream if this is a fork *)
7575- packages_added : string list; (** Package names from .opam files *)
7676- from_handle : string option; (** Verse handle if joined from verse *)
7777-}
7878-7979-val pp_join_result : join_result Fmt.t
8080-(** [pp_join_result] formats a join result. *)
8121282213val join :
83214 proc:_ Eio.Process.mgr ->
+17
lib/git.ml
···635635 stdout = Buffer.contents buf_stdout;
636636 stderr = Buffer.contents buf_stderr;
637637 } ))
638638+639639+let add_all ~proc ~fs path =
640640+ let cwd = path_to_eio ~fs path in
641641+ run_git_ok ~proc ~cwd [ "add"; "-A" ] |> Result.map ignore
642642+643643+let commit ~proc ~fs ~message path =
644644+ let cwd = path_to_eio ~fs path in
645645+ run_git_ok ~proc ~cwd [ "commit"; "-m"; message ] |> Result.map ignore
646646+647647+let has_subtree_history ~proc ~fs ~repo ~prefix () =
648648+ (* Check if there's subtree commit history for this prefix.
649649+ Returns true if we can find a subtree-related commit message. *)
650650+ subtree_last_upstream_commit ~proc ~fs ~repo ~prefix () |> Option.is_some
651651+652652+let branch_rename ~proc ~fs ~new_name path =
653653+ let cwd = path_to_eio ~fs path in
654654+ run_git_ok ~proc ~cwd [ "branch"; "-M"; new_name ] |> Result.map ignore
+37
lib/git.mli
···595595596596 Uses [git apply] to apply the diff. Returns [Ok ()] if the diff was applied
597597 successfully or was empty, [Error] if the apply failed. *)
598598+599599+val add_all :
600600+ proc:_ Eio.Process.mgr ->
601601+ fs:Eio.Fs.dir_ty Eio.Path.t ->
602602+ Fpath.t ->
603603+ (unit, error) result
604604+(** [add_all ~proc ~fs path] stages all changes (git add -A) in the repository
605605+ at [path]. *)
606606+607607+val commit :
608608+ proc:_ Eio.Process.mgr ->
609609+ fs:Eio.Fs.dir_ty Eio.Path.t ->
610610+ message:string ->
611611+ Fpath.t ->
612612+ (unit, error) result
613613+(** [commit ~proc ~fs ~message path] creates a commit with the given message
614614+ in the repository at [path]. *)
615615+616616+val has_subtree_history :
617617+ proc:_ Eio.Process.mgr ->
618618+ fs:Eio.Fs.dir_ty Eio.Path.t ->
619619+ repo:Fpath.t ->
620620+ prefix:string ->
621621+ unit ->
622622+ bool
623623+(** [has_subtree_history ~proc ~fs ~repo ~prefix ()] returns true if the
624624+ prefix has subtree commit history (i.e., was added via git subtree add).
625625+ Returns false for fresh local packages that were never part of a subtree. *)
626626+627627+val branch_rename :
628628+ proc:_ Eio.Process.mgr ->
629629+ fs:Eio.Fs.dir_ty Eio.Path.t ->
630630+ new_name:string ->
631631+ Fpath.t ->
632632+ (unit, error) result
633633+(** [branch_rename ~proc ~fs ~new_name path] renames the current branch
634634+ to [new_name] in the repository at [path]. Uses [git branch -M]. *)
+2-2
lib/monopam.ml
···349349 Ok (List.rev packages)
350350351351let get_branch ~config pkg =
352352- let default = Config.default_branch config in
352352+ let default = Config.default_branch in
353353 match Package.branch pkg with
354354 | Some b -> b
355355 | None ->
···948948 Log.info (fun m ->
949949 m "Cloning opam repo from %s to %a" url Fpath.pp opam_repo);
950950 let url = Uri.of_string url in
951951- let branch = Config.default_branch config in
951951+ let branch = Config.default_branch in
952952 match Git.clone ~proc ~fs:fs_t ~url ~branch opam_repo with
953953 | Ok () -> Log.info (fun m -> m "Opam repo cloned successfully")
954954 | Error e ->
+6-195
lib/verse_config.ml
···11-let app_name = "monopam"
22-33-(** Package-level override for vendored packages *)
44-type package_override = {
55- branch : string option; (** Override branch *)
66-}
11+(** Verse_config is now an alias for Config.
7288-(* Simplified config: just root and handle. Paths are hardcoded. *)
99-type t = {
1010- root : Fpath.t;
1111- handle : string;
1212- knot : string; (** Git push server hostname (e.g., "git.recoil.org") *)
1313- packages : (string * package_override) list; (** Per-subtree overrides *)
1414-}
33+ This module is kept for backwards compatibility.
44+ All functionality has been unified into Config. *)
1551616-let root t = t.root
1717-let handle t = t.handle
1818-let knot t = t.knot
1919-let packages t = t.packages
66+include Config
2072121-(* Hardcoded paths derived from root *)
2222-let default_branch = "main"
2323-let mono_path t = Fpath.(t.root / "mono")
2424-let src_path t = Fpath.(t.root / "src")
2525-let opam_repo_path t = Fpath.(t.root / "opam-repo")
2626-let verse_path t = Fpath.(t.root / "verse")
2727-2828-(* Compute XDG directories following XDG Base Directory Specification *)
2929-let xdg_config_home () =
3030- match Sys.getenv_opt "XDG_CONFIG_HOME" with
3131- | Some dir when dir <> "" -> Fpath.v dir
3232- | _ -> (
3333- match Sys.getenv_opt "HOME" with
3434- | Some home -> Fpath.(v home / ".config")
3535- | None -> Fpath.v "/tmp")
3636-3737-let xdg_data_home () =
3838- match Sys.getenv_opt "XDG_DATA_HOME" with
3939- | Some dir when dir <> "" -> Fpath.v dir
4040- | _ -> (
4141- match Sys.getenv_opt "HOME" with
4242- | Some home -> Fpath.(v home / ".local" / "share")
4343- | None -> Fpath.v "/tmp")
4444-4545-let xdg_cache_home () =
4646- match Sys.getenv_opt "XDG_CACHE_HOME" with
4747- | Some dir when dir <> "" -> Fpath.v dir
4848- | _ ->
4949- match Sys.getenv_opt "HOME" with
5050- | Some home -> Fpath.(v home / ".cache")
5151- | None -> Fpath.v "/tmp"
5252-5353-let config_dir () = Fpath.(xdg_config_home () / app_name)
5454-let data_dir () = Fpath.(xdg_data_home () / app_name)
5555-let cache_dir () = Fpath.(xdg_cache_home () / app_name)
5656-let config_file () = Fpath.(config_dir () / "opamverse.toml")
5757-let registry_path () = Fpath.(data_dir () / "opamverse-registry")
5858-5959-(** Derive knot (git push server) from handle.
6060- E.g., "anil.recoil.org" -> "git.recoil.org" *)
6161-let default_knot_from_handle handle =
6262- match String.index_opt handle '.' with
6363- | None -> "git." ^ handle (* fallback *)
6464- | Some i ->
6565- let domain = String.sub handle (i + 1) (String.length handle - i - 1) in
6666- "git." ^ domain
6767-6868-let create ~root ~handle ?knot ?(packages = []) () =
6969- let knot = match knot with Some k -> k | None -> default_knot_from_handle handle in
7070- { root; handle; knot; packages }
7171-7272-let expand_tilde s =
7373- if String.length s > 0 && s.[0] = '~' then
7474- match Sys.getenv_opt "HOME" with
7575- | Some home ->
7676- if String.length s = 1 then home
7777- else if s.[1] = '/' then home ^ String.sub s 1 (String.length s - 1)
7878- else s
7979- | None -> s
8080- else s
8181-8282-let fpath_codec : Fpath.t Tomlt.t =
8383- Tomlt.map
8484- ~dec:(fun s ->
8585- let s = expand_tilde s in
8686- match Fpath.of_string s with Ok p -> p | Error (`Msg m) -> failwith m)
8787- ~enc:Fpath.to_string Tomlt.string
8888-8989-(* TOML structure:
9090- [workspace]
9191- root = "~/tangled"
9292-9393- [identity]
9494- handle = "anil.recoil.org"
9595- knot = "git.recoil.org"
9696-9797- # Optional package overrides (branch only; URL overrides go in sources.toml)
9898- [packages.braid]
9999- branch = "backport-fix"
100100-*)
101101-102102-type workspace_section = { w_root : Fpath.t }
103103-type identity_section = { i_handle : string; i_knot : string option }
104104-105105-let default_knot = "git.recoil.org"
106106-107107-let workspace_codec : workspace_section Tomlt.t =
108108- Tomlt.(
109109- Table.(
110110- obj (fun w_root -> { w_root })
111111- |> mem "root" fpath_codec ~enc:(fun w -> w.w_root)
112112- |> finish))
113113-114114-let identity_codec : identity_section Tomlt.t =
115115- Tomlt.(
116116- Table.(
117117- obj (fun i_handle i_knot -> { i_handle; i_knot })
118118- |> mem "handle" string ~enc:(fun i -> i.i_handle)
119119- |> opt_mem "knot" string ~enc:(fun i -> i.i_knot)
120120- |> finish))
121121-122122-let package_override_codec : package_override Tomlt.t =
123123- Tomlt.(
124124- Table.(
125125- obj (fun branch -> { branch })
126126- |> opt_mem "branch" string ~enc:(fun p -> p.branch)
127127- |> finish))
128128-129129-(* Codec for the [packages] table which contains subtree->override mappings *)
130130-let packages_table_codec : (string * package_override) list Tomlt.t =
131131- Tomlt.(
132132- Table.(
133133- obj (fun pkgs -> pkgs)
134134- |> keep_unknown ~enc:(fun pkgs -> pkgs)
135135- (Mems.assoc package_override_codec)
136136- |> finish))
137137-138138-(* Internal codec that tracks whether knot was present in the file *)
139139-type loaded_config = { config : t; knot_was_missing : bool }
140140-141141-let internal_codec : loaded_config Tomlt.t =
142142- Tomlt.(
143143- Table.(
144144- obj (fun workspace identity packages ->
145145- let packages = Option.value ~default:[] packages in
146146- let knot_was_missing = Option.is_none identity.i_knot in
147147- let knot = Option.value ~default:default_knot identity.i_knot in
148148- { config = { root = workspace.w_root; handle = identity.i_handle; knot; packages };
149149- knot_was_missing })
150150- |> mem "workspace" workspace_codec ~enc:(fun lc -> { w_root = lc.config.root })
151151- |> mem "identity" identity_codec ~enc:(fun lc -> { i_handle = lc.config.handle; i_knot = Some lc.config.knot })
152152- |> opt_mem "packages" packages_table_codec
153153- ~enc:(fun lc -> if lc.config.packages = [] then None else Some lc.config.packages)
154154- |> finish))
155155-156156-(* Public codec for encoding only *)
157157-let codec : t Tomlt.t =
158158- Tomlt.(
159159- Table.(
160160- obj (fun workspace identity packages ->
161161- let packages = Option.value ~default:[] packages in
162162- let knot = Option.value ~default:default_knot identity.i_knot in
163163- { root = workspace.w_root; handle = identity.i_handle; knot; packages })
164164- |> mem "workspace" workspace_codec ~enc:(fun t -> { w_root = t.root })
165165- |> mem "identity" identity_codec ~enc:(fun t -> { i_handle = t.handle; i_knot = Some t.knot })
166166- |> opt_mem "packages" packages_table_codec
167167- ~enc:(fun t -> if t.packages = [] then None else Some t.packages)
168168- |> finish))
169169-170170-let save ~fs t =
171171- let dir = config_dir () in
172172- let path = config_file () in
173173- try
174174- (* Ensure XDG config directory exists *)
175175- let dir_path = Eio.Path.(fs / Fpath.to_string dir) in
176176- (try Eio.Path.mkdirs ~perm:0o755 dir_path with Eio.Io _ -> ());
177177- Tomlt_eio.encode_path codec t ~fs (Fpath.to_string path);
178178- Ok ()
179179- with Eio.Io _ as e -> Error (Printexc.to_string e)
180180-181181-let load ~fs () =
182182- let path = config_file () in
183183- let path_str = Fpath.to_string path in
184184- try
185185- let loaded = Tomlt_eio.decode_path_exn internal_codec ~fs path_str in
186186- (* If knot was missing from the config file, write it back with the default *)
187187- if loaded.knot_was_missing then begin
188188- Logs.info (fun m -> m "Adding default knot=%s to config" default_knot);
189189- ignore (save ~fs loaded.config)
190190- end;
191191- Ok loaded.config
192192- with
193193- | Eio.Io _ as e -> Error (Printexc.to_string e)
194194- | Failure msg -> Error (Fmt.str "Invalid config: %s" msg)
195195-196196-let pp ppf t =
197197- Fmt.pf ppf "@[<v>workspace:@, root: %a@,identity:@, handle: %s@, knot: %s@]" Fpath.pp
198198- t.root t.handle t.knot
88+(** Legacy type alias for package overrides *)
99+type package_override = Config.Package_config.t
+8-114
lib/verse_config.mli
···11-(** Opamverse workspace configuration.
22-33- Configuration is stored in the XDG config directory at
44- [~/.config/monopam/opamverse.toml].
55-66- The config stores just the workspace root and user's handle. All paths are
77- derived from the root:
88- - [mono/] - user's monorepo
99- - [src/] - git checkouts for subtrees
1010- - [opam-repo/] - opam overlay repository
1111- - [verse/] - other members' monorepos *)
1212-1313-(** {1 Types} *)
1414-1515-(** Package-level override for vendored packages.
1616-1717- Note: For dev-repo URL overrides, use [sources.toml] in the monorepo root instead.
1818- This type only supports branch overrides. *)
1919-type package_override = {
2020- branch : string option; (** Override git branch *)
2121-}
2222-2323-type t
2424-(** Opamverse workspace configuration. *)
2525-2626-(** {1 Accessors} *)
2727-2828-val root : t -> Fpath.t
2929-(** [root t] returns the workspace root directory. *)
3030-3131-val handle : t -> string
3232-(** [handle t] returns the user's tangled handle. *)
3333-3434-val knot : t -> string
3535-(** [knot t] returns the git push server hostname (e.g., "git.recoil.org").
3636- Used for converting tangled URLs to SSH push URLs. *)
3737-3838-val packages : t -> (string * package_override) list
3939-(** [packages t] returns the list of package overrides.
4040- Each entry is [(subtree_name, override)] where subtree_name is the
4141- directory name in the monorepo (e.g., "braid" for mono/braid/).
4242-4343- Use this to override git branches. For dev-repo URL overrides,
4444- use [sources.toml] in the monorepo root instead. *)
4545-4646-(** {1 Derived Paths} *)
4747-4848-val default_branch : string
4949-(** Default git branch, always ["main"]. *)
5050-5151-val mono_path : t -> Fpath.t
5252-(** [mono_path t] returns the path to the user's monorepo ([root/mono/]). *)
5353-5454-val src_path : t -> Fpath.t
5555-(** [src_path t] returns the path to git checkouts ([root/src/]). *)
5656-5757-val opam_repo_path : t -> Fpath.t
5858-(** [opam_repo_path t] returns the path to the opam overlay ([root/opam-repo/]).
5959-*)
6060-6161-val verse_path : t -> Fpath.t
6262-(** [verse_path t] returns the path to tracked members' monorepos
6363- ([root/verse/]). *)
11+(** Verse_config is now an alias for Config.
6426565-(** {1 XDG Paths} *)
33+ This module is kept for backwards compatibility.
44+ All functionality has been unified into Config.
6656767-val config_dir : unit -> Fpath.t
6868-(** [config_dir ()] returns the XDG config directory for monopam
6969- (~/.config/monopam). *)
66+ @deprecated Use {!Config} directly. *)
7077171-val data_dir : unit -> Fpath.t
7272-(** [data_dir ()] returns the XDG data directory for monopam
7373- (~/.local/share/monopam). *)
88+include module type of Config
7497575-val cache_dir : unit -> Fpath.t
7676-(** [cache_dir ()] returns the XDG cache directory for monopam
7777- (~/.cache/monopam). Used for non-essential cached data like fetch timestamps. *)
7878-7979-val config_file : unit -> Fpath.t
8080-(** [config_file ()] returns the path to the opamverse config file
8181- (~/.config/monopam/opamverse.toml). *)
8282-8383-val registry_path : unit -> Fpath.t
8484-(** [registry_path ()] returns the path to the cloned registry git repo
8585- (~/.local/share/monopam/opamverse-registry). *)
8686-8787-(** {1 Loading and Saving} *)
8888-8989-val load : fs:Eio.Fs.dir_ty Eio.Path.t -> unit -> (t, string) result
9090-(** [load ~fs ()] loads the workspace configuration from the XDG config file.
9191-9292- @param fs Eio filesystem *)
9393-9494-val save : fs:Eio.Fs.dir_ty Eio.Path.t -> t -> (unit, string) result
9595-(** [save ~fs config] saves the configuration to the XDG config file.
9696-9797- @param fs Eio filesystem
9898- @param config Configuration to save *)
9999-100100-val create :
101101- root:Fpath.t ->
102102- handle:string ->
103103- ?knot:string ->
104104- ?packages:(string * package_override) list ->
105105- unit ->
106106- t
107107-(** [create ~root ~handle ?knot ?packages ()] creates a new configuration.
108108-109109- @param root Workspace root directory (absolute path)
110110- @param handle User's tangled handle
111111- @param knot Git push server hostname (e.g., "git.recoil.org"). If not provided,
112112- derived from handle (e.g., "anil.recoil.org" -> "git.recoil.org")
113113- @param packages Optional list of package overrides for vendored packages *)
114114-115115-(** {1 Pretty Printing} *)
116116-117117-val pp : t Fmt.t
118118-(** [pp] formats a workspace configuration. *)
1010+(** Legacy type alias for package overrides.
1111+ @deprecated Use {!Config.Package_config.t} instead. *)
1212+type package_override = Config.Package_config.t