···11+# Unpac Architecture
22+33+Unpac is a multi-backend vendoring tool that uses git worktrees for isolated branch operations.
44+55+## Directory Structure
66+77+An unpac project has this structure:
88+99+```
1010+my-project/
1111+├── git/ # Bare repository (shared object store)
1212+├── main/ # Worktree → main branch (metadata only)
1313+│ └── unpac.toml
1414+├── project/ # Project worktrees (where builds happen)
1515+│ ├── myapp/ # Worktree → project/myapp
1616+│ │ ├── src/
1717+│ │ ├── vendor/
1818+│ │ │ └── opam/
1919+│ │ │ ├── astring/
2020+│ │ │ └── eio/
2121+│ │ ├── dune-project
2222+│ │ └── dune
2323+│ └── feature-x/ # Worktree → project/feature-x
2424+├── opam/ # On-demand worktrees for opam backend
2525+│ ├── upstream/
2626+│ │ └── astring/ # Temporary: during add/update
2727+│ ├── vendor/
2828+│ │ └── astring/ # Temporary: during add/update
2929+│ └── patches/
3030+│ └── astring/ # On-demand: created for editing
3131+└── cargo/ # Future: cargo backend worktrees
3232+```
3333+3434+## Branch Hierarchy
3535+3636+```
3737+main # Metadata only (unpac.toml)
3838+│
3939+├── project/myapp # Buildable project (orphan)
4040+├── project/feature-x # Another project (orphan)
4141+│
4242+├── opam/upstream/astring # Pristine upstream (files at root)
4343+├── opam/upstream/eio
4444+├── opam/vendor/astring # Orphan, files under vendor/opam/astring/
4545+├── opam/vendor/eio
4646+├── opam/patches/astring # Forked from vendor, local modifications
4747+├── opam/patches/eio
4848+│
4949+└── cargo/... # Future
5050+```
5151+5252+## Branch Content
5353+5454+### `opam/upstream/<pkg>`
5555+5656+Pristine upstream code, files at repository root:
5757+5858+```
5959+(root)
6060+├── src/
6161+│ └── astring.ml
6262+├── dune
6363+└── astring.opam
6464+```
6565+6666+### `opam/vendor/<pkg>` (orphan branch)
6767+6868+Files relocated under `vendor/opam/<pkg>/` prefix for conflict-free merging:
6969+7070+```
7171+(root)
7272+└── vendor/
7373+ └── opam/
7474+ └── astring/
7575+ ├── src/
7676+ │ └── astring.ml
7777+ ├── dune
7878+ └── astring.opam
7979+```
8080+8181+### `opam/patches/<pkg>`
8282+8383+Forked from vendor branch. Same structure, may contain local modifications:
8484+8585+```
8686+(root)
8787+└── vendor/
8888+ └── opam/
8989+ └── astring/
9090+ ├── src/
9191+ │ └── astring.ml # May be patched
9292+ ├── dune
9393+ └── astring.opam
9494+```
9595+9696+### `project/<name>` (orphan branch)
9797+9898+Self-contained buildable project. Vendor content merged from patches branches:
9999+100100+```
101101+(root)
102102+├── src/
103103+│ └── my_app.ml
104104+├── vendor/
105105+│ └── opam/
106106+│ ├── astring/ # Merged from opam/patches/astring
107107+│ └── eio/ # Merged from opam/patches/eio
108108+├── dune-project
109109+└── dune # Contains (vendored_dirs vendor)
110110+```
111111+112112+## Configuration
113113+114114+### `main/unpac.toml`
115115+116116+Global configuration. Package lists are NOT stored here—they're derived from git.
117117+118118+```toml
119119+[opam]
120120+repositories = [
121121+ { name = "default", path = "/path/to/opam-repository" },
122122+]
123123+compiler = "5.4.0"
124124+125125+# Optional: override default XDG cache location
126126+# vendor_cache = "/path/to/vendor-cache"
127127+128128+[cargo]
129129+# Future
130130+131131+[projects]
132132+# Project existence only, no package lists
133133+myapp = {}
134134+feature-x = {}
135135+```
136136+137137+## Vendor Cache
138138+139139+A bare git repository that caches fetched packages to avoid hitting upstream remotes.
140140+141141+**Default location**: `$XDG_CACHE_HOME/unpac/vendor-cache/` (via xdge)
142142+143143+**Override**: Set `vendor_cache` in config or pass `--vendor-cache` on CLI.
144144+145145+The cache holds:
146146+- `opam/upstream/*` branches (pristine upstream)
147147+- `opam/vendor/*` branches (pre-built with vendor prefix)
148148+149149+Projects fetch from the cache as a git remote.
150150+151151+## Derivable State
152152+153153+All package state is derived from git, not duplicated in TOML:
154154+155155+| Information | How to derive |
156156+|-------------|---------------|
157157+| Vendored packages (global) | List `opam/patches/*` branches |
158158+| Packages in a project | List `vendor/opam/*/` directories |
159159+| Package versions | Commit metadata on `opam/upstream/*` |
160160+| Patch status | Diff `opam/patches/*` vs `opam/vendor/*` |
161161+| Merge status | Git merge history |
162162+163163+## Workflows
164164+165165+### Initialize Project
166166+167167+```bash
168168+unpac init my-project
169169+```
170170+171171+1. Create `my-project/git/` (bare repository)
172172+2. Create `main` branch with initial `unpac.toml`
173173+3. Create `my-project/main/` worktree
174174+175175+### Create Project Branch
176176+177177+```bash
178178+unpac project new myapp
179179+```
180180+181181+1. Create orphan branch `project/myapp` with template:
182182+ - `dune-project` (lang dune 3.20)
183183+ - `dune` with `(vendored_dirs vendor)`
184184+2. Create worktree `project/myapp/`
185185+3. Add to `[projects]` in `main/unpac.toml`
186186+187187+### Add Packages
188188+189189+```bash
190190+unpac opam add eio lwt --project=myapp
191191+```
192192+193193+1. Resolve dependencies (eio, lwt → full dependency tree)
194194+2. For each package:
195195+ a. Check vendor cache, fetch from upstream if missing
196196+ b. Create `opam/upstream/<pkg>` branch
197197+ c. Create `opam/vendor/<pkg>` orphan branch (with prefix)
198198+ d. Create `opam/patches/<pkg>` branch (from vendor)
199199+3. Merge all `opam/patches/*` into `project/myapp`
200200+201201+Worktrees are created temporarily during operations and cleaned up after.
202202+203203+### Update Packages
204204+205205+```bash
206206+unpac opam update eio --project=myapp
207207+```
208208+209209+1. Fetch latest upstream into `opam/upstream/eio`
210210+2. Update `opam/vendor/eio` with new content
211211+3. Rebase `opam/patches/eio` onto new vendor
212212+ - If conflicts: leave worktree for manual resolution
213213+4. Merge updated patches into `project/myapp`
214214+215215+### Edit Patches
216216+217217+```bash
218218+unpac opam edit astring
219219+```
220220+221221+1. Create worktree `opam/patches/astring/`
222222+2. User edits files in `vendor/opam/astring/`
223223+3. User commits changes
224224+225225+```bash
226226+unpac opam done astring
227227+```
228228+229229+1. Remove worktree (keeps branch and commits)
230230+231231+```bash
232232+# Then merge into project
233233+cd project/myapp
234234+git merge opam/patches/astring
235235+```
236236+237237+### Vendor Cache Operations
238238+239239+```bash
240240+# Fetch packages into cache (without adding to any project)
241241+unpac cache fetch eio lwt --deps
242242+```
243243+244244+1. Resolve dependencies
245245+2. Fetch each package into vendor cache
246246+3. Create `opam/upstream/*` and `opam/vendor/*` branches in cache
247247+248248+## Module Structure
249249+250250+```
251251+lib/
252252+├── unpac.ml # Library entry point
253253+│
254254+├── init.ml # Project initialization
255255+├── config.ml # TOML config parsing
256256+├── worktree.ml # Git worktree lifecycle management
257257+├── git.ml # Low-level git operations
258258+├── git_repo_lookup.ml # URL rewriting (erratique → github, etc.)
259259+├── cache.ml # Vendor cache management
260260+│
261261+├── backend.ml # Backend module signature
262262+│
263263+├── opam/ # Opam backend
264264+│ ├── opam_backend.ml # Backend implementation
265265+│ ├── upstream.ml # opam/upstream/* management
266266+│ ├── vendor.ml # opam/vendor/* management
267267+│ ├── patches.ml # opam/patches/* management
268268+│ ├── solver.ml # Dependency resolution
269269+│ ├── source.ml # Source extraction
270270+│ ├── repo_index.ml # Repository indexing
271271+│ └── dev_repo.ml # Dev-repo normalization
272272+│
273273+└── project.ml # Project branch operations
274274+275275+bin/
276276+└── main.ml # CLI
277277+```
278278+279279+### Key Module: `worktree.ml`
280280+281281+Manages worktree lifecycle within the unpac directory structure.
282282+283283+```ocaml
284284+type root
285285+(** The unpac project root (contains git/, main/, etc.) *)
286286+287287+type kind =
288288+ | Main
289289+ | Project of string
290290+ | Opam_upstream of string
291291+ | Opam_vendor of string
292292+ | Opam_patches of string
293293+294294+val path : root -> kind -> path
295295+(** Filesystem path for a worktree kind. *)
296296+297297+val branch : kind -> string
298298+(** Git branch name for a worktree kind. *)
299299+300300+val ensure : proc_mgr -> root -> kind -> unit
301301+(** Create worktree if it doesn't exist. *)
302302+303303+val remove : proc_mgr -> root -> kind -> unit
304304+(** Remove worktree (keeps branch). *)
305305+306306+val with_temp : proc_mgr -> root -> kind -> (path -> 'a) -> 'a
307307+(** Create worktree, run function, remove worktree. *)
308308+```
309309+310310+### Key Module: `backend.ml`
311311+312312+Signature for package manager backends.
313313+314314+```ocaml
315315+module type S = sig
316316+ val name : string
317317+ (** "opam", "cargo", etc. *)
318318+319319+ val upstream_branch : string -> string
320320+ val vendor_branch : string -> string
321321+ val patches_branch : string -> string
322322+ val vendor_path : string -> string
323323+324324+ val add :
325325+ proc_mgr -> root -> cache:path -> name:string -> url:string -> branch:string -> unit
326326+327327+ val update :
328328+ proc_mgr -> root -> name:string -> unit
329329+end
330330+```
331331+332332+## CLI Commands
333333+334334+```
335335+unpac init <path>
336336+ Initialize new unpac project
337337+338338+unpac project new <name>
339339+ Create new project branch
340340+341341+unpac project list
342342+ List projects
343343+344344+unpac project remove <name>
345345+ Remove project branch and worktree
346346+347347+unpac opam add <pkg...> --project=<name> [--deps]
348348+ Add packages to project (--deps is default)
349349+350350+unpac opam update <pkg...> --project=<name>
351351+ Update packages from upstream
352352+353353+unpac opam remove <pkg...> --project=<name>
354354+ Remove packages from project
355355+356356+unpac opam edit <pkg>
357357+ Create patches worktree for editing
358358+359359+unpac opam done <pkg>
360360+ Remove patches worktree
361361+362362+unpac opam status [--project=<name>]
363363+ Show package status
364364+365365+unpac cache fetch <pkg...> [--deps]
366366+ Fetch packages into vendor cache
367367+368368+unpac cache status
369369+ Show cache status
370370+```
371371+372372+## Future: Cargo Backend
373373+374374+The architecture supports multiple backends. Cargo would follow the same pattern:
375375+376376+```
377377+cargo/upstream/<crate> # Pristine from crates.io
378378+cargo/vendor/<crate> # Files under vendor/cargo/<crate>/
379379+cargo/patches/<crate> # Local modifications
380380+```
381381+382382+With corresponding worktree paths:
383383+384384+```
385385+my-project/
386386+├── cargo/
387387+│ ├── upstream/
388388+│ │ └── serde/
389389+│ ├── vendor/
390390+│ │ └── serde/
391391+│ └── patches/
392392+│ └── serde/
393393+└── project/
394394+ └── myapp/
395395+ └── vendor/
396396+ ├── opam/
397397+ │ └── eio/
398398+ └── cargo/
399399+ └── serde/
400400+```
+18
TODO.md
···11+# TODO
22+33+## Vendor Cache Git Repository
44+55+Add a persistent vendor cache as a bare git repository in the XDG cache directory
66+(`$XDG_CACHE_HOME/unpac/vendor-cache` or `~/.cache/unpac/vendor-cache`).
77+88+This cache would:
99+- Store fetched upstream repositories as branches (e.g., `github.com/dbuenzli/astring/master`)
1010+- Persist across multiple unpac project runs
1111+- Save network fetches when the same package is used in multiple projects
1212+- Allow offline operations when packages are already cached
1313+1414+Implementation notes:
1515+- Initialize bare git repo on first use
1616+- Add remotes and fetch before cloning into project's upstream branches
1717+- Use `git fetch --all` to update cache
1818+- Store branch names using URL-based naming (e.g., `github.com/owner/repo/branch`)
···11open Cmdliner
2233(* Logging setup *)
44-55-let setup_logging style_renderer level =
66- Fmt_tty.setup_std_outputs ?style_renderer ();
77- Logs.set_level level;
88- Logs.set_reporter (Logs_fmt.reporter ());
99- ()
44+let setup_logging () =
55+ Fmt_tty.setup_std_outputs ();
66+ Logs.set_level (Some Logs.Info);
77+ Logs.set_reporter (Logs_fmt.reporter ())
108119let logging_term =
1212- Term.(const setup_logging $ Fmt_cli.style_renderer () $ Logs_cli.level ())
1010+ Term.(const setup_logging $ const ())
13111414-(* Common options *)
1212+(* Helper to find project root *)
1313+let with_root f =
1414+ Eio_main.run @@ fun env ->
1515+ let fs = Eio.Stdenv.fs env in
1616+ let proc_mgr = Eio.Stdenv.process_mgr env in
1717+ let cwd = Sys.getcwd () in
1818+ match Unpac.Init.find_root ~fs ~cwd with
1919+ | None ->
2020+ Format.eprintf "Error: Not in an unpac project.@.";
2121+ exit 1
2222+ | Some root ->
2323+ f ~env ~fs ~proc_mgr ~root
15241616-let config_file =
1717- let doc = "Path to unpac.toml config file." in
1818- Arg.(value & opt file "unpac.toml" & info [ "c"; "config" ] ~doc ~docv:"FILE")
1919-2020-let cache_dir_term =
2121- let app_env = "UNPAC_CACHE_DIR" in
2222- let xdg_var = "XDG_CACHE_HOME" in
2323- let home = Sys.getenv "HOME" in
2424- let default_path = home ^ "/.cache/unpac" in
2525- let doc =
2626- Printf.sprintf
2727- "Override cache directory. Can also be set with %s or %s. Default: %s"
2828- app_env xdg_var default_path
2929- in
3030- let arg =
3131- Arg.(value & opt string default_path & info [ "cache-dir" ] ~docv:"DIR" ~doc)
3232- in
3333- Term.(
3434- const (fun cmdline_val ->
3535- if cmdline_val <> default_path then cmdline_val
3636- else
3737- match Sys.getenv_opt app_env with
3838- | Some v when v <> "" -> v
3939- | _ -> (
4040- match Sys.getenv_opt xdg_var with
4141- | Some v when v <> "" -> v ^ "/unpac"
4242- | _ -> default_path))
4343- $ arg)
4444-4545-(* Output format selection *)
4646-type output_format = Text | Json | Toml
4747-4848-let output_format_term =
4949- let json =
5050- let doc = "Output in JSON format." in
5151- Arg.(value & flag & info [ "json" ] ~doc)
5252- in
5353- let toml =
5454- let doc = "Output in TOML format." in
5555- Arg.(value & flag & info [ "toml" ] ~doc)
5656- in
5757- let select json toml =
5858- match (json, toml) with
5959- | true, false -> Json
6060- | false, true -> Toml
6161- | false, false -> Text
6262- | true, true ->
6363- Format.eprintf "Cannot use both --json and --toml@.";
6464- Text
6565- in
6666- Term.(const select $ json $ toml)
6767-6868-let get_format = function
6969- | Text -> Unpac.Output.Text
7070- | Json -> Unpac.Output.Json
7171- | Toml -> Unpac.Output.Toml
7272-7373-(* Helper to load index from config with caching *)
7474-7575-let load_index ~fs ~cache_dir config_path =
7676- let cache_path = Eio.Path.(fs / cache_dir) in
7777- Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 cache_path;
7878- Unpac.Cache.load_index ~cache_dir:cache_path ~config_path
7979-8080-(* Get compiler spec from config *)
8181-let get_compiler_spec config_path =
8282- try
8383- let config = Unpac.Config.load_exn config_path in
8484- match config.opam.compiler with
8585- | Some s -> Unpac.Solver.parse_compiler_spec s
8686- | None -> None
8787- with _ -> None
8888-8989-(* Error formatting helper - strip Eio.Io prefix for cleaner output *)
9090-let format_error exn =
9191- let s = Printexc.to_string exn in
9292- if String.length s > 7 && String.sub s 0 7 = "Eio.Io " then
9393- String.sub s 7 (String.length s - 7)
9494- else
9595- s
9696-9797-(* Source kind selection *)
9898-let source_kind_term =
9999- let git =
100100- let doc = "Get git/dev-repo URLs instead of archive URLs." in
101101- Arg.(value & flag & info [ "git" ] ~doc)
102102- in
103103- Term.(
104104- const (fun git ->
105105- if git then Unpac.Source.Git else Unpac.Source.Archive)
106106- $ git)
107107-108108-(* Resolve dependencies flag *)
109109-let resolve_deps_term =
110110- let doc = "Resolve dependencies using the 0install solver." in
111111- Arg.(value & flag & info [ "deps"; "with-deps" ] ~doc)
112112-113113-(* ============================================================================
114114- INIT COMMAND
115115- ============================================================================ *)
116116-2525+(* Init command *)
11726let init_cmd =
118118- let doc = "Initialize a new unpac repository." in
119119- let man = [
120120- `S Manpage.s_description;
121121- `P "Initializes a new git repository with unpac project structure.";
122122- `P "Creates the main branch with a project registry.";
123123- ] in
124124- let run () =
2727+ let doc = "Initialize a new unpac project." in
2828+ let path_arg =
2929+ let doc = "Path for the new project." in
3030+ Arg.(required & pos 0 (some string) None & info [] ~docv:"PATH" ~doc)
3131+ in
3232+ let run () path =
12533 Eio_main.run @@ fun env ->
126126- let cwd = Eio.Stdenv.cwd env in
3434+ let fs = Eio.Stdenv.fs env in
12735 let proc_mgr = Eio.Stdenv.process_mgr env in
128128- Unpac.Project.init ~proc_mgr ~cwd:(cwd :> Eio.Fs.dir_ty Eio.Path.t);
129129- Format.printf "Repository initialized.@.";
130130- Format.printf "Create a project with: unpac project create <name>@."
3636+ let _root = Unpac.Init.init ~proc_mgr ~fs path in
3737+ Format.printf "Initialized unpac project at %s@." path
13138 in
132132- let info = Cmd.info "init" ~doc ~man in
133133- Cmd.v info Term.(const run $ logging_term)
3939+ let info = Cmd.info "init" ~doc in
4040+ Cmd.v info Term.(const run $ logging_term $ path_arg)
13441135135-(* ============================================================================
136136- PROJECT COMMANDS
137137- ============================================================================ *)
138138-139139-let project_name_arg =
140140- let doc = "Project name." in
141141- Arg.(required & pos 0 (some string) None & info [] ~docv:"NAME" ~doc)
142142-143143-let project_desc_opt =
144144- let doc = "Project description." in
145145- Arg.(value & opt (some string) None & info ["d"; "description"] ~docv:"DESC" ~doc)
146146-147147-let project_create_cmd =
148148- let doc = "Create a new project." in
149149- let man = [
150150- `S Manpage.s_description;
151151- `P "Creates a new project branch and switches to it.";
152152- `P "The project is registered in the main branch's unpac.toml.";
153153- ] in
154154- let run () name description =
155155- Eio_main.run @@ fun env ->
156156- let cwd = Eio.Stdenv.cwd env in
157157- let proc_mgr = Eio.Stdenv.process_mgr env in
158158- let description = match description with Some d -> d | None -> "" in
159159- Unpac.Project.create ~proc_mgr ~cwd:(cwd :> Eio.Fs.dir_ty Eio.Path.t)
160160- ~name ~description ()
4242+(* Project new command *)
4343+let project_new_cmd =
4444+ let doc = "Create a new project branch." in
4545+ let name_arg =
4646+ let doc = "Name of the project." in
4747+ Arg.(required & pos 0 (some string) None & info [] ~docv:"NAME" ~doc)
4848+ in
4949+ let run () name =
5050+ with_root @@ fun ~env:_ ~fs:_ ~proc_mgr ~root ->
5151+ let _path = Unpac.Init.create_project ~proc_mgr root name in
5252+ Format.printf "Created project %s@." name
16153 in
162162- let info = Cmd.info "create" ~doc ~man in
163163- Cmd.v info Term.(const run $ logging_term $ project_name_arg $ project_desc_opt)
5454+ let info = Cmd.info "new" ~doc in
5555+ Cmd.v info Term.(const run $ logging_term $ name_arg)
164565757+(* Project list command *)
16558let project_list_cmd =
166166- let doc = "List all projects." in
167167- let man = [
168168- `S Manpage.s_description;
169169- `P "Lists all projects in the repository.";
170170- ] in
5959+ let doc = "List projects." in
17160 let run () =
172172- Eio_main.run @@ fun env ->
173173- let cwd = Eio.Stdenv.cwd env in
174174- let proc_mgr = Eio.Stdenv.process_mgr env in
175175- let projects = Unpac.Project.list_projects ~proc_mgr
176176- ~cwd:(cwd :> Eio.Fs.dir_ty Eio.Path.t) in
177177- let current = Unpac.Project.current_project ~proc_mgr
178178- ~cwd:(cwd :> Eio.Fs.dir_ty Eio.Path.t) in
179179- if projects = [] then
180180- Format.printf "No projects. Create one with: unpac project create <name>@."
181181- else begin
182182- Format.printf "Projects:@.";
183183- List.iter (fun (p : Unpac.Project.project_info) ->
184184- let marker = if Some p.name = current then "* " else " " in
185185- Format.printf "%s%s (%s)@." marker p.name p.branch
186186- ) projects
187187- end
6161+ with_root @@ fun ~env:_ ~fs:_ ~proc_mgr ~root ->
6262+ let projects = Unpac.Worktree.list_projects ~proc_mgr root in
6363+ List.iter (Format.printf "%s@.") projects
18864 in
189189- let info = Cmd.info "list" ~doc ~man in
6565+ let info = Cmd.info "list" ~doc in
19066 Cmd.v info Term.(const run $ logging_term)
19167192192-let project_switch_cmd =
193193- let doc = "Switch to a project." in
194194- let man = [
195195- `S Manpage.s_description;
196196- `P "Switches to the specified project's branch.";
197197- ] in
198198- let run () name =
199199- Eio_main.run @@ fun env ->
200200- let cwd = Eio.Stdenv.cwd env in
201201- let proc_mgr = Eio.Stdenv.process_mgr env in
202202- Unpac.Project.switch ~proc_mgr ~cwd:(cwd :> Eio.Fs.dir_ty Eio.Path.t) name
203203- in
204204- let info = Cmd.info "switch" ~doc ~man in
205205- Cmd.v info Term.(const run $ logging_term $ project_name_arg)
206206-6868+(* Project command group *)
20769let project_cmd =
20870 let doc = "Project management commands." in
209209- let man = [
210210- `S Manpage.s_description;
211211- `P "Commands for managing projects (branches).";
212212- ] in
213213- let info = Cmd.info "project" ~doc ~man in
214214- Cmd.group info [project_create_cmd; project_list_cmd; project_switch_cmd]
7171+ let info = Cmd.info "project" ~doc in
7272+ Cmd.group info [project_new_cmd; project_list_cmd]
21573216216-(* ============================================================================
217217- ADD COMMANDS
218218- ============================================================================ *)
219219-220220-let package_name_arg =
221221- let doc = "Package name to add." in
222222- Arg.(required & pos 0 (some string) None & info [] ~docv:"PACKAGE" ~doc)
223223-224224-let add_opam_cmd =
225225- let doc = "Add a package from opam." in
226226- let man = [
227227- `S Manpage.s_description;
228228- `P "Adds a package from opam, creating vendor branches and merging into the current project.";
229229- `P "Must be on a project branch (not main).";
230230- `P "Use --with-deps to include all transitive dependencies.";
231231- `S Manpage.s_examples;
232232- `P "Add a single package:";
233233- `Pre " unpac add opam eio";
234234- `P "Add a package with all dependencies:";
235235- `Pre " unpac add opam lwt --with-deps";
236236- ] in
237237- let run () config_path cache_dir resolve_deps pkg_name =
238238- Eio_main.run @@ fun env ->
239239- let fs = Eio.Stdenv.fs env in
240240- let cwd = Eio.Stdenv.cwd env in
241241- let proc_mgr = Eio.Stdenv.process_mgr env in
242242- let cwd_path = (cwd :> Eio.Fs.dir_ty Eio.Path.t) in
243243-244244- (* Check we're on a project branch *)
245245- let _project =
246246- try Unpac.Project.require_project_branch ~proc_mgr ~cwd:cwd_path
247247- with Failure msg ->
248248- Format.eprintf "%s@." msg;
249249- exit 1
250250- in
251251-252252- (* Check for pending recovery *)
253253- if Unpac.Recovery.has_recovery ~cwd:cwd_path then begin
254254- Format.eprintf "There's a pending operation. Run 'unpac vendor continue' or 'unpac vendor abort'.@.";
255255- exit 1
256256- end;
257257-258258- (* Load opam index *)
259259- let index = load_index ~fs ~cache_dir config_path in
260260- let compiler = get_compiler_spec config_path in
261261-262262- (* Parse package spec *)
263263- let spec = match Unpac.Solver.parse_package_spec pkg_name with
264264- | Ok s -> s
265265- | Error msg ->
266266- Format.eprintf "Invalid package spec: %s@." msg;
267267- exit 1
268268- in
269269-270270- (* Get packages to add *)
271271- let packages_to_add =
272272- if resolve_deps then begin
273273- match Unpac.Solver.select_with_deps ?compiler index [spec] with
274274- | Ok selection -> selection.packages
275275- | Error msg ->
276276- Format.eprintf "Error resolving dependencies: %s@." msg;
277277- exit 1
278278- end else begin
279279- match Unpac.Solver.select_packages index [spec] with
280280- | Ok selection -> selection.packages
281281- | Error msg ->
282282- Format.eprintf "Error selecting package: %s@." msg;
283283- exit 1
284284- end
285285- in
286286-287287- if packages_to_add = [] then begin
288288- Format.eprintf "Package '%s' not found.@." pkg_name;
289289- exit 1
290290- end;
291291-292292- (* Group packages by dev-repo *)
293293- let sources = Unpac.Source.extract_all Unpac.Source.Git packages_to_add in
294294- let grouped = Unpac.Source.group_by_dev_repo sources in
295295-296296- Format.printf "Found %d package group(s) to vendor:@." (List.length grouped);
297297-298298- (* Add each group *)
299299- List.iter (fun (group : Unpac.Source.grouped_sources) ->
300300- match group.dev_repo with
301301- | None ->
302302- Format.printf " Skipping packages without dev-repo@."
303303- | Some dev_repo ->
304304- let url_str = Unpac.Dev_repo.to_string dev_repo in
305305- let opam_packages = List.map (fun (p : Unpac.Source.package_source) -> p.name) group.packages in
306306-307307- (* Use first package name as canonical name, or extract from URL *)
308308- let name =
309309- match opam_packages with
310310- | first :: _ -> first
311311- | [] -> "unknown"
312312- in
313313-314314- (* Reconstruct full URL for git clone, with rewrites *)
315315- let url =
316316- let raw_url =
317317- let first_pkg = List.hd group.packages in
318318- match first_pkg.source with
319319- | Unpac.Source.GitSource g -> g.url
320320- | _ -> "https://" ^ url_str (* Fallback *)
321321- in
322322- Unpac.Git_repo_lookup.rewrite_url raw_url
323323- in
324324-325325- Format.printf " Adding %s (%d packages: %s)@."
326326- name (List.length opam_packages)
327327- (String.concat ", " opam_packages);
328328-329329- (* Detect default branch *)
330330- let branch = Unpac.Git.ls_remote_default_branch ~proc_mgr ~url in
331331-332332- match Unpac.Vendor.add_package ~proc_mgr ~cwd:cwd_path
333333- ~name ~url ~branch ~opam_packages with
334334- | Unpac.Vendor.Success { canonical_name; opam_packages; _ } ->
335335- Format.printf " [OK] Added %s (%d opam packages)@."
336336- canonical_name (List.length opam_packages)
337337- | Unpac.Vendor.Already_vendored name ->
338338- Format.printf " [SKIP] %s already vendored@." name
339339- | Unpac.Vendor.Failed { step; recovery_hint; error } ->
340340- Format.eprintf " [FAIL] Failed at step '%s': %s@." step
341341- (format_error error);
342342- Format.eprintf " %s@." recovery_hint;
343343- exit 1
344344- ) grouped;
345345-346346- Format.printf "Done.@."
7474+(* Opam add command *)
7575+let opam_add_cmd =
7676+ let doc = "Vendor an opam package from a git URL." in
7777+ let url_arg =
7878+ let doc = "Git URL of the package repository." in
7979+ Arg.(required & pos 0 (some string) None & info [] ~docv:"URL" ~doc)
34780 in
348348- let info = Cmd.info "opam" ~doc ~man in
349349- Cmd.v info Term.(const run $ logging_term $ config_file $ cache_dir_term
350350- $ resolve_deps_term $ package_name_arg)
351351-352352-let add_cmd =
353353- let doc = "Add packages to the project." in
354354- let man = [
355355- `S Manpage.s_description;
356356- `P "Commands for adding packages from various sources.";
357357- ] in
358358- let info = Cmd.info "add" ~doc ~man in
359359- Cmd.group info [add_opam_cmd]
360360-361361-(* ============================================================================
362362- VENDOR COMMANDS
363363- ============================================================================ *)
364364-365365-let vendor_package_arg =
366366- let doc = "Package name." in
367367- Arg.(required & pos 0 (some string) None & info [] ~docv:"PACKAGE" ~doc)
368368-369369-let vendor_status_cmd =
370370- let doc = "Show status of vendored packages." in
371371- let man = [
372372- `S Manpage.s_description;
373373- `P "Shows the status of all vendored packages including their SHAs and patch counts.";
374374- ] in
375375- let run () =
376376- Eio_main.run @@ fun env ->
377377- let cwd = Eio.Stdenv.cwd env in
378378- let proc_mgr = Eio.Stdenv.process_mgr env in
379379- let cwd_path = (cwd :> Eio.Fs.dir_ty Eio.Path.t) in
380380-381381- let statuses = Unpac.Vendor.all_status ~proc_mgr ~cwd:cwd_path in
382382-383383- if statuses = [] then begin
384384- Format.printf "No vendored packages.@.";
385385- Format.printf "Add packages with: unpac add opam <pkg>@."
386386- end else begin
387387- (* Print header *)
388388- Format.printf "%-20s %-12s %-12s %-8s %-8s@."
389389- "PACKAGE" "UPSTREAM" "VENDOR" "PATCHES" "MERGED";
390390- Format.printf "%-20s %-12s %-12s %-8s %-8s@."
391391- "-------" "--------" "------" "-------" "------";
392392-393393- List.iter (fun (s : Unpac.Vendor.package_status) ->
394394- let upstream = match s.upstream_sha with Some x -> x | None -> "-" in
395395- let vendor = match s.vendor_sha with Some x -> x | None -> "-" in
396396- let patches = string_of_int s.patch_count in
397397- let merged = if s.in_project then "yes" else "no" in
398398- Format.printf "%-20s %-12s %-12s %-8s %-8s@."
399399- s.name upstream vendor patches merged
400400- ) statuses
401401- end
8181+ let name_arg =
8282+ let doc = "Package name (defaults to repository name)." in
8383+ Arg.(value & opt (some string) None & info ["n"; "name"] ~docv:"NAME" ~doc)
40284 in
403403- let info = Cmd.info "status" ~doc ~man in
404404- Cmd.v info Term.(const run $ logging_term)
405405-406406-let vendor_update_cmd =
407407- let doc = "Update a vendored package from upstream." in
408408- let man = [
409409- `S Manpage.s_description;
410410- `P "Fetches the latest changes from upstream and updates the vendor branch.";
411411- `P "After updating, use 'unpac vendor rebase <pkg>' to rebase your patches.";
412412- ] in
413413- let run () name =
414414- Eio_main.run @@ fun env ->
415415- let cwd = Eio.Stdenv.cwd env in
416416- let proc_mgr = Eio.Stdenv.process_mgr env in
417417- let cwd_path = (cwd :> Eio.Fs.dir_ty Eio.Path.t) in
418418-419419- match Unpac.Vendor.update_package ~proc_mgr ~cwd:cwd_path ~name with
420420- | Unpac.Vendor.Updated { old_sha; new_sha; commit_count } ->
421421- let old_short = String.sub old_sha 0 7 in
422422- let new_short = String.sub new_sha 0 7 in
423423- Format.printf "[OK] Updated %s: %s -> %s (%d commits)@."
424424- name old_short new_short commit_count;
425425- Format.printf "Next: unpac vendor rebase %s@." name
426426- | Unpac.Vendor.No_changes ->
427427- Format.printf "[OK] %s is up to date@." name
428428- | Unpac.Vendor.Update_failed { step; error; recovery_hint } ->
429429- Format.eprintf "[FAIL] Failed at step '%s': %s@." step
430430- (format_error error);
431431- Format.eprintf "%s@." recovery_hint;
432432- exit 1
433433- in
434434- let info = Cmd.info "update" ~doc ~man in
435435- Cmd.v info Term.(const run $ logging_term $ vendor_package_arg)
436436-437437-let vendor_rebase_cmd =
438438- let doc = "Rebase patches onto updated vendor branch." in
439439- let man = [
440440- `S Manpage.s_description;
441441- `P "Rebases your patches on top of the updated vendor branch.";
442442- `P "Run this after 'unpac vendor update <pkg>'.";
443443- ] in
444444- let run () name =
445445- Eio_main.run @@ fun env ->
446446- let cwd = Eio.Stdenv.cwd env in
447447- let proc_mgr = Eio.Stdenv.process_mgr env in
448448- let cwd_path = (cwd :> Eio.Fs.dir_ty Eio.Path.t) in
449449-450450- match Unpac.Vendor.rebase_patches ~proc_mgr ~cwd:cwd_path ~name with
451451- | Ok () ->
452452- Format.printf "[OK] Rebased %s@." name;
453453- Format.printf "Next: unpac vendor merge %s@." name
454454- | Error (`Conflict _hint) ->
455455- Format.eprintf "[CONFLICT] Rebase has conflicts@.";
456456- Format.eprintf "Resolve conflicts, then: git rebase --continue@.";
457457- Format.eprintf "Or abort: git rebase --abort@.";
458458- exit 1
8585+ let branch_arg =
8686+ let doc = "Git branch to vendor (defaults to remote default)." in
8787+ Arg.(value & opt (some string) None & info ["b"; "branch"] ~docv:"BRANCH" ~doc)
45988 in
460460- let info = Cmd.info "rebase" ~doc ~man in
461461- Cmd.v info Term.(const run $ logging_term $ vendor_package_arg)
462462-463463-let vendor_merge_cmd =
464464- let doc = "Merge patches into current project branch." in
465465- let man = [
466466- `S Manpage.s_description;
467467- `P "Merges the patches branch into the current project branch.";
468468- ] in
469469- let run () name =
470470- Eio_main.run @@ fun env ->
471471- let cwd = Eio.Stdenv.cwd env in
472472- let proc_mgr = Eio.Stdenv.process_mgr env in
473473- let cwd_path = (cwd :> Eio.Fs.dir_ty Eio.Path.t) in
474474-475475- match Unpac.Vendor.merge_to_project ~proc_mgr ~cwd:cwd_path ~name with
476476- | Ok () ->
477477- Format.printf "[OK] Merged %s into project@." name
478478- | Error (`Conflict _files) ->
479479- Format.eprintf "[CONFLICT] Merge has conflicts@.";
480480- Format.eprintf "Resolve conflicts, then: git add <files> && git commit@.";
481481- Format.eprintf "Or abort: git merge --abort@.";
8989+ let run () url name_opt branch_opt =
9090+ with_root @@ fun ~env:_ ~fs:_ ~proc_mgr ~root ->
9191+ let name = match name_opt with
9292+ | Some n -> n
9393+ | None ->
9494+ (* Extract name from URL *)
9595+ let base = Filename.basename url in
9696+ if String.ends_with ~suffix:".git" base then
9797+ String.sub base 0 (String.length base - 4)
9898+ else base
9999+ in
100100+ let info : Unpac.Backend.package_info = {
101101+ name;
102102+ url;
103103+ branch = branch_opt;
104104+ } in
105105+ match Unpac_opam.Opam.add_package ~proc_mgr ~root info with
106106+ | Unpac.Backend.Added { name; sha } ->
107107+ Format.printf "Added %s (%s)@." name (String.sub sha 0 7)
108108+ | Unpac.Backend.Already_exists name ->
109109+ Format.printf "Package %s already vendored@." name
110110+ | Unpac.Backend.Failed { name; error } ->
111111+ Format.eprintf "Error adding %s: %s@." name error;
482112 exit 1
483113 in
484484- let info = Cmd.info "merge" ~doc ~man in
485485- Cmd.v info Term.(const run $ logging_term $ vendor_package_arg)
486486-487487-let vendor_continue_cmd =
488488- let doc = "Continue an interrupted operation." in
489489- let man = [
490490- `S Manpage.s_description;
491491- `P "Continues an operation that was interrupted (e.g., by a conflict).";
492492- `P "Run this after resolving conflicts.";
493493- ] in
494494- let run () =
495495- Eio_main.run @@ fun env ->
496496- let cwd = Eio.Stdenv.cwd env in
497497- let proc_mgr = Eio.Stdenv.process_mgr env in
498498- let cwd_path = (cwd :> Eio.Fs.dir_ty Eio.Path.t) in
114114+ let info = Cmd.info "add" ~doc in
115115+ Cmd.v info Term.(const run $ logging_term $ url_arg $ name_arg $ branch_arg)
499116500500- match Unpac.Recovery.load ~cwd:cwd_path with
501501- | None ->
502502- Format.printf "No pending operation to continue.@."
503503- | Some state ->
504504- Format.printf "Continuing: %a@." Unpac.Recovery.pp_operation state.operation;
505505- match Unpac.Vendor.continue ~proc_mgr ~cwd:cwd_path state with
506506- | Unpac.Vendor.Success { canonical_name; _ } ->
507507- Format.printf "[OK] Completed %s@." canonical_name
508508- | Unpac.Vendor.Already_vendored name ->
509509- Format.printf "[OK] %s already vendored@." name
510510- | Unpac.Vendor.Failed { step; error; recovery_hint } ->
511511- Format.eprintf "[FAIL] Failed at step '%s': %s@." step
512512- (format_error error);
513513- Format.eprintf "%s@." recovery_hint;
514514- exit 1
515515- in
516516- let info = Cmd.info "continue" ~doc ~man in
517517- Cmd.v info Term.(const run $ logging_term)
518518-519519-let vendor_abort_cmd =
520520- let doc = "Abort an interrupted operation." in
521521- let man = [
522522- `S Manpage.s_description;
523523- `P "Aborts an operation and restores the repository to its previous state.";
524524- ] in
117117+(* Opam list command *)
118118+let opam_list_cmd =
119119+ let doc = "List vendored opam packages." in
525120 let run () =
526526- Eio_main.run @@ fun env ->
527527- let cwd = Eio.Stdenv.cwd env in
528528- let proc_mgr = Eio.Stdenv.process_mgr env in
529529- let cwd_path = (cwd :> Eio.Fs.dir_ty Eio.Path.t) in
530530-531531- match Unpac.Recovery.load ~cwd:cwd_path with
532532- | None ->
533533- Format.printf "No pending operation to abort.@."
534534- | Some state ->
535535- Format.printf "Aborting: %a@." Unpac.Recovery.pp_operation state.operation;
536536- Unpac.Recovery.abort ~proc_mgr ~cwd:cwd_path state;
537537- Format.printf "[OK] Aborted. Repository restored.@."
121121+ with_root @@ fun ~env:_ ~fs:_ ~proc_mgr ~root ->
122122+ let packages = Unpac_opam.Opam.list_packages ~proc_mgr ~root in
123123+ List.iter (Format.printf "%s@.") packages
538124 in
539539- let info = Cmd.info "abort" ~doc ~man in
125125+ let info = Cmd.info "list" ~doc in
540126 Cmd.v info Term.(const run $ logging_term)
541127542542-let vendor_cmd =
543543- let doc = "Vendor package management." in
544544- let man = [
545545- `S Manpage.s_description;
546546- `P "Commands for managing vendored packages.";
547547- ] in
548548- let info = Cmd.info "vendor" ~doc ~man in
549549- Cmd.group info [
550550- vendor_status_cmd;
551551- vendor_update_cmd;
552552- vendor_rebase_cmd;
553553- vendor_merge_cmd;
554554- vendor_continue_cmd;
555555- vendor_abort_cmd;
556556- ]
557557-558558-(* ============================================================================
559559- OPAM COMMANDS (existing)
560560- ============================================================================ *)
561561-562562-let opam_list_cmd =
563563- let doc = "List packages in the merged repository." in
564564- let man =
565565- [
566566- `S Manpage.s_description;
567567- `P "Lists packages from all configured opam repositories.";
568568- `P "If no packages are specified, lists all available packages.";
569569- `P "Use --deps to include transitive dependencies.";
570570- `S Manpage.s_examples;
571571- `P "List all packages:";
572572- `Pre " unpac opam list";
573573- `P "List specific packages with dependencies:";
574574- `Pre " unpac opam list --deps lwt cmdliner";
575575- ]
128128+(* Opam update command *)
129129+let opam_update_cmd =
130130+ let doc = "Update a vendored opam package from upstream." in
131131+ let name_arg =
132132+ let doc = "Package name to update." in
133133+ Arg.(required & pos 0 (some string) None & info [] ~docv:"NAME" ~doc)
576134 in
577577- let run () config_path cache_dir format resolve_deps package_specs =
578578- Eio_main.run @@ fun env ->
579579- let fs = Eio.Stdenv.fs env in
580580- let index = load_index ~fs ~cache_dir config_path in
581581- let compiler = get_compiler_spec config_path in
582582- let selection_result =
583583- if package_specs = [] then Ok (Unpac.Solver.select_all index)
584584- else if resolve_deps then Unpac.Solver.select_with_deps ?compiler index package_specs
585585- else Unpac.Solver.select_packages index package_specs
586586- in
587587- match selection_result with
588588- | Error msg ->
589589- Format.eprintf "Error selecting packages: %s@." msg;
135135+ let run () name =
136136+ with_root @@ fun ~env:_ ~fs:_ ~proc_mgr ~root ->
137137+ match Unpac_opam.Opam.update_package ~proc_mgr ~root name with
138138+ | Unpac.Backend.Updated { name; old_sha; new_sha } ->
139139+ Format.printf "Updated %s: %s -> %s@." name
140140+ (String.sub old_sha 0 7) (String.sub new_sha 0 7)
141141+ | Unpac.Backend.No_changes name ->
142142+ Format.printf "%s is up to date@." name
143143+ | Unpac.Backend.Update_failed { name; error } ->
144144+ Format.eprintf "Error updating %s: %s@." name error;
590145 exit 1
591591- | Ok selection ->
592592- let packages =
593593- List.sort
594594- (fun (a : Unpac.Repo_index.package_info) b ->
595595- let cmp = OpamPackage.Name.compare a.name b.name in
596596- if cmp <> 0 then cmp
597597- else OpamPackage.Version.compare a.version b.version)
598598- selection.packages
599599- in
600600- Unpac.Output.output_package_list (get_format format) packages
601146 in
602602- let info = Cmd.info "list" ~doc ~man in
603603- Cmd.v info
604604- Term.(
605605- const run $ logging_term $ config_file $ cache_dir_term $ output_format_term
606606- $ resolve_deps_term $ Unpac.Solver.package_specs_term)
147147+ let info = Cmd.info "update" ~doc in
148148+ Cmd.v info Term.(const run $ logging_term $ name_arg)
607149608608-let opam_info_cmd =
609609- let doc = "Show detailed information about packages." in
610610- let man =
611611- [
612612- `S Manpage.s_description;
613613- `P "Displays detailed information about the specified packages.";
614614- `P "Use --deps to include transitive dependencies.";
615615- `S Manpage.s_examples;
616616- `P "Show info for a package:";
617617- `Pre " unpac opam info lwt";
618618- `P "Show info for packages and their dependencies:";
619619- `Pre " unpac opam info --deps cmdliner";
620620- ]
150150+(* Opam merge command *)
151151+let opam_merge_cmd =
152152+ let doc = "Merge a vendored opam package into a project." in
153153+ let pkg_arg =
154154+ let doc = "Package name to merge." in
155155+ Arg.(required & pos 0 (some string) None & info [] ~docv:"PACKAGE" ~doc)
621156 in
622622- let run () config_path cache_dir format resolve_deps package_specs =
623623- Eio_main.run @@ fun env ->
624624- let fs = Eio.Stdenv.fs env in
625625- let index = load_index ~fs ~cache_dir config_path in
626626- let compiler = get_compiler_spec config_path in
627627- if package_specs = [] then begin
628628- Format.eprintf "Please specify at least one package.@.";
629629- exit 1
630630- end;
631631- let selection_result =
632632- if resolve_deps then Unpac.Solver.select_with_deps ?compiler index package_specs
633633- else Unpac.Solver.select_packages index package_specs
634634- in
635635- match selection_result with
636636- | Error msg ->
637637- Format.eprintf "Error selecting packages: %s@." msg;
638638- exit 1
639639- | Ok selection ->
640640- if selection.packages = [] then
641641- Format.eprintf "No packages found.@."
642642- else Unpac.Output.output_package_info (get_format format) selection.packages
157157+ let project_arg =
158158+ let doc = "Target project name." in
159159+ Arg.(required & pos 1 (some string) None & info [] ~docv:"PROJECT" ~doc)
643160 in
644644- let info = Cmd.info "info" ~doc ~man in
645645- Cmd.v info
646646- Term.(
647647- const run $ logging_term $ config_file $ cache_dir_term $ output_format_term
648648- $ resolve_deps_term $ Unpac.Solver.package_specs_term)
649649-650650-let opam_related_cmd =
651651- let doc = "Show packages sharing the same dev-repo." in
652652- let man =
653653- [
654654- `S Manpage.s_description;
655655- `P
656656- "Lists all packages that share a development repository with the \
657657- specified packages.";
658658- `P "Use --deps to first resolve dependencies, then find related packages.";
659659- `S Manpage.s_examples;
660660- `P "Find related packages for a single package:";
661661- `Pre " unpac opam related lwt";
662662- `P "Find related packages including dependencies:";
663663- `Pre " unpac opam related --deps cmdliner";
664664- ]
665665- in
666666- let run () config_path cache_dir format resolve_deps package_specs =
667667- Eio_main.run @@ fun env ->
668668- let fs = Eio.Stdenv.fs env in
669669- let index = load_index ~fs ~cache_dir config_path in
670670- let compiler = get_compiler_spec config_path in
671671- if package_specs = [] then begin
672672- Format.eprintf "Please specify at least one package.@.";
673673- exit 1
674674- end;
675675- (* First, get the packages (with optional deps) *)
676676- let selection_result =
677677- if resolve_deps then Unpac.Solver.select_with_deps ?compiler index package_specs
678678- else Unpac.Solver.select_packages index package_specs
679679- in
680680- match selection_result with
681681- | Error msg ->
682682- Format.eprintf "Error selecting packages: %s@." msg;
161161+ let run () pkg project =
162162+ with_root @@ fun ~env:_ ~fs:_ ~proc_mgr ~root ->
163163+ let patches_branch = Unpac_opam.Opam.patches_branch pkg in
164164+ match Unpac.Backend.merge_to_project ~proc_mgr ~root ~project ~patches_branch with
165165+ | Ok () ->
166166+ Format.printf "Merged %s into project %s@." pkg project
167167+ | Error (`Conflict files) ->
168168+ Format.eprintf "Merge conflict in %s:@." pkg;
169169+ List.iter (Format.eprintf " %s@.") files;
170170+ Format.eprintf "Resolve conflicts in project/%s and commit.@." project;
683171 exit 1
684684- | Ok selection ->
685685- (* Find related packages for all selected packages *)
686686- let all_related = List.concat_map (fun (info : Unpac.Repo_index.package_info) ->
687687- Unpac.Repo_index.related_packages info.name index)
688688- selection.packages
689689- in
690690- (* Deduplicate *)
691691- let seen = Hashtbl.create 64 in
692692- let unique = List.filter (fun (info : Unpac.Repo_index.package_info) ->
693693- let key = OpamPackage.Name.to_string info.name in
694694- if Hashtbl.mem seen key then false
695695- else begin Hashtbl.add seen key (); true end)
696696- all_related
697697- in
698698- let first_pkg = List.hd package_specs in
699699- let pkg_name = OpamPackage.Name.to_string first_pkg.Unpac.Solver.name in
700700- if unique = [] then
701701- Format.eprintf "No related packages found.@."
702702- else Unpac.Output.output_related (get_format format) pkg_name unique
703172 in
704704- let info = Cmd.info "related" ~doc ~man in
705705- Cmd.v info
706706- Term.(
707707- const run $ logging_term $ config_file $ cache_dir_term $ output_format_term
708708- $ resolve_deps_term $ Unpac.Solver.package_specs_term)
173173+ let info = Cmd.info "merge" ~doc in
174174+ Cmd.v info Term.(const run $ logging_term $ pkg_arg $ project_arg)
709175710710-let opam_sources_cmd =
711711- let doc = "Get source URLs for packages, grouped by dev-repo." in
712712- let man =
713713- [
714714- `S Manpage.s_description;
715715- `P
716716- "Outputs source URLs (archive or git) for the specified packages, \
717717- grouped by their development repository (dev-repo). Packages that \
718718- share the same dev-repo are listed together since they typically \
719719- need to be fetched from the same source.";
720720- `P
721721- "If no packages are specified, outputs sources for all packages \
722722- (latest version of each).";
723723- `P
724724- "Use --git to get development repository URLs instead of archive URLs.";
725725- `P
726726- "Use --deps to include transitive dependencies using the 0install solver.";
727727- `S Manpage.s_examples;
728728- `P "Get archive URLs for all packages:";
729729- `Pre " unpac opam sources";
730730- `P "Get git URLs for specific packages:";
731731- `Pre " unpac opam sources --git lwt dune";
732732- `P "Get sources with version constraints:";
733733- `Pre " unpac opam sources cmdliner>=1.0 lwt.5.6.0";
734734- `P "Get sources with dependencies resolved:";
735735- `Pre " unpac opam sources --deps lwt";
736736- ]
737737- in
738738- let run () config_path cache_dir format source_kind resolve_deps package_specs =
739739- Eio_main.run @@ fun env ->
740740- let fs = Eio.Stdenv.fs env in
741741- let index = load_index ~fs ~cache_dir config_path in
742742- let compiler = get_compiler_spec config_path in
743743- (* Select packages based on specs *)
744744- let selection_result =
745745- if package_specs = [] then Ok (Unpac.Solver.select_all index)
746746- else if resolve_deps then Unpac.Solver.select_with_deps ?compiler index package_specs
747747- else Unpac.Solver.select_packages index package_specs
748748- in
749749- match selection_result with
750750- | Error msg ->
751751- Format.eprintf "Error selecting packages: %s@." msg;
752752- exit 1
753753- | Ok selection ->
754754- let sources =
755755- Unpac.Source.extract_all source_kind selection.packages
756756- in
757757- (* Filter out packages with no source *)
758758- let sources =
759759- List.filter
760760- (fun (s : Unpac.Source.package_source) ->
761761- s.source <> Unpac.Source.NoSource)
762762- sources
763763- in
764764- Unpac.Output.output_sources (get_format format) sources
765765- in
766766- let info = Cmd.info "sources" ~doc ~man in
767767- Cmd.v info
768768- Term.(
769769- const run $ logging_term $ config_file $ cache_dir_term $ output_format_term $ source_kind_term
770770- $ resolve_deps_term $ Unpac.Solver.package_specs_term)
771771-772772-let opam_vendor_fetch_cmd =
773773- let doc = "Fetch package sources into a vendor git repository." in
774774- let man =
775775- [
776776- `S Manpage.s_description;
777777- `P
778778- "Fetches git sources for the specified packages into a centralized \
779779- vendor git repository. This repository can then be used as a local \
780780- cache for subsequent 'unpac add' operations.";
781781- `P
782782- "Each package's upstream is stored as a branch named 'upstream/<pkg>' \
783783- in the vendor repository.";
784784- `P "Use --deps to include transitive dependencies.";
785785- `S Manpage.s_examples;
786786- `P "Fetch a package and its dependencies:";
787787- `Pre " unpac opam vendor-fetch --deps lwt --vendor-repo ./vendor-cache";
788788- `P "Fetch multiple packages:";
789789- `Pre " unpac opam vendor-fetch eio cmdliner fmt --vendor-repo ~/vendor";
790790- ]
791791- in
792792- let vendor_repo_arg =
793793- let doc = "Path to the vendor git repository (overrides config)." in
794794- Arg.(value & opt (some string) None & info ["vendor-repo"] ~docv:"DIR" ~doc)
795795- in
796796- let run () config_path cache_dir resolve_deps vendor_repo_arg package_specs =
797797- Eio_main.run @@ fun env ->
798798- let fs = Eio.Stdenv.fs env in
799799- let proc_mgr = Eio.Stdenv.process_mgr env in
800800-801801- if package_specs = [] then begin
802802- Format.eprintf "Please specify at least one package.@.";
803803- exit 1
804804- end;
805805-806806- (* Load config and determine vendor repo path *)
807807- let config = Unpac.Config.load_exn config_path in
808808- let vendor_repo = match vendor_repo_arg with
809809- | Some path -> path
810810- | None -> match config.opam.vendor_repo with
811811- | Some path -> path
812812- | None ->
813813- Format.eprintf "Error: No vendor-repo specified. Use --vendor-repo or set opam.vendor_repo in config.@.";
814814- exit 1
815815- in
816816- let vendor_path = Eio.Path.(fs / vendor_repo) in
817817-818818- (* Initialize vendor repo if needed *)
819819- if not (Unpac.Git.is_repository vendor_path) then begin
820820- Format.printf "Initializing vendor repository at %s@." vendor_repo;
821821- Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 vendor_path;
822822- Unpac.Git.init ~proc_mgr ~cwd:vendor_path
823823- end;
824824-825825- (* Check for vendor-upstream remote in config (for fetching pre-vendored branches) *)
826826- let vendor_upstream_url = match vendor_repo_arg with
827827- | Some _ -> config.opam.vendor_repo (* If overriding, use config as upstream *)
828828- | None -> None (* No separate upstream if using config directly *)
829829- in
830830- let has_vendor_upstream = Option.is_some vendor_upstream_url in
831831-832832- (* Setup vendor-upstream remote if configured *)
833833- (match vendor_upstream_url with
834834- | Some url ->
835835- Format.printf "Setting up vendor-upstream remote -> %s@." url;
836836- ignore (Unpac.Git.ensure_remote ~proc_mgr ~cwd:vendor_path ~name:"vendor-upstream" ~url);
837837- Unpac.Git.fetch ~proc_mgr ~cwd:vendor_path ~remote:"vendor-upstream"
838838- | None -> ());
839839-840840- (* Load opam index and resolve packages *)
841841- let index = load_index ~fs ~cache_dir config_path in
842842- let compiler = get_compiler_spec config_path in
843843- let selection_result =
844844- if resolve_deps then Unpac.Solver.select_with_deps ?compiler index package_specs
845845- else Unpac.Solver.select_packages index package_specs
846846- in
847847- let packages = match selection_result with
848848- | Error msg ->
849849- Format.eprintf "Error selecting packages: %s@." msg;
850850- exit 1
851851- | Ok selection -> selection.packages
852852- in
853853-854854- (* Extract sources and group by dev-repo *)
855855- let sources = Unpac.Source.extract_all Unpac.Source.Git packages in
856856- let grouped = Unpac.Source.group_by_dev_repo sources in
857857-858858- Format.printf "Found %d unique git source(s) to fetch:@." (List.length grouped);
859859-860860- (* Fetch each unique dev-repo *)
861861- let fetched = ref 0 in
862862- let from_upstream = ref 0 in
863863- let skipped = ref 0 in
864864- List.iter (fun (group : Unpac.Source.grouped_sources) ->
865865- match group.dev_repo with
866866- | None ->
867867- incr skipped;
868868- let pkg_names = List.map (fun (p : Unpac.Source.package_source) -> p.name) group.packages in
869869- Format.printf " [SKIP] No dev-repo: %s@." (String.concat ", " pkg_names)
870870- | Some _dev_repo ->
871871- let opam_packages = List.map (fun (p : Unpac.Source.package_source) -> p.name) group.packages in
872872- let name = match opam_packages with
873873- | first :: _ -> first
874874- | [] -> "unknown"
875875- in
876876-877877- let upstream_branch = "opam/upstream/" ^ name in
878878-879879- (* Check if branch already exists from vendor-upstream *)
880880- let found_in_vendor_upstream =
881881- if has_vendor_upstream then
882882- let ref_name = "vendor-upstream/" ^ upstream_branch in
883883- Option.is_some (Unpac.Git.rev_parse ~proc_mgr ~cwd:vendor_path ref_name)
884884- else false
885885- in
886886-887887- if found_in_vendor_upstream then begin
888888- (* Use branch from vendor-upstream *)
889889- let ref_point = "vendor-upstream/" ^ upstream_branch in
890890- Unpac.Git.branch_force ~proc_mgr ~cwd:vendor_path ~name:upstream_branch ~point:ref_point;
891891- incr fetched;
892892- incr from_upstream;
893893- Format.printf " [OK] %s -> %s (from vendor-upstream)@." name upstream_branch
894894- end else begin
895895- (* Fall back to fetching from original upstream *)
896896- let url =
897897- let raw_url =
898898- let first_pkg = List.hd group.packages in
899899- match first_pkg.source with
900900- | Unpac.Source.GitSource g -> g.url
901901- | _ -> ""
902902- in
903903- Unpac.Git_repo_lookup.rewrite_url raw_url
904904- in
905905-906906- if url = "" then begin
907907- incr skipped;
908908- Format.printf " [SKIP] No git URL for %s@." name
909909- end else begin
910910- Format.printf " Fetching %s from %s@." name url;
911911-912912- let remote = "origin-" ^ name in
913913-914914- try
915915- (* Add/update remote *)
916916- ignore (Unpac.Git.ensure_remote ~proc_mgr ~cwd:vendor_path ~name:remote ~url);
917917-918918- (* Fetch from remote *)
919919- Unpac.Git.fetch ~proc_mgr ~cwd:vendor_path ~remote;
920920-921921- (* Detect default branch *)
922922- let default_branch = Unpac.Git.ls_remote_default_branch ~proc_mgr ~url in
923923- let ref_point = remote ^ "/" ^ default_branch in
924924-925925- (* Create/update upstream branch *)
926926- Unpac.Git.branch_force ~proc_mgr ~cwd:vendor_path ~name:upstream_branch ~point:ref_point;
927927-928928- incr fetched;
929929- Format.printf " [OK] %s -> %s@." name upstream_branch
930930- with exn ->
931931- Format.eprintf " [FAIL] %s: %s@." name (format_error exn)
932932- end
933933- end
934934- ) grouped;
935935-936936- if has_vendor_upstream then
937937- Format.printf "Done: %d fetched (%d from vendor-upstream), %d skipped@." !fetched !from_upstream !skipped
938938- else
939939- Format.printf "Done: %d fetched, %d skipped@." !fetched !skipped
940940- in
941941- let info = Cmd.info "vendor-fetch" ~doc ~man in
942942- Cmd.v info
943943- Term.(
944944- const run $ logging_term $ config_file $ cache_dir_term
945945- $ resolve_deps_term $ vendor_repo_arg $ Unpac.Solver.package_specs_term)
946946-947947-(* Opam subcommand group *)
948948-176176+(* Opam command group *)
949177let opam_cmd =
950950- let doc = "Opam repository operations." in
951951- let man =
952952- [
953953- `S Manpage.s_description;
954954- `P
955955- "Commands for querying and managing opam repositories defined in the \
956956- configuration file.";
957957- ]
958958- in
959959- let info = Cmd.info "opam" ~doc ~man in
960960- Cmd.group info [ opam_list_cmd; opam_info_cmd; opam_related_cmd; opam_sources_cmd; opam_vendor_fetch_cmd ]
961961-962962-(* ============================================================================
963963- MAIN COMMAND
964964- ============================================================================ *)
178178+ let doc = "Opam package vendoring commands." in
179179+ let info = Cmd.info "opam" ~doc in
180180+ Cmd.group info [opam_add_cmd; opam_list_cmd; opam_update_cmd; opam_merge_cmd]
965181182182+(* Main command *)
966183let main_cmd =
967967- let doc = "Monorepo management tool." in
968968- let man =
969969- [
970970- `S Manpage.s_description;
971971- `P "unpac is a tool for managing OCaml monorepos with vendored packages.";
972972- `P "It uses a project-based branch model:";
973973- `P " - main branch holds the project registry";
974974- `P " - project/<name> branches hold actual code and vendor packages";
975975- `S "QUICK START";
976976- `P "Initialize a new repository:";
977977- `Pre " unpac init";
978978- `P "Create a project:";
979979- `Pre " unpac project create myapp";
980980- `P "Add packages:";
981981- `Pre " unpac add opam eio";
982982- `Pre " unpac add opam lwt --with-deps";
983983- `P "Check status:";
984984- `Pre " unpac vendor status";
985985- `S Manpage.s_bugs;
986986- `P "Report bugs at https://github.com/avsm/unpac/issues";
987987- ]
988988- in
989989- let info = Cmd.info "unpac" ~version:"0.1.0" ~doc ~man in
990990- Cmd.group info [ init_cmd; project_cmd; add_cmd; vendor_cmd; opam_cmd ]
184184+ let doc = "Multi-backend vendoring tool using git worktrees." in
185185+ let info = Cmd.info "unpac" ~version:"0.1.0" ~doc in
186186+ Cmd.group info [init_cmd; project_cmd; opam_cmd]
991187992992-let () =
993993- Unpac.Txn_log.start_session ~args:(List.tl (Array.to_list Sys.argv));
994994- let exit_code = Cmd.eval main_cmd in
995995- Unpac.Txn_log.end_session ~exit_code;
996996- exit exit_code
188188+let () = exit (Cmd.eval main_cmd)
···11+(** Backend module signature for package managers.
22+33+ Each backend (opam, cargo, etc.) implements this interface to provide
44+ vendoring capabilities. *)
55+66+(** {1 Types} *)
77+88+type package_info = {
99+ name : string;
1010+ url : string;
1111+ branch : string option; (** Branch/tag/ref to use *)
1212+}
1313+(** Information about a package to vendor. *)
1414+1515+type add_result =
1616+ | Added of { name : string; sha : string }
1717+ | Already_exists of string
1818+ | Failed of { name : string; error : string }
1919+2020+type update_result =
2121+ | Updated of { name : string; old_sha : string; new_sha : string }
2222+ | No_changes of string
2323+ | Update_failed of { name : string; error : string }
2424+2525+(** {1 Backend Signature} *)
2626+2727+module type S = sig
2828+ val name : string
2929+ (** Backend name, e.g. "opam", "cargo". *)
3030+3131+ (** {2 Branch Naming} *)
3232+3333+ val upstream_branch : string -> string
3434+ (** [upstream_branch pkg] returns branch name, e.g. "opam/upstream/astring". *)
3535+3636+ val vendor_branch : string -> string
3737+ (** [vendor_branch pkg] returns branch name, e.g. "opam/vendor/astring". *)
3838+3939+ val patches_branch : string -> string
4040+ (** [patches_branch pkg] returns branch name, e.g. "opam/patches/astring". *)
4141+4242+ val vendor_path : string -> string
4343+ (** [vendor_path pkg] returns path prefix, e.g. "vendor/opam/astring". *)
4444+4545+ (** {2 Worktree Kinds} *)
4646+4747+ val upstream_kind : string -> Worktree.kind
4848+ val vendor_kind : string -> Worktree.kind
4949+ val patches_kind : string -> Worktree.kind
5050+5151+ (** {2 Package Operations} *)
5252+5353+ val add_package :
5454+ proc_mgr:Git.proc_mgr ->
5555+ root:Worktree.root ->
5656+ package_info ->
5757+ add_result
5858+ (** [add_package ~proc_mgr ~root info] vendors a single package.
5959+6060+ 1. Creates/updates opam/upstream/<pkg> from URL
6161+ 2. Creates opam/vendor/<pkg> orphan with vendor/ prefix
6262+ 3. Creates opam/patches/<pkg> from vendor *)
6363+6464+ val update_package :
6565+ proc_mgr:Git.proc_mgr ->
6666+ root:Worktree.root ->
6767+ string ->
6868+ update_result
6969+ (** [update_package ~proc_mgr ~root name] updates a package from upstream.
7070+7171+ 1. Fetches latest into opam/upstream/<pkg>
7272+ 2. Updates opam/vendor/<pkg> with new content
7373+ Does NOT rebase patches - that's a separate operation. *)
7474+7575+ val list_packages :
7676+ proc_mgr:Git.proc_mgr ->
7777+ root:Worktree.root ->
7878+ string list
7979+ (** [list_packages ~proc_mgr root] returns all vendored package names. *)
8080+end
8181+8282+(** {1 Merge Operations} *)
8383+8484+(** These operations are backend-agnostic and work on any patches branch. *)
8585+8686+let merge_to_project ~proc_mgr ~root ~project ~patches_branch =
8787+ let project_wt = Worktree.path root (Worktree.Project project) in
8888+ Git.merge_allow_unrelated ~proc_mgr ~cwd:project_wt
8989+ ~branch:patches_branch
9090+ ~message:(Printf.sprintf "Merge %s" patches_branch)
9191+9292+let rebase_patches ~proc_mgr ~root ~patches_kind ~onto =
9393+ Worktree.ensure ~proc_mgr root patches_kind;
9494+ let patches_wt = Worktree.path root patches_kind in
9595+ let result = Git.rebase ~proc_mgr ~cwd:patches_wt ~onto in
9696+ Worktree.remove ~proc_mgr root patches_kind;
9797+ result
-127
lib/cache.ml
···11-(** Config file modification time - if this changes, config was edited *)
22-type cache_header = float
33-44-(** Repo content modification times - if any change, repo contents changed *)
55-type cache_key = float list
66-77-let cache_filename = "repo_index.cache"
88-99-let get_file_mtime path =
1010- try
1111- let stat = Unix.stat path in
1212- stat.Unix.st_mtime
1313- with Unix.Unix_error _ -> 0.0
1414-1515-let get_repo_mtime path =
1616- let packages_dir = Filename.concat path "packages" in
1717- get_file_mtime packages_dir
1818-1919-let make_cache_key (repos : Config.repo_config list) =
2020- List.filter_map
2121- (fun (r : Config.repo_config) ->
2222- match r.source with
2323- | Config.Local path -> Some (get_repo_mtime path)
2424- | Config.Remote _ -> None)
2525- repos
2626-2727-let cache_path cache_dir =
2828- Eio.Path.(cache_dir / cache_filename)
2929-3030-(* Read just the header to check if config has changed *)
3131-let read_cache_header cache_dir =
3232- let path = cache_path cache_dir in
3333- try
3434- let path_str = Eio.Path.native_exn path in
3535- let ic = open_in_bin path_str in
3636- Fun.protect
3737- ~finally:(fun () -> close_in ic)
3838- (fun () ->
3939- let header : cache_header = Marshal.from_channel ic in
4040- Some header)
4141- with
4242- | Sys_error _ -> None
4343- | End_of_file -> None
4444- | Failure _ -> None
4545-4646-(* Load full cache if header and key match *)
4747-let load_cached cache_dir expected_header expected_key =
4848- let path = cache_path cache_dir in
4949- try
5050- let path_str = Eio.Path.native_exn path in
5151- let ic = open_in_bin path_str in
5252- Fun.protect
5353- ~finally:(fun () -> close_in ic)
5454- (fun () ->
5555- let header : cache_header = Marshal.from_channel ic in
5656- if not (Float.equal header expected_header) then None
5757- else
5858- let key : cache_key = Marshal.from_channel ic in
5959- if not (List.equal Float.equal key expected_key) then None
6060- else
6161- let index : Repo_index.t = Marshal.from_channel ic in
6262- Some index)
6363- with
6464- | Sys_error _ -> None
6565- | End_of_file -> None
6666- | Failure _ -> None
6767-6868-let save_cache cache_dir header key (index : Repo_index.t) =
6969- let path = cache_path cache_dir in
7070- try
7171- let path_str = Eio.Path.native_exn path in
7272- let oc = open_out_bin path_str in
7373- Fun.protect
7474- ~finally:(fun () -> close_out oc)
7575- (fun () ->
7676- Marshal.to_channel oc header [];
7777- Marshal.to_channel oc key [];
7878- Marshal.to_channel oc index [])
7979- with
8080- | Sys_error msg ->
8181- Format.eprintf "Warning: Could not save cache: %s@." msg
8282- | Failure msg ->
8383- Format.eprintf "Warning: Could not serialize cache: %s@." msg
8484-8585-let rec load_index ~cache_dir ~config_path =
8686- let header : cache_header = get_file_mtime config_path in
8787-8888- (* Quick check: has config file changed? *)
8989- let cached_header = read_cache_header cache_dir in
9090- let config_unchanged =
9191- match cached_header with
9292- | Some h -> Float.equal h header
9393- | None -> false
9494- in
9595-9696- (* Load config *)
9797- let config = Config.load_exn config_path in
9898- let key = make_cache_key config.opam.repositories in
9999-100100- (* If config unchanged, try to load from cache *)
101101- if config_unchanged then
102102- match load_cached cache_dir header key with
103103- | Some index -> index
104104- | None ->
105105- (* Cache invalid, rebuild *)
106106- let index = build_index config in
107107- save_cache cache_dir header key index;
108108- index
109109- else begin
110110- (* Config changed, rebuild *)
111111- let index = build_index config in
112112- save_cache cache_dir header key index;
113113- index
114114- end
115115-116116-and build_index (config : Config.t) =
117117- List.fold_left
118118- (fun acc (repo : Config.repo_config) ->
119119- match repo.source with
120120- | Config.Local path ->
121121- Repo_index.load_local_repo ~name:repo.name ~path acc
122122- | Config.Remote _url ->
123123- Format.eprintf
124124- "Warning: Remote repositories not yet supported: %s@."
125125- repo.name;
126126- acc)
127127- Repo_index.empty config.opam.repositories
-23
lib/cache.mli
···11-(** Cache for repository index.
22-33- This module provides caching for the repository index using Marshal
44- serialization. The cache is stored in the XDG cache directory and
55- is invalidated when:
66- - The config file path or mtime changes
77- - Repository paths change
88- - Repository package directories' mtimes change *)
99-1010-val load_index :
1111- cache_dir:Eio.Fs.dir_ty Eio.Path.t ->
1212- config_path:string ->
1313- Repo_index.t
1414-(** [load_index ~cache_dir ~config_path] loads the repository index,
1515- using a cached version if available and valid.
1616-1717- The cache stores the config file path and mtime, along with repository
1818- paths and their package directory mtimes. If any of these change, the
1919- cache is invalidated and rebuilt.
2020-2121- @param cache_dir The XDG cache directory path
2222- @param config_path Path to the unpac.toml config file
2323- @return The repository index *)
+40-19
lib/config.ml
···11+(** Configuration file handling for unpac.
22+33+ Loads and parses main/unpac.toml configuration files. *)
44+55+(** {1 Types} *)
66+17type repo_source =
28 | Local of string
39 | Remote of string
410511type repo_config = {
66- name : string;
1212+ repo_name : string;
713 source : repo_source;
814}
9151016type opam_config = {
1117 repositories : repo_config list;
1212- compiler : string option; (* e.g., "ocaml.5.4.0" or "5.4.0" *)
1313- vendor_repo : string option; (* Path or URL to vendor repository *)
1818+ compiler : string option;
1419}
15201616-type t = { opam : opam_config }
2121+type project_config = {
2222+ project_name : string;
2323+}
17241818-(* TOML Codecs *)
2525+type t = {
2626+ opam : opam_config;
2727+ projects : project_config list;
2828+}
19292020-let repo_config_codec =
3030+(** {1 TOML Codecs} *)
3131+3232+let repo_config_codec : repo_config Tomlt.t =
2133 let open Tomlt in
2234 let open Table in
2323- let make name path url =
3535+ let make repo_name path url : repo_config =
2436 let source =
2537 match (path, url) with
2638 | Some p, None -> Local p
···2941 failwith "Repository cannot have both 'path' and 'url'"
3042 | None, None -> failwith "Repository must have either 'path' or 'url'"
3143 in
3232- { name; source }
4444+ { repo_name; source }
3345 in
3434- let enc_path r =
4646+ let enc_path (r : repo_config) =
3547 match r.source with Local p -> Some p | Remote _ -> None
3648 in
3737- let enc_url r =
4949+ let enc_url (r : repo_config) =
3850 match r.source with Remote u -> Some u | Local _ -> None
3951 in
4052 obj make
4141- |> mem "name" string ~enc:(fun r -> r.name)
5353+ |> mem "name" string ~enc:(fun (r : repo_config) -> r.repo_name)
4254 |> opt_mem "path" string ~enc:enc_path
4355 |> opt_mem "url" string ~enc:enc_url
4456 |> finish
45574646-let opam_config_codec =
5858+let opam_config_codec : opam_config Tomlt.t =
4759 let open Tomlt in
4860 let open Table in
4949- obj (fun repositories compiler vendor_repo -> { repositories; compiler; vendor_repo })
6161+ obj (fun repositories compiler : opam_config -> { repositories; compiler })
5062 |> mem "repositories" (list repo_config_codec)
5151- ~enc:(fun c -> c.repositories)
5252- |> opt_mem "compiler" string ~enc:(fun c -> c.compiler)
5353- |> opt_mem "vendor_repo" string ~enc:(fun c -> c.vendor_repo)
6363+ ~enc:(fun (c : opam_config) -> c.repositories)
6464+ |> opt_mem "compiler" string ~enc:(fun (c : opam_config) -> c.compiler)
5465 |> finish
55665656-let codec =
6767+(* For now, projects is not parsed from TOML - derived from git branches *)
6868+type config = t
6969+7070+let codec : config Tomlt.t =
5771 let open Tomlt in
5872 let open Table in
5959- obj (fun opam -> { opam })
6060- |> mem "opam" opam_config_codec ~enc:(fun c -> c.opam)
7373+ obj (fun opam : config -> { opam; projects = [] })
7474+ |> mem "opam" opam_config_codec ~enc:(fun (c : config) -> c.opam)
6175 |> finish
62767777+(** {1 Loading} *)
7878+6379let load path =
6480 try
6581 let content = In_channel.with_open_text path In_channel.input_all in
···71877288let load_exn path =
7389 match load path with Ok c -> c | Error msg -> failwith msg
9090+9191+(** {1 Helpers} *)
9292+9393+let find_project config name =
9494+ List.find_opt (fun p -> p.project_name = name) config.projects
+18-11
lib/config.mli
···11(** Configuration file handling for unpac.
2233- Loads and parses unpac.toml configuration files using tomlt. *)
33+ Loads and parses main/unpac.toml configuration files. *)
4455(** {1 Types} *)
6677type repo_source =
88- | Local of string (** Local filesystem path *)
99- | Remote of string (** Remote URL (git+https://..., etc.) *)
1010-(** Source location for an opam repository. *)
88+ | Local of string
99+ | Remote of string
11101211type repo_config = {
1313- name : string;
1212+ repo_name : string;
1413 source : repo_source;
1514}
1616-(** Configuration for a single opam repository. *)
17151816type opam_config = {
1917 repositories : repo_config list;
2020- compiler : string option; (** Target compiler version, e.g. "5.4.0" or "ocaml.5.4.0" *)
2121- vendor_repo : string option; (** Path or URL to vendor repository with opam/vendor/* branches *)
1818+ compiler : string option;
1919+}
2020+2121+type project_config = {
2222+ project_name : string;
2223}
2323-(** Opam-specific configuration. *)
24242525-type t = { opam : opam_config }
2626-(** The complete unpac configuration. *)
2525+type t = {
2626+ opam : opam_config;
2727+ projects : project_config list;
2828+}
27292830(** {1 Loading} *)
2931···32343335val load_exn : string -> t
3436(** [load_exn path] is like {!load} but raises on error. *)
3737+3838+(** {1 Helpers} *)
3939+4040+val find_project : t -> string -> project_config option
4141+(** [find_project config name] finds a project by name. *)
35423643(** {1 Codecs} *)
3744
···11-type format = Text | Json | Toml
22-33-(* JSON Codecs *)
44-55-let dev_repo_jsont =
66- Jsont.(
77- map
88- ~dec:(fun s -> Dev_repo.of_string s)
99- ~enc:Dev_repo.to_string string)
1010-1111-let package_name_jsont =
1212- Jsont.(
1313- map
1414- ~dec:OpamPackage.Name.of_string
1515- ~enc:OpamPackage.Name.to_string
1616- string)
1717-1818-let package_version_jsont =
1919- Jsont.(
2020- map
2121- ~dec:OpamPackage.Version.of_string
2222- ~enc:OpamPackage.Version.to_string
2323- string)
2424-2525-let package_info_jsont : Repo_index.package_info Jsont.t =
2626- let open Jsont in
2727- let open Repo_index in
2828- Object.map
2929- ~kind:"package_info"
3030- (fun name version dev_repo source_repo ->
3131- (* Create a minimal opam record - we don't encode the full opam file *)
3232- let opam = OpamFile.OPAM.empty in
3333- { name; version; opam; dev_repo; source_repo })
3434- |> Object.mem "name" package_name_jsont
3535- ~enc:(fun p -> p.name)
3636- |> Object.mem "version" package_version_jsont
3737- ~enc:(fun p -> p.version)
3838- |> Object.opt_mem "dev_repo" dev_repo_jsont
3939- ~enc:(fun p -> p.dev_repo)
4040- |> Object.mem "source_repo" string
4141- ~enc:(fun p -> p.source_repo)
4242- |> Object.finish
4343-4444-let package_list_jsont = Jsont.list package_info_jsont
4545-4646-(* Text Output *)
4747-4848-let pp_package_info fmt (info : Repo_index.package_info) =
4949- Format.fprintf fmt "%s.%s"
5050- (OpamPackage.Name.to_string info.name)
5151- (OpamPackage.Version.to_string info.version)
5252-5353-let pp_package_info_detailed fmt (info : Repo_index.package_info) =
5454- Format.fprintf fmt "@[<v>%s.%s@, repo: %s"
5555- (OpamPackage.Name.to_string info.name)
5656- (OpamPackage.Version.to_string info.version)
5757- info.source_repo;
5858- (match info.dev_repo with
5959- | Some dr -> Format.fprintf fmt "@, dev-repo: %s" (Dev_repo.to_string dr)
6060- | None -> ());
6161- Format.fprintf fmt "@]"
6262-6363-(* JSON encoding helper *)
6464-let encode_json codec value =
6565- match Jsont_bytesrw.encode_string codec value with
6666- | Ok s -> s
6767- | Error e -> failwith e
6868-6969-(* Output functions *)
7070-7171-let output_package_list format packages =
7272- match format with
7373- | Text ->
7474- List.iter
7575- (fun info -> Format.printf "%a@." pp_package_info info)
7676- packages
7777- | Json ->
7878- let json = encode_json package_list_jsont packages in
7979- print_endline json
8080- | Toml ->
8181- (* For TOML, we output as array of inline tables *)
8282- Format.printf "# Package list@.";
8383- List.iter
8484- (fun (info : Repo_index.package_info) ->
8585- Format.printf "[[packages]]@.";
8686- Format.printf "name = %S@." (OpamPackage.Name.to_string info.name);
8787- Format.printf "version = %S@."
8888- (OpamPackage.Version.to_string info.version);
8989- Format.printf "@.")
9090- packages
9191-9292-let output_package_info format packages =
9393- match format with
9494- | Text ->
9595- List.iter
9696- (fun info -> Format.printf "%a@.@." pp_package_info_detailed info)
9797- packages
9898- | Json ->
9999- let json = encode_json package_list_jsont packages in
100100- print_endline json
101101- | Toml ->
102102- List.iter
103103- (fun (info : Repo_index.package_info) ->
104104- Format.printf "[[packages]]@.";
105105- Format.printf "name = %S@." (OpamPackage.Name.to_string info.name);
106106- Format.printf "version = %S@."
107107- (OpamPackage.Version.to_string info.version);
108108- Format.printf "source_repo = %S@." info.source_repo;
109109- (match info.dev_repo with
110110- | Some dr -> Format.printf "dev_repo = %S@." (Dev_repo.to_string dr)
111111- | None -> ());
112112- Format.printf "@.")
113113- packages
114114-115115-let output_related format pkg_name packages =
116116- match format with
117117- | Text ->
118118- Format.printf "Packages related to %s:@." pkg_name;
119119- List.iter
120120- (fun info -> Format.printf " %a@." pp_package_info info)
121121- packages
122122- | Json ->
123123- let json_obj =
124124- let open Jsont in
125125- Object.map ~kind:"related_packages" (fun pkg related ->
126126- (pkg, related))
127127- |> Object.mem "package" string ~enc:fst
128128- |> Object.mem "related" package_list_jsont ~enc:snd
129129- |> Object.finish
130130- in
131131- let json = encode_json json_obj (pkg_name, packages) in
132132- print_endline json
133133- | Toml ->
134134- Format.printf "package = %S@." pkg_name;
135135- Format.printf "@.";
136136- List.iter
137137- (fun (info : Repo_index.package_info) ->
138138- Format.printf "[[related]]@.";
139139- Format.printf "name = %S@." (OpamPackage.Name.to_string info.name);
140140- Format.printf "version = %S@."
141141- (OpamPackage.Version.to_string info.version);
142142- Format.printf "@.")
143143- packages
144144-145145-let pp_grouped_source fmt (group : Source.grouped_sources) =
146146- (match group.dev_repo with
147147- | Some dr ->
148148- Format.fprintf fmt "@[<v>## %s@," (Dev_repo.to_string dr)
149149- | None ->
150150- Format.fprintf fmt "@[<v>## (no dev-repo)@,");
151151- List.iter
152152- (fun (src : Source.package_source) ->
153153- Format.fprintf fmt " %s.%s" src.name src.version;
154154- (match src.source with
155155- | Source.ArchiveSource a ->
156156- Format.fprintf fmt " [%s]" a.url
157157- | Source.GitSource g ->
158158- Format.fprintf fmt " [git: %s]" g.url
159159- | Source.NoSource -> ());
160160- Format.fprintf fmt "@,")
161161- group.packages;
162162- Format.fprintf fmt "@]"
163163-164164-let output_sources format sources =
165165- let grouped = Source.group_by_dev_repo sources in
166166- match format with
167167- | Text ->
168168- List.iter (fun g -> Format.printf "%a@." pp_grouped_source g) grouped
169169- | Json ->
170170- let json = encode_json Source.grouped_sources_list_jsont grouped in
171171- print_endline json
172172- | Toml ->
173173- (* Format as array of tables with nested packages *)
174174- List.iter
175175- (fun (group : Source.grouped_sources) ->
176176- Format.printf "[[repos]]@.";
177177- (match group.dev_repo with
178178- | Some dr -> Format.printf "dev_repo = %S@." (Dev_repo.to_string dr)
179179- | None -> Format.printf "# no dev-repo@.");
180180- Format.printf "@.";
181181- List.iter
182182- (fun (src : Source.package_source) ->
183183- Format.printf "[[repos.packages]]@.";
184184- Format.printf "name = %S@." src.name;
185185- Format.printf "version = %S@." src.version;
186186- (match src.source with
187187- | Source.ArchiveSource a ->
188188- Format.printf "[repos.packages.source]@.";
189189- Format.printf "type = \"archive\"@.";
190190- Format.printf "url = %S@." a.url;
191191- if a.checksums <> [] then begin
192192- Format.printf "checksums = [";
193193- List.iteri
194194- (fun i cs ->
195195- if i > 0 then Format.printf ", ";
196196- Format.printf "%S" cs)
197197- a.checksums;
198198- Format.printf "]@."
199199- end;
200200- if a.mirrors <> [] then begin
201201- Format.printf "mirrors = [";
202202- List.iteri
203203- (fun i m ->
204204- if i > 0 then Format.printf ", ";
205205- Format.printf "%S" m)
206206- a.mirrors;
207207- Format.printf "]@."
208208- end
209209- | Source.GitSource g ->
210210- Format.printf "[repos.packages.source]@.";
211211- Format.printf "type = \"git\"@.";
212212- Format.printf "url = %S@." g.url;
213213- (match g.branch with
214214- | Some b -> Format.printf "branch = %S@." b
215215- | None -> ())
216216- | Source.NoSource ->
217217- Format.printf "[repos.packages.source]@.";
218218- Format.printf "type = \"none\"@.");
219219- Format.printf "@.")
220220- group.packages)
221221- grouped
-35
lib/output.mli
···11-(** Output formatting for unpac commands.
22-33- Provides plain text, JSON, and TOML output formats. *)
44-55-(** {1 Output Format} *)
66-77-type format =
88- | Text (** Human-readable text output *)
99- | Json (** Machine-readable JSON output *)
1010- | Toml (** TOML output *)
1111-(** Output format selection. *)
1212-1313-(** {1 Package Output} *)
1414-1515-val output_package_list : format -> Repo_index.package_info list -> unit
1616-(** [output_package_list fmt packages] outputs a list of packages. *)
1717-1818-val output_package_info : format -> Repo_index.package_info list -> unit
1919-(** [output_package_info fmt packages] outputs detailed package information. *)
2020-2121-val output_related : format -> string -> Repo_index.package_info list -> unit
2222-(** [output_related fmt pkg_name packages] outputs related packages. *)
2323-2424-(** {1 Source Output} *)
2525-2626-val output_sources : format -> Source.package_source list -> unit
2727-(** [output_sources fmt sources] outputs package sources. *)
2828-2929-(** {1 JSON Codecs} *)
3030-3131-val package_info_jsont : Repo_index.package_info Jsont.t
3232-(** JSON codec for package info. *)
3333-3434-val package_list_jsont : Repo_index.package_info list Jsont.t
3535-(** JSON codec for package list. *)
-329
lib/project.ml
···11-(** Project management - handling project branches. *)
22-33-let src = Logs.Src.create "unpac.project" ~doc:"Project operations"
44-module Log = (val Logs.src_log src : Logs.LOG)
55-66-(* Option helper for compatibility *)
77-let option_value ~default = function
88- | Some x -> x
99- | None -> default
1010-1111-(* Types *)
1212-1313-type project_info = {
1414- name : string;
1515- branch : string;
1616- description : string;
1717- created : string;
1818-}
1919-2020-type registry = {
2121- version : string;
2222- projects : project_info list;
2323-}
2424-2525-(* Branch conventions *)
2626-2727-let project_prefix = "project/"
2828-2929-let project_branch name = project_prefix ^ name
3030-3131-let is_project_branch branch =
3232- String.starts_with ~prefix:project_prefix branch
3333-3434-let project_name_of_branch branch =
3535- if is_project_branch branch then
3636- Some (String.sub branch (String.length project_prefix)
3737- (String.length branch - String.length project_prefix))
3838- else
3939- None
4040-4141-(* Get current timestamp in ISO 8601 format *)
4242-let iso_timestamp () =
4343- let t = Unix.gettimeofday () in
4444- let tm = Unix.gmtime t in
4545- Printf.sprintf "%04d-%02d-%02dT%02d:%02d:%02dZ"
4646- (tm.Unix.tm_year + 1900)
4747- (tm.Unix.tm_mon + 1)
4848- tm.Unix.tm_mday
4949- tm.Unix.tm_hour
5050- tm.Unix.tm_min
5151- tm.Unix.tm_sec
5252-5353-(* TOML encoding for registry *)
5454-5555-let project_info_codec =
5656- let open Tomlt in
5757- let open Table in
5858- obj (fun name branch description created ->
5959- { name; branch; description; created })
6060- |> mem "name" string ~enc:(fun p -> p.name)
6161- |> mem "branch" string ~enc:(fun p -> p.branch)
6262- |> mem "description" string ~dec_absent:"" ~enc:(fun p -> p.description)
6363- |> mem "created" string ~dec_absent:"" ~enc:(fun p -> p.created)
6464- |> finish
6565-6666-let registry_codec =
6767- let open Tomlt in
6868- let open Table in
6969- obj (fun version projects -> { version; projects })
7070- |> mem "version" string ~dec_absent:"0.1.0" ~enc:(fun r -> r.version)
7171- |> mem "projects" (list project_info_codec) ~dec_absent:[] ~enc:(fun r -> r.projects)
7272- |> finish
7373-7474-let unpac_toml_codec =
7575- let open Tomlt in
7676- let open Table in
7777- obj (fun unpac -> unpac)
7878- |> mem "unpac" registry_codec ~enc:Fun.id
7979- |> finish
8080-8181-(* Configuration *)
8282-8383-let config_file = "unpac.toml"
8484-8585-let load_registry ~cwd =
8686- let path = Eio.Path.(cwd / config_file) in
8787- match Eio.Path.load path with
8888- | content ->
8989- begin match Tomlt_bytesrw.decode_string unpac_toml_codec content with
9090- | Ok registry -> Some registry
9191- | Error e ->
9292- Log.warn (fun m -> m "Failed to parse %s: %s" config_file
9393- (Tomlt.Toml.Error.to_string e));
9494- None
9595- end
9696- | exception Eio.Io (Eio.Fs.E (Eio.Fs.Not_found _), _) ->
9797- None
9898- | exception exn ->
9999- Log.warn (fun m -> m "Failed to load %s: %a" config_file Fmt.exn exn);
100100- None
101101-102102-let save_registry ~cwd registry =
103103- let path = Eio.Path.(cwd / config_file) in
104104- let content = Tomlt_bytesrw.encode_string unpac_toml_codec registry in
105105- Eio.Path.save ~create:(`Or_truncate 0o644) path content;
106106- Log.debug (fun m -> m "Saved registry to %s" config_file)
107107-108108-(* Queries *)
109109-110110-let current_project ~proc_mgr ~cwd =
111111- match Git.current_branch ~proc_mgr ~cwd with
112112- | None -> None
113113- | Some branch -> project_name_of_branch branch
114114-115115-let require_project_branch ~proc_mgr ~cwd =
116116- match Git.current_branch ~proc_mgr ~cwd with
117117- | None ->
118118- Log.err (fun m -> m "Not on any branch (detached HEAD)");
119119- failwith "Not on any branch. Switch to a project branch first."
120120- | Some branch ->
121121- match project_name_of_branch branch with
122122- | Some name -> name
123123- | None ->
124124- Log.err (fun m -> m "Not on a project branch. Current branch: %s" branch);
125125- failwith (Printf.sprintf
126126- "Not on a project branch (current: %s).\n\
127127- Switch to a project: unpac project switch <name>\n\
128128- Or create one: unpac project create <name>" branch)
129129-130130-let is_main_branch ~proc_mgr ~cwd =
131131- match Git.current_branch ~proc_mgr ~cwd with
132132- | Some "main" | Some "master" -> true
133133- | _ -> false
134134-135135-let list_projects ~proc_mgr ~cwd =
136136- (* First try to load from registry on current branch *)
137137- match load_registry ~cwd with
138138- | Some registry -> registry.projects
139139- | None ->
140140- (* Fallback: scan for project branches *)
141141- let branches = Git.run_lines ~proc_mgr ~cwd
142142- ["for-each-ref"; "--format=%(refname:short)"; "refs/heads/project/"]
143143- in
144144- List.filter_map (fun branch ->
145145- match project_name_of_branch branch with
146146- | Some name -> Some { name; branch; description = ""; created = "" }
147147- | None -> None
148148- ) branches
149149-150150-let project_exists ~proc_mgr ~cwd name =
151151- Git.branch_exists ~proc_mgr ~cwd (project_branch name)
152152-153153-(* Operations *)
154154-155155-let init ~proc_mgr ~cwd =
156156- if Git.is_repository cwd then begin
157157- Log.warn (fun m -> m "Git repository already exists");
158158- (* Check if we have a registry *)
159159- if Option.is_some (load_registry ~cwd) then
160160- Log.info (fun m -> m "Registry already exists")
161161- else begin
162162- (* Create registry on current branch *)
163163- let registry = { version = "0.1.0"; projects = [] } in
164164- save_registry ~cwd registry;
165165- if Git.has_uncommitted_changes ~proc_mgr ~cwd then begin
166166- Git.add_all ~proc_mgr ~cwd;
167167- Git.commit ~proc_mgr ~cwd ~message:"unpac: initialize project registry"
168168- end
169169- end
170170- end else begin
171171- Log.info (fun m -> m "Initializing git repository...");
172172- Git.init ~proc_mgr ~cwd;
173173-174174- (* Create README *)
175175- let readme_path = Eio.Path.(cwd / "README.md") in
176176- let readme_content = {|# Unpac Vendor Repository
177177-178178-This repository uses unpac's project-based branch model for vendoring OCaml packages.
179179-180180-## Branch Structure
181181-182182-- `main` - Project registry (metadata only)
183183-- `project/<name>` - Individual project branches with vendored code
184184-185185-## Quick Start
186186-187187-```bash
188188-# Create a new project
189189-unpac project create myapp
190190-191191-# Add packages (must be on a project branch)
192192-unpac add opam eio
193193-unpac add opam lwt --with-deps
194194-195195-# Check status
196196-unpac vendor status
197197-```
198198-199199-## Commands
200200-201201-```bash
202202-unpac init # Initialize repository
203203-unpac project create <name> # Create new project
204204-unpac project switch <name> # Switch to project
205205-unpac add opam <pkg> # Add package from opam
206206-unpac vendor status # Show vendored packages
207207-unpac vendor update <pkg> # Update from upstream
208208-```
209209-|}
210210- in
211211- Eio.Path.save ~create:(`Or_truncate 0o644) readme_path readme_content;
212212-213213- (* Create .gitignore *)
214214- let gitignore_path = Eio.Path.(cwd / ".gitignore") in
215215- let gitignore_content = {|_build/
216216-*.install
217217-.merlin
218218-*.byte
219219-*.native
220220-*.cmo
221221-*.cmi
222222-*.cma
223223-*.cmx
224224-*.cmxa
225225-*.cmxs
226226-*.o
227227-*.a
228228-.unpac/
229229-.unpac.log
230230-|}
231231- in
232232- Eio.Path.save ~create:(`Or_truncate 0o644) gitignore_path gitignore_content;
233233-234234- (* Create registry *)
235235- let registry = { version = "0.1.0"; projects = [] } in
236236- save_registry ~cwd registry;
237237-238238- (* Initial commit *)
239239- Git.add_all ~proc_mgr ~cwd;
240240- Git.commit ~proc_mgr ~cwd ~message:"Initial unpac repository setup";
241241-242242- Log.info (fun m -> m "Repository initialized")
243243- end
244244-245245-let create ~proc_mgr ~cwd ~name ?(description="") () =
246246- if project_exists ~proc_mgr ~cwd name then begin
247247- Log.err (fun m -> m "Project %s already exists" name);
248248- failwith (Printf.sprintf "Project '%s' already exists" name)
249249- end;
250250-251251- let branch = project_branch name in
252252- let created = iso_timestamp () in
253253-254254- Log.info (fun m -> m "Creating project: %s" name);
255255-256256- (* Load current registry (might be on main or another branch) *)
257257- let registry = load_registry ~cwd |> option_value
258258- ~default:{ version = "0.1.0"; projects = [] }
259259- in
260260-261261- (* Add project to registry *)
262262- let project = { name; branch; description; created } in
263263- let registry = { registry with projects = project :: registry.projects } in
264264-265265- (* Create the project branch from current HEAD *)
266266- let current = Git.current_branch ~proc_mgr ~cwd in
267267- let start_point = Git.current_head ~proc_mgr ~cwd in
268268-269269- Git.branch_create ~proc_mgr ~cwd ~name:branch ~start_point;
270270- Git.checkout ~proc_mgr ~cwd branch;
271271-272272- (* Create project-specific config *)
273273- let project_config_path = Eio.Path.(cwd / config_file) in
274274- let project_config = Printf.sprintf {|[project]
275275-name = "%s"
276276-description = "%s"
277277-278278-[opam]
279279-# Repositories are listed in priority order (later ones take priority).
280280-# repositories = [
281281-# { name = "default", path = "/path/to/opam-repository" },
282282-# { name = "custom", path = "/path/to/custom-repo" },
283283-# ]
284284-repositories = []
285285-# compiler = "ocaml.5.3.0"
286286-287287-[vendor]
288288-# Vendored packages will be listed here
289289-|} name description
290290- in
291291- Eio.Path.save ~create:(`Or_truncate 0o644) project_config_path project_config;
292292-293293- Git.add_all ~proc_mgr ~cwd;
294294- Git.commit ~proc_mgr ~cwd ~message:(Printf.sprintf "project: create %s" name);
295295-296296- (* Update registry on main branch if it exists *)
297297- begin match current with
298298- | Some "main" | Some "master" as main_branch ->
299299- let main = Option.get main_branch in
300300- Git.checkout ~proc_mgr ~cwd main;
301301- save_registry ~cwd registry;
302302- if Git.has_uncommitted_changes ~proc_mgr ~cwd then begin
303303- Git.add_all ~proc_mgr ~cwd;
304304- Git.commit ~proc_mgr ~cwd ~message:(Printf.sprintf "registry: add project %s" name)
305305- end;
306306- (* Switch back to project branch *)
307307- Git.checkout ~proc_mgr ~cwd branch
308308- | _ ->
309309- (* Not on main, just save registry to current project branch too *)
310310- save_registry ~cwd registry
311311- end;
312312-313313- Log.info (fun m -> m "Created project '%s' on branch '%s'" name branch);
314314- Log.info (fun m -> m "Add packages with: unpac add opam <pkg>")
315315-316316-let switch ~proc_mgr ~cwd name =
317317- let branch = project_branch name in
318318- if not (Git.branch_exists ~proc_mgr ~cwd branch) then begin
319319- Log.err (fun m -> m "Project %s does not exist" name);
320320- failwith (Printf.sprintf "Project '%s' does not exist. Create it with: unpac project create %s" name name)
321321- end;
322322-323323- if Git.has_uncommitted_changes ~proc_mgr ~cwd then begin
324324- Log.warn (fun m -> m "You have uncommitted changes");
325325- Log.warn (fun m -> m "Commit or stash them before switching projects")
326326- end;
327327-328328- Log.info (fun m -> m "Switching to project: %s" name);
329329- Git.checkout ~proc_mgr ~cwd branch
-100
lib/project.mli
···11-(** Project management - handling project branches.
22-33- The main branch serves as a registry of all projects.
44- Each project has its own branch [project/<name>] where actual work happens. *)
55-66-(** {1 Types} *)
77-88-type project_info = {
99- name : string;
1010- branch : string;
1111- description : string;
1212- created : string; (** ISO 8601 timestamp *)
1313-}
1414-1515-(** {1 Branch conventions} *)
1616-1717-val project_branch : string -> string
1818-(** [project_branch name] returns ["project/<name>"] *)
1919-2020-val is_project_branch : string -> bool
2121-(** [is_project_branch branch] checks if [branch] is a project branch. *)
2222-2323-val project_name_of_branch : string -> string option
2424-(** [project_name_of_branch branch] extracts project name from branch. *)
2525-2626-(** {1 Queries} *)
2727-2828-val current_project :
2929- proc_mgr:Git.proc_mgr ->
3030- cwd:Git.path ->
3131- string option
3232-(** [current_project ~proc_mgr ~cwd] returns the current project name if on a project branch. *)
3333-3434-val require_project_branch :
3535- proc_mgr:Git.proc_mgr ->
3636- cwd:Git.path ->
3737- string
3838-(** [require_project_branch ~proc_mgr ~cwd] returns project name or raises error. *)
3939-4040-val is_main_branch :
4141- proc_mgr:Git.proc_mgr ->
4242- cwd:Git.path ->
4343- bool
4444-(** [is_main_branch ~proc_mgr ~cwd] checks if currently on main branch. *)
4545-4646-val list_projects :
4747- proc_mgr:Git.proc_mgr ->
4848- cwd:Git.path ->
4949- project_info list
5050-(** [list_projects ~proc_mgr ~cwd] returns all projects from the registry. *)
5151-5252-val project_exists :
5353- proc_mgr:Git.proc_mgr ->
5454- cwd:Git.path ->
5555- string ->
5656- bool
5757-(** [project_exists ~proc_mgr ~cwd name] checks if project [name] exists. *)
5858-5959-(** {1 Operations} *)
6060-6161-val init :
6262- proc_mgr:Git.proc_mgr ->
6363- cwd:Git.path ->
6464- unit
6565-(** [init ~proc_mgr ~cwd] initializes the repository with main branch and registry. *)
6666-6767-val create :
6868- proc_mgr:Git.proc_mgr ->
6969- cwd:Git.path ->
7070- name:string ->
7171- ?description:string ->
7272- unit ->
7373- unit
7474-(** [create ~proc_mgr ~cwd ~name ()] creates a new project and switches to it.
7575- The project is registered in main branch's unpac.toml. *)
7676-7777-val switch :
7878- proc_mgr:Git.proc_mgr ->
7979- cwd:Git.path ->
8080- string ->
8181- unit
8282-(** [switch ~proc_mgr ~cwd name] switches to project [name]. *)
8383-8484-(** {1 Configuration} *)
8585-8686-type registry = {
8787- version : string;
8888- projects : project_info list;
8989-}
9090-9191-val load_registry :
9292- cwd:Git.path ->
9393- registry option
9494-(** [load_registry ~cwd] loads the project registry from main branch's unpac.toml. *)
9595-9696-val save_registry :
9797- cwd:Git.path ->
9898- registry ->
9999- unit
100100-(** [save_registry ~cwd registry] saves the project registry. *)
-315
lib/recovery.ml
···11-(** Recovery state for error recovery during multi-step operations. *)
22-33-let src = Logs.Src.create "unpac.recovery" ~doc:"Recovery operations"
44-module Log = (val Logs.src_log src : Logs.LOG)
55-66-(* Step types *)
77-88-type step =
99- | Remote_add of { remote : string; url : string }
1010- | Fetch of { remote : string }
1111- | Create_upstream of { branch : string; start_point : string }
1212- | Create_vendor of { name : string; upstream : string }
1313- | Create_patches of { branch : string; vendor : string }
1414- | Merge_to_project of { patches : string }
1515- | Update_toml of { package_name : string }
1616- | Commit of { message : string }
1717-1818-let step_name = function
1919- | Remote_add _ -> "remote_add"
2020- | Fetch _ -> "fetch"
2121- | Create_upstream _ -> "create_upstream"
2222- | Create_vendor _ -> "create_vendor"
2323- | Create_patches _ -> "create_patches"
2424- | Merge_to_project _ -> "merge_to_project"
2525- | Update_toml _ -> "update_toml"
2626- | Commit _ -> "commit"
2727-2828-let pp_step fmt = function
2929- | Remote_add { remote; url } ->
3030- Format.fprintf fmt "remote_add(%s -> %s)" remote url
3131- | Fetch { remote } ->
3232- Format.fprintf fmt "fetch(%s)" remote
3333- | Create_upstream { branch; start_point } ->
3434- Format.fprintf fmt "create_upstream(%s from %s)" branch start_point
3535- | Create_vendor { name; upstream } ->
3636- Format.fprintf fmt "create_vendor(%s from %s)" name upstream
3737- | Create_patches { branch; vendor } ->
3838- Format.fprintf fmt "create_patches(%s from %s)" branch vendor
3939- | Merge_to_project { patches } ->
4040- Format.fprintf fmt "merge_to_project(%s)" patches
4141- | Update_toml { package_name } ->
4242- Format.fprintf fmt "update_toml(%s)" package_name
4343- | Commit { message } ->
4444- let msg = if String.length message > 30 then String.sub message 0 30 ^ "..." else message in
4545- Format.fprintf fmt "commit(%s)" msg
4646-4747-(* Operation types *)
4848-4949-type operation =
5050- | Add_package of {
5151- name : string;
5252- url : string;
5353- branch : string;
5454- opam_packages : string list
5555- }
5656- | Update_package of { name : string }
5757- | Rebase_patches of { name : string }
5858-5959-let pp_operation fmt = function
6060- | Add_package { name; _ } ->
6161- Format.fprintf fmt "add_package(%s)" name
6262- | Update_package { name } ->
6363- Format.fprintf fmt "update_package(%s)" name
6464- | Rebase_patches { name } ->
6565- Format.fprintf fmt "rebase_patches(%s)" name
6666-6767-(* State *)
6868-6969-type state = {
7070- operation : operation;
7171- original_branch : string;
7272- original_head : string;
7373- started : string;
7474- completed : step list;
7575- pending : step list;
7676-}
7777-7878-let pp_state fmt state =
7979- Format.fprintf fmt "@[<v>Operation: %a@,Original: %s @ %s@,Started: %s@,Completed: %d steps@,Pending: %d steps@]"
8080- pp_operation state.operation
8181- state.original_branch state.original_head
8282- state.started
8383- (List.length state.completed)
8484- (List.length state.pending)
8585-8686-(* Persistence *)
8787-8888-let recovery_dir = ".unpac"
8989-let recovery_file = ".unpac/recovery.toml"
9090-9191-(* TOML encoding for steps - uses Tomlt.Toml for raw value construction *)
9292-module T = Tomlt.Toml
9393-9494-let step_to_toml step =
9595- let typ = step_name step in
9696- let data = match step with
9797- | Remote_add { remote; url } ->
9898- [("remote", T.string remote); ("url", T.string url)]
9999- | Fetch { remote } ->
100100- [("remote", T.string remote)]
101101- | Create_upstream { branch; start_point } ->
102102- [("branch", T.string branch); ("start_point", T.string start_point)]
103103- | Create_vendor { name; upstream } ->
104104- [("name", T.string name); ("upstream", T.string upstream)]
105105- | Create_patches { branch; vendor } ->
106106- [("branch", T.string branch); ("vendor", T.string vendor)]
107107- | Merge_to_project { patches } ->
108108- [("patches", T.string patches)]
109109- | Update_toml { package_name } ->
110110- [("package_name", T.string package_name)]
111111- | Commit { message } ->
112112- [("message", T.string message)]
113113- in
114114- T.table (("type", T.string typ) :: data)
115115-116116-let step_of_toml toml =
117117- let get_string key =
118118- match T.find_opt key toml with
119119- | Some (T.String s) -> s
120120- | _ -> failwith ("missing key: " ^ key)
121121- in
122122- match get_string "type" with
123123- | "remote_add" ->
124124- Remote_add { remote = get_string "remote"; url = get_string "url" }
125125- | "fetch" ->
126126- Fetch { remote = get_string "remote" }
127127- | "create_upstream" ->
128128- Create_upstream { branch = get_string "branch"; start_point = get_string "start_point" }
129129- | "create_vendor" ->
130130- Create_vendor { name = get_string "name"; upstream = get_string "upstream" }
131131- | "create_patches" ->
132132- Create_patches { branch = get_string "branch"; vendor = get_string "vendor" }
133133- | "merge_to_project" ->
134134- Merge_to_project { patches = get_string "patches" }
135135- | "update_toml" ->
136136- Update_toml { package_name = get_string "package_name" }
137137- | "commit" ->
138138- Commit { message = get_string "message" }
139139- | typ ->
140140- failwith ("unknown step type: " ^ typ)
141141-142142-let operation_to_toml op =
143143- match op with
144144- | Add_package { name; url; branch; opam_packages } ->
145145- T.table [
146146- ("type", T.string "add_package");
147147- ("name", T.string name);
148148- ("url", T.string url);
149149- ("branch", T.string branch);
150150- ("opam_packages", T.array (List.map T.string opam_packages));
151151- ]
152152- | Update_package { name } ->
153153- T.table [
154154- ("type", T.string "update_package");
155155- ("name", T.string name);
156156- ]
157157- | Rebase_patches { name } ->
158158- T.table [
159159- ("type", T.string "rebase_patches");
160160- ("name", T.string name);
161161- ]
162162-163163-let operation_of_toml toml =
164164- let get_string key =
165165- match T.find_opt key toml with
166166- | Some (T.String s) -> s
167167- | _ -> failwith ("missing key: " ^ key)
168168- in
169169- let get_string_list key =
170170- match T.find_opt key toml with
171171- | Some (T.Array arr) ->
172172- List.filter_map (function T.String s -> Some s | _ -> None) arr
173173- | _ -> []
174174- in
175175- match get_string "type" with
176176- | "add_package" ->
177177- Add_package {
178178- name = get_string "name";
179179- url = get_string "url";
180180- branch = get_string "branch";
181181- opam_packages = get_string_list "opam_packages";
182182- }
183183- | "update_package" ->
184184- Update_package { name = get_string "name" }
185185- | "rebase_patches" ->
186186- Rebase_patches { name = get_string "name" }
187187- | typ ->
188188- failwith ("unknown operation type: " ^ typ)
189189-190190-let state_to_toml state =
191191- T.table [
192192- ("operation", operation_to_toml state.operation);
193193- ("original_branch", T.string state.original_branch);
194194- ("original_head", T.string state.original_head);
195195- ("started", T.string state.started);
196196- ("completed", T.array (List.map step_to_toml state.completed));
197197- ("pending", T.array (List.map step_to_toml state.pending));
198198- ]
199199-200200-let state_of_toml toml =
201201- let get_string key =
202202- match T.find_opt key toml with
203203- | Some (T.String s) -> s
204204- | _ -> failwith ("missing key: " ^ key)
205205- in
206206- let get_table key =
207207- match T.find_opt key toml with
208208- | Some (T.Table t) -> T.table t
209209- | _ -> failwith ("missing table: " ^ key)
210210- in
211211- let get_step_list key =
212212- match T.find_opt key toml with
213213- | Some (T.Array arr) ->
214214- List.filter_map (function
215215- | T.Table t -> Some (step_of_toml (T.table t))
216216- | _ -> None
217217- ) arr
218218- | _ -> []
219219- in
220220- {
221221- operation = operation_of_toml (get_table "operation");
222222- original_branch = get_string "original_branch";
223223- original_head = get_string "original_head";
224224- started = get_string "started";
225225- completed = get_step_list "completed";
226226- pending = get_step_list "pending";
227227- }
228228-229229-let save ~cwd state =
230230- let dir_path = Eio.Path.(cwd / recovery_dir) in
231231- Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 dir_path;
232232- let file_path = Eio.Path.(cwd / recovery_file) in
233233- let toml = state_to_toml state in
234234- let content = Tomlt_bytesrw.to_string toml in
235235- Eio.Path.save ~create:(`Or_truncate 0o644) file_path content;
236236- Log.debug (fun m -> m "Saved recovery state to %s" recovery_file)
237237-238238-let load ~cwd =
239239- let file_path = Eio.Path.(cwd / recovery_file) in
240240- match Eio.Path.load file_path with
241241- | content ->
242242- begin match Tomlt_bytesrw.of_string content with
243243- | Ok toml ->
244244- let state = state_of_toml toml in
245245- Log.debug (fun m -> m "Loaded recovery state: %a" pp_state state);
246246- Some state
247247- | Error e ->
248248- Log.warn (fun m -> m "Failed to parse recovery file: %s"
249249- (Tomlt.Toml.Error.to_string e));
250250- None
251251- end
252252- | exception Eio.Io (Eio.Fs.E (Eio.Fs.Not_found _), _) ->
253253- None
254254- | exception exn ->
255255- Log.warn (fun m -> m "Failed to load recovery file: %a" Fmt.exn exn);
256256- None
257257-258258-let clear ~cwd =
259259- let file_path = Eio.Path.(cwd / recovery_file) in
260260- begin try Eio.Path.unlink file_path
261261- with Eio.Io (Eio.Fs.E (Eio.Fs.Not_found _), _) -> ()
262262- end;
263263- Log.debug (fun m -> m "Cleared recovery state")
264264-265265-let has_recovery ~cwd =
266266- let file_path = Eio.Path.(cwd / recovery_file) in
267267- match Eio.Path.kind ~follow:false file_path with
268268- | `Regular_file -> true
269269- | _ -> false
270270- | exception _ -> false
271271-272272-(* State transitions *)
273273-274274-let mark_step_complete state =
275275- match state.pending with
276276- | [] -> state
277277- | step :: rest ->
278278- { state with
279279- completed = step :: state.completed;
280280- pending = rest;
281281- }
282282-283283-let current_step state =
284284- match state.pending with
285285- | [] -> None
286286- | step :: _ -> Some step
287287-288288-(* Abort and resume *)
289289-290290-let abort ~proc_mgr ~cwd state =
291291- Log.info (fun m -> m "Aborting operation: %a" pp_operation state.operation);
292292- Log.info (fun m -> m "Restoring to: %s @ %s" state.original_branch state.original_head);
293293-294294- (* Abort any in-progress operations *)
295295- Git.rebase_abort ~proc_mgr ~cwd;
296296- Git.merge_abort ~proc_mgr ~cwd;
297297-298298- (* Reset to original state *)
299299- Git.reset_hard ~proc_mgr ~cwd state.original_head;
300300- Git.clean_fd ~proc_mgr ~cwd;
301301-302302- (* Switch back to original branch if possible *)
303303- begin try
304304- Git.checkout ~proc_mgr ~cwd state.original_branch
305305- with _ ->
306306- Log.warn (fun m -> m "Could not switch back to %s" state.original_branch)
307307- end;
308308-309309- (* Clear recovery state *)
310310- clear ~cwd;
311311-312312- Log.info (fun m -> m "Aborted. Repository restored to previous state.")
313313-314314-let can_resume state =
315315- state.pending <> []
-93
lib/recovery.mli
···11-(** Recovery state for error recovery during multi-step operations.
22-33- When a multi-step operation (like adding a package) fails partway through,
44- the recovery state allows us to either:
55- - Resume from where we left off
66- - Abort and rollback to the original state *)
77-88-(** {1 Step Types} *)
99-1010-type step =
1111- | Remote_add of { remote : string; url : string }
1212- | Fetch of { remote : string }
1313- | Create_upstream of { branch : string; start_point : string }
1414- | Create_vendor of { name : string; upstream : string }
1515- | Create_patches of { branch : string; vendor : string }
1616- | Merge_to_project of { patches : string }
1717- | Update_toml of { package_name : string }
1818- | Commit of { message : string }
1919-2020-val pp_step : Format.formatter -> step -> unit
2121-val step_name : step -> string
2222-2323-(** {1 Operation Types} *)
2424-2525-type operation =
2626- | Add_package of {
2727- name : string;
2828- url : string;
2929- branch : string;
3030- opam_packages : string list
3131- }
3232- | Update_package of { name : string }
3333- | Rebase_patches of { name : string }
3434-3535-val pp_operation : Format.formatter -> operation -> unit
3636-3737-(** {1 State} *)
3838-3939-type state = {
4040- operation : operation;
4141- original_branch : string;
4242- original_head : string;
4343- started : string; (** ISO 8601 timestamp *)
4444- completed : step list;
4545- pending : step list;
4646-}
4747-4848-val pp_state : Format.formatter -> state -> unit
4949-5050-(** {1 Persistence} *)
5151-5252-val recovery_dir : string
5353-(** [".unpac"] - directory for recovery state *)
5454-5555-val recovery_file : string
5656-(** [".unpac/recovery.toml"] - recovery state file *)
5757-5858-val save : cwd:Git.path -> state -> unit
5959-(** [save ~cwd state] persists recovery state to disk. *)
6060-6161-val load : cwd:Git.path -> state option
6262-(** [load ~cwd] loads recovery state if it exists. *)
6363-6464-val clear : cwd:Git.path -> unit
6565-(** [clear ~cwd] removes recovery state file. *)
6666-6767-val has_recovery : cwd:Git.path -> bool
6868-(** [has_recovery ~cwd] checks if there's pending recovery state. *)
6969-7070-(** {1 State Transitions} *)
7171-7272-val mark_step_complete : state -> state
7373-(** [mark_step_complete state] moves the first pending step to completed. *)
7474-7575-val current_step : state -> step option
7676-(** [current_step state] returns the next step to execute. *)
7777-7878-(** {1 Abort and Resume} *)
7979-8080-val abort :
8181- proc_mgr:Git.proc_mgr ->
8282- cwd:Git.path ->
8383- state ->
8484- unit
8585-(** [abort ~proc_mgr ~cwd state] aborts the operation and restores original state.
8686- This will:
8787- - Abort any in-progress merge or rebase
8888- - Reset to original HEAD
8989- - Clean up partial state
9090- - Remove recovery file *)
9191-9292-val can_resume : state -> bool
9393-(** [can_resume state] returns true if the operation can be resumed. *)
lib/repo_index.ml
lib/opam/repo_index.ml
lib/repo_index.mli
lib/opam/repo_index.mli
lib/solver.ml
lib/opam/solver.ml
lib/solver.mli
lib/opam/solver.mli
lib/source.ml
lib/opam/source.ml
lib/source.mli
lib/opam/source.mli
-85
lib/txn_log.ml
···11-(** Transaction log for debugging unpac operations.
22-33- Uses Unix I/O so it works both inside and outside Eio context. *)
44-55-let log_file = ".unpac.log"
66-77-let timestamp () =
88- let t = Unix.gettimeofday () in
99- let tm = Unix.localtime t in
1010- let ms = int_of_float ((t -. floor t) *. 1000.) in
1111- Printf.sprintf "%04d-%02d-%02d %02d:%02d:%02d.%03d"
1212- (tm.Unix.tm_year + 1900)
1313- (tm.Unix.tm_mon + 1)
1414- tm.Unix.tm_mday
1515- tm.Unix.tm_hour
1616- tm.Unix.tm_min
1717- tm.Unix.tm_sec
1818- ms
1919-2020-let append lines =
2121- try
2222- let oc = open_out_gen [Open_append; Open_creat; Open_text] 0o644 log_file in
2323- List.iter (fun line -> output_string oc (line ^ "\n")) lines;
2424- close_out oc
2525- with _ ->
2626- (* Silently ignore logging failures - don't break the main operation *)
2727- ()
2828-2929-let separator = "----------------------------------------"
3030-3131-let start_session ~args =
3232- let ts = timestamp () in
3333- let cmd = String.concat " " ("unpac" :: args) in
3434- let cwd = Sys.getcwd () in
3535- append [
3636- "";
3737- separator;
3838- Printf.sprintf "[%s] SESSION START" ts;
3939- Printf.sprintf "Command: %s" cmd;
4040- Printf.sprintf "CWD: %s" cwd;
4141- separator;
4242- ]
4343-4444-let end_session ~exit_code =
4545- let ts = timestamp () in
4646- let status = if exit_code = 0 then "SUCCESS" else Printf.sprintf "FAILED (exit %d)" exit_code in
4747- append [
4848- separator;
4949- Printf.sprintf "[%s] SESSION END: %s" ts status;
5050- separator;
5151- ]
5252-5353-let log_git_command ~args ~exit_code ~stdout ~stderr =
5454- let ts = timestamp () in
5555- let cmd = String.concat " " ("git" :: args) in
5656- let status = if exit_code = 0 then "OK" else Printf.sprintf "FAILED (exit %d)" exit_code in
5757- let lines = [
5858- Printf.sprintf "[%s] GIT: %s" ts cmd;
5959- Printf.sprintf " Status: %s" status;
6060- ] in
6161- let lines =
6262- if String.trim stdout <> "" then
6363- lines @ [
6464- " --- stdout ---";
6565- String.concat "\n" (List.map (fun l -> " " ^ l) (String.split_on_char '\n' (String.trim stdout)));
6666- ]
6767- else lines
6868- in
6969- let lines =
7070- if String.trim stderr <> "" then
7171- lines @ [
7272- " --- stderr ---";
7373- String.concat "\n" (List.map (fun l -> " " ^ l) (String.split_on_char '\n' (String.trim stderr)));
7474- ]
7575- else lines
7676- in
7777- append lines
7878-7979-let log_message msg =
8080- let ts = timestamp () in
8181- append [Printf.sprintf "[%s] INFO: %s" ts msg]
8282-8383-let log_error msg =
8484- let ts = timestamp () in
8585- append [Printf.sprintf "[%s] ERROR: %s" ts msg]
-31
lib/txn_log.mli
···11-(** Transaction log for debugging unpac operations.
22-33- Maintains a persistent .unpac.log file with a trace of all operations
44- and git commands for debugging purposes. Uses Unix I/O so it works
55- both inside and outside Eio context. *)
66-77-(** {1 Session Management} *)
88-99-val start_session : args:string list -> unit
1010-(** [start_session ~args] logs the start of an unpac session with the
1111- given command-line arguments. *)
1212-1313-val end_session : exit_code:int -> unit
1414-(** [end_session ~exit_code] logs the end of the current session. *)
1515-1616-(** {1 Command Logging} *)
1717-1818-val log_git_command :
1919- args:string list ->
2020- exit_code:int ->
2121- stdout:string ->
2222- stderr:string ->
2323- unit
2424-(** [log_git_command ~args ~exit_code ~stdout ~stderr] logs a git
2525- command execution with its full output. *)
2626-2727-val log_message : string -> unit
2828-(** [log_message msg] logs an informational message. *)
2929-3030-val log_error : string -> unit
3131-(** [log_error msg] logs an error message. *)