A monorepo management tool for the agentic ages

more

+1522 -3066
+400
ARCHITECTURE.md
··· 1 + # Unpac Architecture 2 + 3 + Unpac is a multi-backend vendoring tool that uses git worktrees for isolated branch operations. 4 + 5 + ## Directory Structure 6 + 7 + An unpac project has this structure: 8 + 9 + ``` 10 + my-project/ 11 + ├── git/ # Bare repository (shared object store) 12 + ├── main/ # Worktree → main branch (metadata only) 13 + │ └── unpac.toml 14 + ├── project/ # Project worktrees (where builds happen) 15 + │ ├── myapp/ # Worktree → project/myapp 16 + │ │ ├── src/ 17 + │ │ ├── vendor/ 18 + │ │ │ └── opam/ 19 + │ │ │ ├── astring/ 20 + │ │ │ └── eio/ 21 + │ │ ├── dune-project 22 + │ │ └── dune 23 + │ └── feature-x/ # Worktree → project/feature-x 24 + ├── opam/ # On-demand worktrees for opam backend 25 + │ ├── upstream/ 26 + │ │ └── astring/ # Temporary: during add/update 27 + │ ├── vendor/ 28 + │ │ └── astring/ # Temporary: during add/update 29 + │ └── patches/ 30 + │ └── astring/ # On-demand: created for editing 31 + └── cargo/ # Future: cargo backend worktrees 32 + ``` 33 + 34 + ## Branch Hierarchy 35 + 36 + ``` 37 + main # Metadata only (unpac.toml) 38 + 39 + ├── project/myapp # Buildable project (orphan) 40 + ├── project/feature-x # Another project (orphan) 41 + 42 + ├── opam/upstream/astring # Pristine upstream (files at root) 43 + ├── opam/upstream/eio 44 + ├── opam/vendor/astring # Orphan, files under vendor/opam/astring/ 45 + ├── opam/vendor/eio 46 + ├── opam/patches/astring # Forked from vendor, local modifications 47 + ├── opam/patches/eio 48 + 49 + └── cargo/... # Future 50 + ``` 51 + 52 + ## Branch Content 53 + 54 + ### `opam/upstream/<pkg>` 55 + 56 + Pristine upstream code, files at repository root: 57 + 58 + ``` 59 + (root) 60 + ├── src/ 61 + │ └── astring.ml 62 + ├── dune 63 + └── astring.opam 64 + ``` 65 + 66 + ### `opam/vendor/<pkg>` (orphan branch) 67 + 68 + Files relocated under `vendor/opam/<pkg>/` prefix for conflict-free merging: 69 + 70 + ``` 71 + (root) 72 + └── vendor/ 73 + └── opam/ 74 + └── astring/ 75 + ├── src/ 76 + │ └── astring.ml 77 + ├── dune 78 + └── astring.opam 79 + ``` 80 + 81 + ### `opam/patches/<pkg>` 82 + 83 + Forked from vendor branch. Same structure, may contain local modifications: 84 + 85 + ``` 86 + (root) 87 + └── vendor/ 88 + └── opam/ 89 + └── astring/ 90 + ├── src/ 91 + │ └── astring.ml # May be patched 92 + ├── dune 93 + └── astring.opam 94 + ``` 95 + 96 + ### `project/<name>` (orphan branch) 97 + 98 + Self-contained buildable project. Vendor content merged from patches branches: 99 + 100 + ``` 101 + (root) 102 + ├── src/ 103 + │ └── my_app.ml 104 + ├── vendor/ 105 + │ └── opam/ 106 + │ ├── astring/ # Merged from opam/patches/astring 107 + │ └── eio/ # Merged from opam/patches/eio 108 + ├── dune-project 109 + └── dune # Contains (vendored_dirs vendor) 110 + ``` 111 + 112 + ## Configuration 113 + 114 + ### `main/unpac.toml` 115 + 116 + Global configuration. Package lists are NOT stored here—they're derived from git. 117 + 118 + ```toml 119 + [opam] 120 + repositories = [ 121 + { name = "default", path = "/path/to/opam-repository" }, 122 + ] 123 + compiler = "5.4.0" 124 + 125 + # Optional: override default XDG cache location 126 + # vendor_cache = "/path/to/vendor-cache" 127 + 128 + [cargo] 129 + # Future 130 + 131 + [projects] 132 + # Project existence only, no package lists 133 + myapp = {} 134 + feature-x = {} 135 + ``` 136 + 137 + ## Vendor Cache 138 + 139 + A bare git repository that caches fetched packages to avoid hitting upstream remotes. 140 + 141 + **Default location**: `$XDG_CACHE_HOME/unpac/vendor-cache/` (via xdge) 142 + 143 + **Override**: Set `vendor_cache` in config or pass `--vendor-cache` on CLI. 144 + 145 + The cache holds: 146 + - `opam/upstream/*` branches (pristine upstream) 147 + - `opam/vendor/*` branches (pre-built with vendor prefix) 148 + 149 + Projects fetch from the cache as a git remote. 150 + 151 + ## Derivable State 152 + 153 + All package state is derived from git, not duplicated in TOML: 154 + 155 + | Information | How to derive | 156 + |-------------|---------------| 157 + | Vendored packages (global) | List `opam/patches/*` branches | 158 + | Packages in a project | List `vendor/opam/*/` directories | 159 + | Package versions | Commit metadata on `opam/upstream/*` | 160 + | Patch status | Diff `opam/patches/*` vs `opam/vendor/*` | 161 + | Merge status | Git merge history | 162 + 163 + ## Workflows 164 + 165 + ### Initialize Project 166 + 167 + ```bash 168 + unpac init my-project 169 + ``` 170 + 171 + 1. Create `my-project/git/` (bare repository) 172 + 2. Create `main` branch with initial `unpac.toml` 173 + 3. Create `my-project/main/` worktree 174 + 175 + ### Create Project Branch 176 + 177 + ```bash 178 + unpac project new myapp 179 + ``` 180 + 181 + 1. Create orphan branch `project/myapp` with template: 182 + - `dune-project` (lang dune 3.20) 183 + - `dune` with `(vendored_dirs vendor)` 184 + 2. Create worktree `project/myapp/` 185 + 3. Add to `[projects]` in `main/unpac.toml` 186 + 187 + ### Add Packages 188 + 189 + ```bash 190 + unpac opam add eio lwt --project=myapp 191 + ``` 192 + 193 + 1. Resolve dependencies (eio, lwt → full dependency tree) 194 + 2. For each package: 195 + a. Check vendor cache, fetch from upstream if missing 196 + b. Create `opam/upstream/<pkg>` branch 197 + c. Create `opam/vendor/<pkg>` orphan branch (with prefix) 198 + d. Create `opam/patches/<pkg>` branch (from vendor) 199 + 3. Merge all `opam/patches/*` into `project/myapp` 200 + 201 + Worktrees are created temporarily during operations and cleaned up after. 202 + 203 + ### Update Packages 204 + 205 + ```bash 206 + unpac opam update eio --project=myapp 207 + ``` 208 + 209 + 1. Fetch latest upstream into `opam/upstream/eio` 210 + 2. Update `opam/vendor/eio` with new content 211 + 3. Rebase `opam/patches/eio` onto new vendor 212 + - If conflicts: leave worktree for manual resolution 213 + 4. Merge updated patches into `project/myapp` 214 + 215 + ### Edit Patches 216 + 217 + ```bash 218 + unpac opam edit astring 219 + ``` 220 + 221 + 1. Create worktree `opam/patches/astring/` 222 + 2. User edits files in `vendor/opam/astring/` 223 + 3. User commits changes 224 + 225 + ```bash 226 + unpac opam done astring 227 + ``` 228 + 229 + 1. Remove worktree (keeps branch and commits) 230 + 231 + ```bash 232 + # Then merge into project 233 + cd project/myapp 234 + git merge opam/patches/astring 235 + ``` 236 + 237 + ### Vendor Cache Operations 238 + 239 + ```bash 240 + # Fetch packages into cache (without adding to any project) 241 + unpac cache fetch eio lwt --deps 242 + ``` 243 + 244 + 1. Resolve dependencies 245 + 2. Fetch each package into vendor cache 246 + 3. Create `opam/upstream/*` and `opam/vendor/*` branches in cache 247 + 248 + ## Module Structure 249 + 250 + ``` 251 + lib/ 252 + ├── unpac.ml # Library entry point 253 + 254 + ├── init.ml # Project initialization 255 + ├── config.ml # TOML config parsing 256 + ├── worktree.ml # Git worktree lifecycle management 257 + ├── git.ml # Low-level git operations 258 + ├── git_repo_lookup.ml # URL rewriting (erratique → github, etc.) 259 + ├── cache.ml # Vendor cache management 260 + 261 + ├── backend.ml # Backend module signature 262 + 263 + ├── opam/ # Opam backend 264 + │ ├── opam_backend.ml # Backend implementation 265 + │ ├── upstream.ml # opam/upstream/* management 266 + │ ├── vendor.ml # opam/vendor/* management 267 + │ ├── patches.ml # opam/patches/* management 268 + │ ├── solver.ml # Dependency resolution 269 + │ ├── source.ml # Source extraction 270 + │ ├── repo_index.ml # Repository indexing 271 + │ └── dev_repo.ml # Dev-repo normalization 272 + 273 + └── project.ml # Project branch operations 274 + 275 + bin/ 276 + └── main.ml # CLI 277 + ``` 278 + 279 + ### Key Module: `worktree.ml` 280 + 281 + Manages worktree lifecycle within the unpac directory structure. 282 + 283 + ```ocaml 284 + type root 285 + (** The unpac project root (contains git/, main/, etc.) *) 286 + 287 + type kind = 288 + | Main 289 + | Project of string 290 + | Opam_upstream of string 291 + | Opam_vendor of string 292 + | Opam_patches of string 293 + 294 + val path : root -> kind -> path 295 + (** Filesystem path for a worktree kind. *) 296 + 297 + val branch : kind -> string 298 + (** Git branch name for a worktree kind. *) 299 + 300 + val ensure : proc_mgr -> root -> kind -> unit 301 + (** Create worktree if it doesn't exist. *) 302 + 303 + val remove : proc_mgr -> root -> kind -> unit 304 + (** Remove worktree (keeps branch). *) 305 + 306 + val with_temp : proc_mgr -> root -> kind -> (path -> 'a) -> 'a 307 + (** Create worktree, run function, remove worktree. *) 308 + ``` 309 + 310 + ### Key Module: `backend.ml` 311 + 312 + Signature for package manager backends. 313 + 314 + ```ocaml 315 + module type S = sig 316 + val name : string 317 + (** "opam", "cargo", etc. *) 318 + 319 + val upstream_branch : string -> string 320 + val vendor_branch : string -> string 321 + val patches_branch : string -> string 322 + val vendor_path : string -> string 323 + 324 + val add : 325 + proc_mgr -> root -> cache:path -> name:string -> url:string -> branch:string -> unit 326 + 327 + val update : 328 + proc_mgr -> root -> name:string -> unit 329 + end 330 + ``` 331 + 332 + ## CLI Commands 333 + 334 + ``` 335 + unpac init <path> 336 + Initialize new unpac project 337 + 338 + unpac project new <name> 339 + Create new project branch 340 + 341 + unpac project list 342 + List projects 343 + 344 + unpac project remove <name> 345 + Remove project branch and worktree 346 + 347 + unpac opam add <pkg...> --project=<name> [--deps] 348 + Add packages to project (--deps is default) 349 + 350 + unpac opam update <pkg...> --project=<name> 351 + Update packages from upstream 352 + 353 + unpac opam remove <pkg...> --project=<name> 354 + Remove packages from project 355 + 356 + unpac opam edit <pkg> 357 + Create patches worktree for editing 358 + 359 + unpac opam done <pkg> 360 + Remove patches worktree 361 + 362 + unpac opam status [--project=<name>] 363 + Show package status 364 + 365 + unpac cache fetch <pkg...> [--deps] 366 + Fetch packages into vendor cache 367 + 368 + unpac cache status 369 + Show cache status 370 + ``` 371 + 372 + ## Future: Cargo Backend 373 + 374 + The architecture supports multiple backends. Cargo would follow the same pattern: 375 + 376 + ``` 377 + cargo/upstream/<crate> # Pristine from crates.io 378 + cargo/vendor/<crate> # Files under vendor/cargo/<crate>/ 379 + cargo/patches/<crate> # Local modifications 380 + ``` 381 + 382 + With corresponding worktree paths: 383 + 384 + ``` 385 + my-project/ 386 + ├── cargo/ 387 + │ ├── upstream/ 388 + │ │ └── serde/ 389 + │ ├── vendor/ 390 + │ │ └── serde/ 391 + │ └── patches/ 392 + │ └── serde/ 393 + └── project/ 394 + └── myapp/ 395 + └── vendor/ 396 + ├── opam/ 397 + │ └── eio/ 398 + └── cargo/ 399 + └── serde/ 400 + ```
+18
TODO.md
··· 1 + # TODO 2 + 3 + ## Vendor Cache Git Repository 4 + 5 + Add a persistent vendor cache as a bare git repository in the XDG cache directory 6 + (`$XDG_CACHE_HOME/unpac/vendor-cache` or `~/.cache/unpac/vendor-cache`). 7 + 8 + This cache would: 9 + - Store fetched upstream repositories as branches (e.g., `github.com/dbuenzli/astring/master`) 10 + - Persist across multiple unpac project runs 11 + - Save network fetches when the same package is used in multiple projects 12 + - Allow offline operations when packages are already cached 13 + 14 + Implementation notes: 15 + - Initialize bare git repo on first use 16 + - Add remotes and fetch before cloning into project's upstream branches 17 + - Use `git fetch --all` to update cache 18 + - Store branch names using URL-based naming (e.g., `github.com/owner/repo/branch`)
+1 -1
bin/dune
··· 2 2 (name main) 3 3 (public_name unpac) 4 4 (package unpac) 5 - (libraries unpac cmdliner eio_main logs.fmt fmt.tty)) 5 + (libraries unpac unpac_opam cmdliner eio_main logs logs.fmt fmt.tty))
+143 -951
bin/main.ml
··· 1 1 open Cmdliner 2 2 3 3 (* Logging setup *) 4 - 5 - let setup_logging style_renderer level = 6 - Fmt_tty.setup_std_outputs ?style_renderer (); 7 - Logs.set_level level; 8 - Logs.set_reporter (Logs_fmt.reporter ()); 9 - () 4 + let setup_logging () = 5 + Fmt_tty.setup_std_outputs (); 6 + Logs.set_level (Some Logs.Info); 7 + Logs.set_reporter (Logs_fmt.reporter ()) 10 8 11 9 let logging_term = 12 - Term.(const setup_logging $ Fmt_cli.style_renderer () $ Logs_cli.level ()) 10 + Term.(const setup_logging $ const ()) 13 11 14 - (* Common options *) 12 + (* Helper to find project root *) 13 + let with_root f = 14 + Eio_main.run @@ fun env -> 15 + let fs = Eio.Stdenv.fs env in 16 + let proc_mgr = Eio.Stdenv.process_mgr env in 17 + let cwd = Sys.getcwd () in 18 + match Unpac.Init.find_root ~fs ~cwd with 19 + | None -> 20 + Format.eprintf "Error: Not in an unpac project.@."; 21 + exit 1 22 + | Some root -> 23 + f ~env ~fs ~proc_mgr ~root 15 24 16 - let config_file = 17 - let doc = "Path to unpac.toml config file." in 18 - Arg.(value & opt file "unpac.toml" & info [ "c"; "config" ] ~doc ~docv:"FILE") 19 - 20 - let cache_dir_term = 21 - let app_env = "UNPAC_CACHE_DIR" in 22 - let xdg_var = "XDG_CACHE_HOME" in 23 - let home = Sys.getenv "HOME" in 24 - let default_path = home ^ "/.cache/unpac" in 25 - let doc = 26 - Printf.sprintf 27 - "Override cache directory. Can also be set with %s or %s. Default: %s" 28 - app_env xdg_var default_path 29 - in 30 - let arg = 31 - Arg.(value & opt string default_path & info [ "cache-dir" ] ~docv:"DIR" ~doc) 32 - in 33 - Term.( 34 - const (fun cmdline_val -> 35 - if cmdline_val <> default_path then cmdline_val 36 - else 37 - match Sys.getenv_opt app_env with 38 - | Some v when v <> "" -> v 39 - | _ -> ( 40 - match Sys.getenv_opt xdg_var with 41 - | Some v when v <> "" -> v ^ "/unpac" 42 - | _ -> default_path)) 43 - $ arg) 44 - 45 - (* Output format selection *) 46 - type output_format = Text | Json | Toml 47 - 48 - let output_format_term = 49 - let json = 50 - let doc = "Output in JSON format." in 51 - Arg.(value & flag & info [ "json" ] ~doc) 52 - in 53 - let toml = 54 - let doc = "Output in TOML format." in 55 - Arg.(value & flag & info [ "toml" ] ~doc) 56 - in 57 - let select json toml = 58 - match (json, toml) with 59 - | true, false -> Json 60 - | false, true -> Toml 61 - | false, false -> Text 62 - | true, true -> 63 - Format.eprintf "Cannot use both --json and --toml@."; 64 - Text 65 - in 66 - Term.(const select $ json $ toml) 67 - 68 - let get_format = function 69 - | Text -> Unpac.Output.Text 70 - | Json -> Unpac.Output.Json 71 - | Toml -> Unpac.Output.Toml 72 - 73 - (* Helper to load index from config with caching *) 74 - 75 - let load_index ~fs ~cache_dir config_path = 76 - let cache_path = Eio.Path.(fs / cache_dir) in 77 - Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 cache_path; 78 - Unpac.Cache.load_index ~cache_dir:cache_path ~config_path 79 - 80 - (* Get compiler spec from config *) 81 - let get_compiler_spec config_path = 82 - try 83 - let config = Unpac.Config.load_exn config_path in 84 - match config.opam.compiler with 85 - | Some s -> Unpac.Solver.parse_compiler_spec s 86 - | None -> None 87 - with _ -> None 88 - 89 - (* Error formatting helper - strip Eio.Io prefix for cleaner output *) 90 - let format_error exn = 91 - let s = Printexc.to_string exn in 92 - if String.length s > 7 && String.sub s 0 7 = "Eio.Io " then 93 - String.sub s 7 (String.length s - 7) 94 - else 95 - s 96 - 97 - (* Source kind selection *) 98 - let source_kind_term = 99 - let git = 100 - let doc = "Get git/dev-repo URLs instead of archive URLs." in 101 - Arg.(value & flag & info [ "git" ] ~doc) 102 - in 103 - Term.( 104 - const (fun git -> 105 - if git then Unpac.Source.Git else Unpac.Source.Archive) 106 - $ git) 107 - 108 - (* Resolve dependencies flag *) 109 - let resolve_deps_term = 110 - let doc = "Resolve dependencies using the 0install solver." in 111 - Arg.(value & flag & info [ "deps"; "with-deps" ] ~doc) 112 - 113 - (* ============================================================================ 114 - INIT COMMAND 115 - ============================================================================ *) 116 - 25 + (* Init command *) 117 26 let init_cmd = 118 - let doc = "Initialize a new unpac repository." in 119 - let man = [ 120 - `S Manpage.s_description; 121 - `P "Initializes a new git repository with unpac project structure."; 122 - `P "Creates the main branch with a project registry."; 123 - ] in 124 - let run () = 27 + let doc = "Initialize a new unpac project." in 28 + let path_arg = 29 + let doc = "Path for the new project." in 30 + Arg.(required & pos 0 (some string) None & info [] ~docv:"PATH" ~doc) 31 + in 32 + let run () path = 125 33 Eio_main.run @@ fun env -> 126 - let cwd = Eio.Stdenv.cwd env in 34 + let fs = Eio.Stdenv.fs env in 127 35 let proc_mgr = Eio.Stdenv.process_mgr env in 128 - Unpac.Project.init ~proc_mgr ~cwd:(cwd :> Eio.Fs.dir_ty Eio.Path.t); 129 - Format.printf "Repository initialized.@."; 130 - Format.printf "Create a project with: unpac project create <name>@." 36 + let _root = Unpac.Init.init ~proc_mgr ~fs path in 37 + Format.printf "Initialized unpac project at %s@." path 131 38 in 132 - let info = Cmd.info "init" ~doc ~man in 133 - Cmd.v info Term.(const run $ logging_term) 39 + let info = Cmd.info "init" ~doc in 40 + Cmd.v info Term.(const run $ logging_term $ path_arg) 134 41 135 - (* ============================================================================ 136 - PROJECT COMMANDS 137 - ============================================================================ *) 138 - 139 - let project_name_arg = 140 - let doc = "Project name." in 141 - Arg.(required & pos 0 (some string) None & info [] ~docv:"NAME" ~doc) 142 - 143 - let project_desc_opt = 144 - let doc = "Project description." in 145 - Arg.(value & opt (some string) None & info ["d"; "description"] ~docv:"DESC" ~doc) 146 - 147 - let project_create_cmd = 148 - let doc = "Create a new project." in 149 - let man = [ 150 - `S Manpage.s_description; 151 - `P "Creates a new project branch and switches to it."; 152 - `P "The project is registered in the main branch's unpac.toml."; 153 - ] in 154 - let run () name description = 155 - Eio_main.run @@ fun env -> 156 - let cwd = Eio.Stdenv.cwd env in 157 - let proc_mgr = Eio.Stdenv.process_mgr env in 158 - let description = match description with Some d -> d | None -> "" in 159 - Unpac.Project.create ~proc_mgr ~cwd:(cwd :> Eio.Fs.dir_ty Eio.Path.t) 160 - ~name ~description () 42 + (* Project new command *) 43 + let project_new_cmd = 44 + let doc = "Create a new project branch." in 45 + let name_arg = 46 + let doc = "Name of the project." in 47 + Arg.(required & pos 0 (some string) None & info [] ~docv:"NAME" ~doc) 48 + in 49 + let run () name = 50 + with_root @@ fun ~env:_ ~fs:_ ~proc_mgr ~root -> 51 + let _path = Unpac.Init.create_project ~proc_mgr root name in 52 + Format.printf "Created project %s@." name 161 53 in 162 - let info = Cmd.info "create" ~doc ~man in 163 - Cmd.v info Term.(const run $ logging_term $ project_name_arg $ project_desc_opt) 54 + let info = Cmd.info "new" ~doc in 55 + Cmd.v info Term.(const run $ logging_term $ name_arg) 164 56 57 + (* Project list command *) 165 58 let project_list_cmd = 166 - let doc = "List all projects." in 167 - let man = [ 168 - `S Manpage.s_description; 169 - `P "Lists all projects in the repository."; 170 - ] in 59 + let doc = "List projects." in 171 60 let run () = 172 - Eio_main.run @@ fun env -> 173 - let cwd = Eio.Stdenv.cwd env in 174 - let proc_mgr = Eio.Stdenv.process_mgr env in 175 - let projects = Unpac.Project.list_projects ~proc_mgr 176 - ~cwd:(cwd :> Eio.Fs.dir_ty Eio.Path.t) in 177 - let current = Unpac.Project.current_project ~proc_mgr 178 - ~cwd:(cwd :> Eio.Fs.dir_ty Eio.Path.t) in 179 - if projects = [] then 180 - Format.printf "No projects. Create one with: unpac project create <name>@." 181 - else begin 182 - Format.printf "Projects:@."; 183 - List.iter (fun (p : Unpac.Project.project_info) -> 184 - let marker = if Some p.name = current then "* " else " " in 185 - Format.printf "%s%s (%s)@." marker p.name p.branch 186 - ) projects 187 - end 61 + with_root @@ fun ~env:_ ~fs:_ ~proc_mgr ~root -> 62 + let projects = Unpac.Worktree.list_projects ~proc_mgr root in 63 + List.iter (Format.printf "%s@.") projects 188 64 in 189 - let info = Cmd.info "list" ~doc ~man in 65 + let info = Cmd.info "list" ~doc in 190 66 Cmd.v info Term.(const run $ logging_term) 191 67 192 - let project_switch_cmd = 193 - let doc = "Switch to a project." in 194 - let man = [ 195 - `S Manpage.s_description; 196 - `P "Switches to the specified project's branch."; 197 - ] in 198 - let run () name = 199 - Eio_main.run @@ fun env -> 200 - let cwd = Eio.Stdenv.cwd env in 201 - let proc_mgr = Eio.Stdenv.process_mgr env in 202 - Unpac.Project.switch ~proc_mgr ~cwd:(cwd :> Eio.Fs.dir_ty Eio.Path.t) name 203 - in 204 - let info = Cmd.info "switch" ~doc ~man in 205 - Cmd.v info Term.(const run $ logging_term $ project_name_arg) 206 - 68 + (* Project command group *) 207 69 let project_cmd = 208 70 let doc = "Project management commands." in 209 - let man = [ 210 - `S Manpage.s_description; 211 - `P "Commands for managing projects (branches)."; 212 - ] in 213 - let info = Cmd.info "project" ~doc ~man in 214 - Cmd.group info [project_create_cmd; project_list_cmd; project_switch_cmd] 71 + let info = Cmd.info "project" ~doc in 72 + Cmd.group info [project_new_cmd; project_list_cmd] 215 73 216 - (* ============================================================================ 217 - ADD COMMANDS 218 - ============================================================================ *) 219 - 220 - let package_name_arg = 221 - let doc = "Package name to add." in 222 - Arg.(required & pos 0 (some string) None & info [] ~docv:"PACKAGE" ~doc) 223 - 224 - let add_opam_cmd = 225 - let doc = "Add a package from opam." in 226 - let man = [ 227 - `S Manpage.s_description; 228 - `P "Adds a package from opam, creating vendor branches and merging into the current project."; 229 - `P "Must be on a project branch (not main)."; 230 - `P "Use --with-deps to include all transitive dependencies."; 231 - `S Manpage.s_examples; 232 - `P "Add a single package:"; 233 - `Pre " unpac add opam eio"; 234 - `P "Add a package with all dependencies:"; 235 - `Pre " unpac add opam lwt --with-deps"; 236 - ] in 237 - let run () config_path cache_dir resolve_deps pkg_name = 238 - Eio_main.run @@ fun env -> 239 - let fs = Eio.Stdenv.fs env in 240 - let cwd = Eio.Stdenv.cwd env in 241 - let proc_mgr = Eio.Stdenv.process_mgr env in 242 - let cwd_path = (cwd :> Eio.Fs.dir_ty Eio.Path.t) in 243 - 244 - (* Check we're on a project branch *) 245 - let _project = 246 - try Unpac.Project.require_project_branch ~proc_mgr ~cwd:cwd_path 247 - with Failure msg -> 248 - Format.eprintf "%s@." msg; 249 - exit 1 250 - in 251 - 252 - (* Check for pending recovery *) 253 - if Unpac.Recovery.has_recovery ~cwd:cwd_path then begin 254 - Format.eprintf "There's a pending operation. Run 'unpac vendor continue' or 'unpac vendor abort'.@."; 255 - exit 1 256 - end; 257 - 258 - (* Load opam index *) 259 - let index = load_index ~fs ~cache_dir config_path in 260 - let compiler = get_compiler_spec config_path in 261 - 262 - (* Parse package spec *) 263 - let spec = match Unpac.Solver.parse_package_spec pkg_name with 264 - | Ok s -> s 265 - | Error msg -> 266 - Format.eprintf "Invalid package spec: %s@." msg; 267 - exit 1 268 - in 269 - 270 - (* Get packages to add *) 271 - let packages_to_add = 272 - if resolve_deps then begin 273 - match Unpac.Solver.select_with_deps ?compiler index [spec] with 274 - | Ok selection -> selection.packages 275 - | Error msg -> 276 - Format.eprintf "Error resolving dependencies: %s@." msg; 277 - exit 1 278 - end else begin 279 - match Unpac.Solver.select_packages index [spec] with 280 - | Ok selection -> selection.packages 281 - | Error msg -> 282 - Format.eprintf "Error selecting package: %s@." msg; 283 - exit 1 284 - end 285 - in 286 - 287 - if packages_to_add = [] then begin 288 - Format.eprintf "Package '%s' not found.@." pkg_name; 289 - exit 1 290 - end; 291 - 292 - (* Group packages by dev-repo *) 293 - let sources = Unpac.Source.extract_all Unpac.Source.Git packages_to_add in 294 - let grouped = Unpac.Source.group_by_dev_repo sources in 295 - 296 - Format.printf "Found %d package group(s) to vendor:@." (List.length grouped); 297 - 298 - (* Add each group *) 299 - List.iter (fun (group : Unpac.Source.grouped_sources) -> 300 - match group.dev_repo with 301 - | None -> 302 - Format.printf " Skipping packages without dev-repo@." 303 - | Some dev_repo -> 304 - let url_str = Unpac.Dev_repo.to_string dev_repo in 305 - let opam_packages = List.map (fun (p : Unpac.Source.package_source) -> p.name) group.packages in 306 - 307 - (* Use first package name as canonical name, or extract from URL *) 308 - let name = 309 - match opam_packages with 310 - | first :: _ -> first 311 - | [] -> "unknown" 312 - in 313 - 314 - (* Reconstruct full URL for git clone, with rewrites *) 315 - let url = 316 - let raw_url = 317 - let first_pkg = List.hd group.packages in 318 - match first_pkg.source with 319 - | Unpac.Source.GitSource g -> g.url 320 - | _ -> "https://" ^ url_str (* Fallback *) 321 - in 322 - Unpac.Git_repo_lookup.rewrite_url raw_url 323 - in 324 - 325 - Format.printf " Adding %s (%d packages: %s)@." 326 - name (List.length opam_packages) 327 - (String.concat ", " opam_packages); 328 - 329 - (* Detect default branch *) 330 - let branch = Unpac.Git.ls_remote_default_branch ~proc_mgr ~url in 331 - 332 - match Unpac.Vendor.add_package ~proc_mgr ~cwd:cwd_path 333 - ~name ~url ~branch ~opam_packages with 334 - | Unpac.Vendor.Success { canonical_name; opam_packages; _ } -> 335 - Format.printf " [OK] Added %s (%d opam packages)@." 336 - canonical_name (List.length opam_packages) 337 - | Unpac.Vendor.Already_vendored name -> 338 - Format.printf " [SKIP] %s already vendored@." name 339 - | Unpac.Vendor.Failed { step; recovery_hint; error } -> 340 - Format.eprintf " [FAIL] Failed at step '%s': %s@." step 341 - (format_error error); 342 - Format.eprintf " %s@." recovery_hint; 343 - exit 1 344 - ) grouped; 345 - 346 - Format.printf "Done.@." 74 + (* Opam add command *) 75 + let opam_add_cmd = 76 + let doc = "Vendor an opam package from a git URL." in 77 + let url_arg = 78 + let doc = "Git URL of the package repository." in 79 + Arg.(required & pos 0 (some string) None & info [] ~docv:"URL" ~doc) 347 80 in 348 - let info = Cmd.info "opam" ~doc ~man in 349 - Cmd.v info Term.(const run $ logging_term $ config_file $ cache_dir_term 350 - $ resolve_deps_term $ package_name_arg) 351 - 352 - let add_cmd = 353 - let doc = "Add packages to the project." in 354 - let man = [ 355 - `S Manpage.s_description; 356 - `P "Commands for adding packages from various sources."; 357 - ] in 358 - let info = Cmd.info "add" ~doc ~man in 359 - Cmd.group info [add_opam_cmd] 360 - 361 - (* ============================================================================ 362 - VENDOR COMMANDS 363 - ============================================================================ *) 364 - 365 - let vendor_package_arg = 366 - let doc = "Package name." in 367 - Arg.(required & pos 0 (some string) None & info [] ~docv:"PACKAGE" ~doc) 368 - 369 - let vendor_status_cmd = 370 - let doc = "Show status of vendored packages." in 371 - let man = [ 372 - `S Manpage.s_description; 373 - `P "Shows the status of all vendored packages including their SHAs and patch counts."; 374 - ] in 375 - let run () = 376 - Eio_main.run @@ fun env -> 377 - let cwd = Eio.Stdenv.cwd env in 378 - let proc_mgr = Eio.Stdenv.process_mgr env in 379 - let cwd_path = (cwd :> Eio.Fs.dir_ty Eio.Path.t) in 380 - 381 - let statuses = Unpac.Vendor.all_status ~proc_mgr ~cwd:cwd_path in 382 - 383 - if statuses = [] then begin 384 - Format.printf "No vendored packages.@."; 385 - Format.printf "Add packages with: unpac add opam <pkg>@." 386 - end else begin 387 - (* Print header *) 388 - Format.printf "%-20s %-12s %-12s %-8s %-8s@." 389 - "PACKAGE" "UPSTREAM" "VENDOR" "PATCHES" "MERGED"; 390 - Format.printf "%-20s %-12s %-12s %-8s %-8s@." 391 - "-------" "--------" "------" "-------" "------"; 392 - 393 - List.iter (fun (s : Unpac.Vendor.package_status) -> 394 - let upstream = match s.upstream_sha with Some x -> x | None -> "-" in 395 - let vendor = match s.vendor_sha with Some x -> x | None -> "-" in 396 - let patches = string_of_int s.patch_count in 397 - let merged = if s.in_project then "yes" else "no" in 398 - Format.printf "%-20s %-12s %-12s %-8s %-8s@." 399 - s.name upstream vendor patches merged 400 - ) statuses 401 - end 81 + let name_arg = 82 + let doc = "Package name (defaults to repository name)." in 83 + Arg.(value & opt (some string) None & info ["n"; "name"] ~docv:"NAME" ~doc) 402 84 in 403 - let info = Cmd.info "status" ~doc ~man in 404 - Cmd.v info Term.(const run $ logging_term) 405 - 406 - let vendor_update_cmd = 407 - let doc = "Update a vendored package from upstream." in 408 - let man = [ 409 - `S Manpage.s_description; 410 - `P "Fetches the latest changes from upstream and updates the vendor branch."; 411 - `P "After updating, use 'unpac vendor rebase <pkg>' to rebase your patches."; 412 - ] in 413 - let run () name = 414 - Eio_main.run @@ fun env -> 415 - let cwd = Eio.Stdenv.cwd env in 416 - let proc_mgr = Eio.Stdenv.process_mgr env in 417 - let cwd_path = (cwd :> Eio.Fs.dir_ty Eio.Path.t) in 418 - 419 - match Unpac.Vendor.update_package ~proc_mgr ~cwd:cwd_path ~name with 420 - | Unpac.Vendor.Updated { old_sha; new_sha; commit_count } -> 421 - let old_short = String.sub old_sha 0 7 in 422 - let new_short = String.sub new_sha 0 7 in 423 - Format.printf "[OK] Updated %s: %s -> %s (%d commits)@." 424 - name old_short new_short commit_count; 425 - Format.printf "Next: unpac vendor rebase %s@." name 426 - | Unpac.Vendor.No_changes -> 427 - Format.printf "[OK] %s is up to date@." name 428 - | Unpac.Vendor.Update_failed { step; error; recovery_hint } -> 429 - Format.eprintf "[FAIL] Failed at step '%s': %s@." step 430 - (format_error error); 431 - Format.eprintf "%s@." recovery_hint; 432 - exit 1 433 - in 434 - let info = Cmd.info "update" ~doc ~man in 435 - Cmd.v info Term.(const run $ logging_term $ vendor_package_arg) 436 - 437 - let vendor_rebase_cmd = 438 - let doc = "Rebase patches onto updated vendor branch." in 439 - let man = [ 440 - `S Manpage.s_description; 441 - `P "Rebases your patches on top of the updated vendor branch."; 442 - `P "Run this after 'unpac vendor update <pkg>'."; 443 - ] in 444 - let run () name = 445 - Eio_main.run @@ fun env -> 446 - let cwd = Eio.Stdenv.cwd env in 447 - let proc_mgr = Eio.Stdenv.process_mgr env in 448 - let cwd_path = (cwd :> Eio.Fs.dir_ty Eio.Path.t) in 449 - 450 - match Unpac.Vendor.rebase_patches ~proc_mgr ~cwd:cwd_path ~name with 451 - | Ok () -> 452 - Format.printf "[OK] Rebased %s@." name; 453 - Format.printf "Next: unpac vendor merge %s@." name 454 - | Error (`Conflict _hint) -> 455 - Format.eprintf "[CONFLICT] Rebase has conflicts@."; 456 - Format.eprintf "Resolve conflicts, then: git rebase --continue@."; 457 - Format.eprintf "Or abort: git rebase --abort@."; 458 - exit 1 85 + let branch_arg = 86 + let doc = "Git branch to vendor (defaults to remote default)." in 87 + Arg.(value & opt (some string) None & info ["b"; "branch"] ~docv:"BRANCH" ~doc) 459 88 in 460 - let info = Cmd.info "rebase" ~doc ~man in 461 - Cmd.v info Term.(const run $ logging_term $ vendor_package_arg) 462 - 463 - let vendor_merge_cmd = 464 - let doc = "Merge patches into current project branch." in 465 - let man = [ 466 - `S Manpage.s_description; 467 - `P "Merges the patches branch into the current project branch."; 468 - ] in 469 - let run () name = 470 - Eio_main.run @@ fun env -> 471 - let cwd = Eio.Stdenv.cwd env in 472 - let proc_mgr = Eio.Stdenv.process_mgr env in 473 - let cwd_path = (cwd :> Eio.Fs.dir_ty Eio.Path.t) in 474 - 475 - match Unpac.Vendor.merge_to_project ~proc_mgr ~cwd:cwd_path ~name with 476 - | Ok () -> 477 - Format.printf "[OK] Merged %s into project@." name 478 - | Error (`Conflict _files) -> 479 - Format.eprintf "[CONFLICT] Merge has conflicts@."; 480 - Format.eprintf "Resolve conflicts, then: git add <files> && git commit@."; 481 - Format.eprintf "Or abort: git merge --abort@."; 89 + let run () url name_opt branch_opt = 90 + with_root @@ fun ~env:_ ~fs:_ ~proc_mgr ~root -> 91 + let name = match name_opt with 92 + | Some n -> n 93 + | None -> 94 + (* Extract name from URL *) 95 + let base = Filename.basename url in 96 + if String.ends_with ~suffix:".git" base then 97 + String.sub base 0 (String.length base - 4) 98 + else base 99 + in 100 + let info : Unpac.Backend.package_info = { 101 + name; 102 + url; 103 + branch = branch_opt; 104 + } in 105 + match Unpac_opam.Opam.add_package ~proc_mgr ~root info with 106 + | Unpac.Backend.Added { name; sha } -> 107 + Format.printf "Added %s (%s)@." name (String.sub sha 0 7) 108 + | Unpac.Backend.Already_exists name -> 109 + Format.printf "Package %s already vendored@." name 110 + | Unpac.Backend.Failed { name; error } -> 111 + Format.eprintf "Error adding %s: %s@." name error; 482 112 exit 1 483 113 in 484 - let info = Cmd.info "merge" ~doc ~man in 485 - Cmd.v info Term.(const run $ logging_term $ vendor_package_arg) 486 - 487 - let vendor_continue_cmd = 488 - let doc = "Continue an interrupted operation." in 489 - let man = [ 490 - `S Manpage.s_description; 491 - `P "Continues an operation that was interrupted (e.g., by a conflict)."; 492 - `P "Run this after resolving conflicts."; 493 - ] in 494 - let run () = 495 - Eio_main.run @@ fun env -> 496 - let cwd = Eio.Stdenv.cwd env in 497 - let proc_mgr = Eio.Stdenv.process_mgr env in 498 - let cwd_path = (cwd :> Eio.Fs.dir_ty Eio.Path.t) in 114 + let info = Cmd.info "add" ~doc in 115 + Cmd.v info Term.(const run $ logging_term $ url_arg $ name_arg $ branch_arg) 499 116 500 - match Unpac.Recovery.load ~cwd:cwd_path with 501 - | None -> 502 - Format.printf "No pending operation to continue.@." 503 - | Some state -> 504 - Format.printf "Continuing: %a@." Unpac.Recovery.pp_operation state.operation; 505 - match Unpac.Vendor.continue ~proc_mgr ~cwd:cwd_path state with 506 - | Unpac.Vendor.Success { canonical_name; _ } -> 507 - Format.printf "[OK] Completed %s@." canonical_name 508 - | Unpac.Vendor.Already_vendored name -> 509 - Format.printf "[OK] %s already vendored@." name 510 - | Unpac.Vendor.Failed { step; error; recovery_hint } -> 511 - Format.eprintf "[FAIL] Failed at step '%s': %s@." step 512 - (format_error error); 513 - Format.eprintf "%s@." recovery_hint; 514 - exit 1 515 - in 516 - let info = Cmd.info "continue" ~doc ~man in 517 - Cmd.v info Term.(const run $ logging_term) 518 - 519 - let vendor_abort_cmd = 520 - let doc = "Abort an interrupted operation." in 521 - let man = [ 522 - `S Manpage.s_description; 523 - `P "Aborts an operation and restores the repository to its previous state."; 524 - ] in 117 + (* Opam list command *) 118 + let opam_list_cmd = 119 + let doc = "List vendored opam packages." in 525 120 let run () = 526 - Eio_main.run @@ fun env -> 527 - let cwd = Eio.Stdenv.cwd env in 528 - let proc_mgr = Eio.Stdenv.process_mgr env in 529 - let cwd_path = (cwd :> Eio.Fs.dir_ty Eio.Path.t) in 530 - 531 - match Unpac.Recovery.load ~cwd:cwd_path with 532 - | None -> 533 - Format.printf "No pending operation to abort.@." 534 - | Some state -> 535 - Format.printf "Aborting: %a@." Unpac.Recovery.pp_operation state.operation; 536 - Unpac.Recovery.abort ~proc_mgr ~cwd:cwd_path state; 537 - Format.printf "[OK] Aborted. Repository restored.@." 121 + with_root @@ fun ~env:_ ~fs:_ ~proc_mgr ~root -> 122 + let packages = Unpac_opam.Opam.list_packages ~proc_mgr ~root in 123 + List.iter (Format.printf "%s@.") packages 538 124 in 539 - let info = Cmd.info "abort" ~doc ~man in 125 + let info = Cmd.info "list" ~doc in 540 126 Cmd.v info Term.(const run $ logging_term) 541 127 542 - let vendor_cmd = 543 - let doc = "Vendor package management." in 544 - let man = [ 545 - `S Manpage.s_description; 546 - `P "Commands for managing vendored packages."; 547 - ] in 548 - let info = Cmd.info "vendor" ~doc ~man in 549 - Cmd.group info [ 550 - vendor_status_cmd; 551 - vendor_update_cmd; 552 - vendor_rebase_cmd; 553 - vendor_merge_cmd; 554 - vendor_continue_cmd; 555 - vendor_abort_cmd; 556 - ] 557 - 558 - (* ============================================================================ 559 - OPAM COMMANDS (existing) 560 - ============================================================================ *) 561 - 562 - let opam_list_cmd = 563 - let doc = "List packages in the merged repository." in 564 - let man = 565 - [ 566 - `S Manpage.s_description; 567 - `P "Lists packages from all configured opam repositories."; 568 - `P "If no packages are specified, lists all available packages."; 569 - `P "Use --deps to include transitive dependencies."; 570 - `S Manpage.s_examples; 571 - `P "List all packages:"; 572 - `Pre " unpac opam list"; 573 - `P "List specific packages with dependencies:"; 574 - `Pre " unpac opam list --deps lwt cmdliner"; 575 - ] 128 + (* Opam update command *) 129 + let opam_update_cmd = 130 + let doc = "Update a vendored opam package from upstream." in 131 + let name_arg = 132 + let doc = "Package name to update." in 133 + Arg.(required & pos 0 (some string) None & info [] ~docv:"NAME" ~doc) 576 134 in 577 - let run () config_path cache_dir format resolve_deps package_specs = 578 - Eio_main.run @@ fun env -> 579 - let fs = Eio.Stdenv.fs env in 580 - let index = load_index ~fs ~cache_dir config_path in 581 - let compiler = get_compiler_spec config_path in 582 - let selection_result = 583 - if package_specs = [] then Ok (Unpac.Solver.select_all index) 584 - else if resolve_deps then Unpac.Solver.select_with_deps ?compiler index package_specs 585 - else Unpac.Solver.select_packages index package_specs 586 - in 587 - match selection_result with 588 - | Error msg -> 589 - Format.eprintf "Error selecting packages: %s@." msg; 135 + let run () name = 136 + with_root @@ fun ~env:_ ~fs:_ ~proc_mgr ~root -> 137 + match Unpac_opam.Opam.update_package ~proc_mgr ~root name with 138 + | Unpac.Backend.Updated { name; old_sha; new_sha } -> 139 + Format.printf "Updated %s: %s -> %s@." name 140 + (String.sub old_sha 0 7) (String.sub new_sha 0 7) 141 + | Unpac.Backend.No_changes name -> 142 + Format.printf "%s is up to date@." name 143 + | Unpac.Backend.Update_failed { name; error } -> 144 + Format.eprintf "Error updating %s: %s@." name error; 590 145 exit 1 591 - | Ok selection -> 592 - let packages = 593 - List.sort 594 - (fun (a : Unpac.Repo_index.package_info) b -> 595 - let cmp = OpamPackage.Name.compare a.name b.name in 596 - if cmp <> 0 then cmp 597 - else OpamPackage.Version.compare a.version b.version) 598 - selection.packages 599 - in 600 - Unpac.Output.output_package_list (get_format format) packages 601 146 in 602 - let info = Cmd.info "list" ~doc ~man in 603 - Cmd.v info 604 - Term.( 605 - const run $ logging_term $ config_file $ cache_dir_term $ output_format_term 606 - $ resolve_deps_term $ Unpac.Solver.package_specs_term) 147 + let info = Cmd.info "update" ~doc in 148 + Cmd.v info Term.(const run $ logging_term $ name_arg) 607 149 608 - let opam_info_cmd = 609 - let doc = "Show detailed information about packages." in 610 - let man = 611 - [ 612 - `S Manpage.s_description; 613 - `P "Displays detailed information about the specified packages."; 614 - `P "Use --deps to include transitive dependencies."; 615 - `S Manpage.s_examples; 616 - `P "Show info for a package:"; 617 - `Pre " unpac opam info lwt"; 618 - `P "Show info for packages and their dependencies:"; 619 - `Pre " unpac opam info --deps cmdliner"; 620 - ] 150 + (* Opam merge command *) 151 + let opam_merge_cmd = 152 + let doc = "Merge a vendored opam package into a project." in 153 + let pkg_arg = 154 + let doc = "Package name to merge." in 155 + Arg.(required & pos 0 (some string) None & info [] ~docv:"PACKAGE" ~doc) 621 156 in 622 - let run () config_path cache_dir format resolve_deps package_specs = 623 - Eio_main.run @@ fun env -> 624 - let fs = Eio.Stdenv.fs env in 625 - let index = load_index ~fs ~cache_dir config_path in 626 - let compiler = get_compiler_spec config_path in 627 - if package_specs = [] then begin 628 - Format.eprintf "Please specify at least one package.@."; 629 - exit 1 630 - end; 631 - let selection_result = 632 - if resolve_deps then Unpac.Solver.select_with_deps ?compiler index package_specs 633 - else Unpac.Solver.select_packages index package_specs 634 - in 635 - match selection_result with 636 - | Error msg -> 637 - Format.eprintf "Error selecting packages: %s@." msg; 638 - exit 1 639 - | Ok selection -> 640 - if selection.packages = [] then 641 - Format.eprintf "No packages found.@." 642 - else Unpac.Output.output_package_info (get_format format) selection.packages 157 + let project_arg = 158 + let doc = "Target project name." in 159 + Arg.(required & pos 1 (some string) None & info [] ~docv:"PROJECT" ~doc) 643 160 in 644 - let info = Cmd.info "info" ~doc ~man in 645 - Cmd.v info 646 - Term.( 647 - const run $ logging_term $ config_file $ cache_dir_term $ output_format_term 648 - $ resolve_deps_term $ Unpac.Solver.package_specs_term) 649 - 650 - let opam_related_cmd = 651 - let doc = "Show packages sharing the same dev-repo." in 652 - let man = 653 - [ 654 - `S Manpage.s_description; 655 - `P 656 - "Lists all packages that share a development repository with the \ 657 - specified packages."; 658 - `P "Use --deps to first resolve dependencies, then find related packages."; 659 - `S Manpage.s_examples; 660 - `P "Find related packages for a single package:"; 661 - `Pre " unpac opam related lwt"; 662 - `P "Find related packages including dependencies:"; 663 - `Pre " unpac opam related --deps cmdliner"; 664 - ] 665 - in 666 - let run () config_path cache_dir format resolve_deps package_specs = 667 - Eio_main.run @@ fun env -> 668 - let fs = Eio.Stdenv.fs env in 669 - let index = load_index ~fs ~cache_dir config_path in 670 - let compiler = get_compiler_spec config_path in 671 - if package_specs = [] then begin 672 - Format.eprintf "Please specify at least one package.@."; 673 - exit 1 674 - end; 675 - (* First, get the packages (with optional deps) *) 676 - let selection_result = 677 - if resolve_deps then Unpac.Solver.select_with_deps ?compiler index package_specs 678 - else Unpac.Solver.select_packages index package_specs 679 - in 680 - match selection_result with 681 - | Error msg -> 682 - Format.eprintf "Error selecting packages: %s@." msg; 161 + let run () pkg project = 162 + with_root @@ fun ~env:_ ~fs:_ ~proc_mgr ~root -> 163 + let patches_branch = Unpac_opam.Opam.patches_branch pkg in 164 + match Unpac.Backend.merge_to_project ~proc_mgr ~root ~project ~patches_branch with 165 + | Ok () -> 166 + Format.printf "Merged %s into project %s@." pkg project 167 + | Error (`Conflict files) -> 168 + Format.eprintf "Merge conflict in %s:@." pkg; 169 + List.iter (Format.eprintf " %s@.") files; 170 + Format.eprintf "Resolve conflicts in project/%s and commit.@." project; 683 171 exit 1 684 - | Ok selection -> 685 - (* Find related packages for all selected packages *) 686 - let all_related = List.concat_map (fun (info : Unpac.Repo_index.package_info) -> 687 - Unpac.Repo_index.related_packages info.name index) 688 - selection.packages 689 - in 690 - (* Deduplicate *) 691 - let seen = Hashtbl.create 64 in 692 - let unique = List.filter (fun (info : Unpac.Repo_index.package_info) -> 693 - let key = OpamPackage.Name.to_string info.name in 694 - if Hashtbl.mem seen key then false 695 - else begin Hashtbl.add seen key (); true end) 696 - all_related 697 - in 698 - let first_pkg = List.hd package_specs in 699 - let pkg_name = OpamPackage.Name.to_string first_pkg.Unpac.Solver.name in 700 - if unique = [] then 701 - Format.eprintf "No related packages found.@." 702 - else Unpac.Output.output_related (get_format format) pkg_name unique 703 172 in 704 - let info = Cmd.info "related" ~doc ~man in 705 - Cmd.v info 706 - Term.( 707 - const run $ logging_term $ config_file $ cache_dir_term $ output_format_term 708 - $ resolve_deps_term $ Unpac.Solver.package_specs_term) 173 + let info = Cmd.info "merge" ~doc in 174 + Cmd.v info Term.(const run $ logging_term $ pkg_arg $ project_arg) 709 175 710 - let opam_sources_cmd = 711 - let doc = "Get source URLs for packages, grouped by dev-repo." in 712 - let man = 713 - [ 714 - `S Manpage.s_description; 715 - `P 716 - "Outputs source URLs (archive or git) for the specified packages, \ 717 - grouped by their development repository (dev-repo). Packages that \ 718 - share the same dev-repo are listed together since they typically \ 719 - need to be fetched from the same source."; 720 - `P 721 - "If no packages are specified, outputs sources for all packages \ 722 - (latest version of each)."; 723 - `P 724 - "Use --git to get development repository URLs instead of archive URLs."; 725 - `P 726 - "Use --deps to include transitive dependencies using the 0install solver."; 727 - `S Manpage.s_examples; 728 - `P "Get archive URLs for all packages:"; 729 - `Pre " unpac opam sources"; 730 - `P "Get git URLs for specific packages:"; 731 - `Pre " unpac opam sources --git lwt dune"; 732 - `P "Get sources with version constraints:"; 733 - `Pre " unpac opam sources cmdliner>=1.0 lwt.5.6.0"; 734 - `P "Get sources with dependencies resolved:"; 735 - `Pre " unpac opam sources --deps lwt"; 736 - ] 737 - in 738 - let run () config_path cache_dir format source_kind resolve_deps package_specs = 739 - Eio_main.run @@ fun env -> 740 - let fs = Eio.Stdenv.fs env in 741 - let index = load_index ~fs ~cache_dir config_path in 742 - let compiler = get_compiler_spec config_path in 743 - (* Select packages based on specs *) 744 - let selection_result = 745 - if package_specs = [] then Ok (Unpac.Solver.select_all index) 746 - else if resolve_deps then Unpac.Solver.select_with_deps ?compiler index package_specs 747 - else Unpac.Solver.select_packages index package_specs 748 - in 749 - match selection_result with 750 - | Error msg -> 751 - Format.eprintf "Error selecting packages: %s@." msg; 752 - exit 1 753 - | Ok selection -> 754 - let sources = 755 - Unpac.Source.extract_all source_kind selection.packages 756 - in 757 - (* Filter out packages with no source *) 758 - let sources = 759 - List.filter 760 - (fun (s : Unpac.Source.package_source) -> 761 - s.source <> Unpac.Source.NoSource) 762 - sources 763 - in 764 - Unpac.Output.output_sources (get_format format) sources 765 - in 766 - let info = Cmd.info "sources" ~doc ~man in 767 - Cmd.v info 768 - Term.( 769 - const run $ logging_term $ config_file $ cache_dir_term $ output_format_term $ source_kind_term 770 - $ resolve_deps_term $ Unpac.Solver.package_specs_term) 771 - 772 - let opam_vendor_fetch_cmd = 773 - let doc = "Fetch package sources into a vendor git repository." in 774 - let man = 775 - [ 776 - `S Manpage.s_description; 777 - `P 778 - "Fetches git sources for the specified packages into a centralized \ 779 - vendor git repository. This repository can then be used as a local \ 780 - cache for subsequent 'unpac add' operations."; 781 - `P 782 - "Each package's upstream is stored as a branch named 'upstream/<pkg>' \ 783 - in the vendor repository."; 784 - `P "Use --deps to include transitive dependencies."; 785 - `S Manpage.s_examples; 786 - `P "Fetch a package and its dependencies:"; 787 - `Pre " unpac opam vendor-fetch --deps lwt --vendor-repo ./vendor-cache"; 788 - `P "Fetch multiple packages:"; 789 - `Pre " unpac opam vendor-fetch eio cmdliner fmt --vendor-repo ~/vendor"; 790 - ] 791 - in 792 - let vendor_repo_arg = 793 - let doc = "Path to the vendor git repository (overrides config)." in 794 - Arg.(value & opt (some string) None & info ["vendor-repo"] ~docv:"DIR" ~doc) 795 - in 796 - let run () config_path cache_dir resolve_deps vendor_repo_arg package_specs = 797 - Eio_main.run @@ fun env -> 798 - let fs = Eio.Stdenv.fs env in 799 - let proc_mgr = Eio.Stdenv.process_mgr env in 800 - 801 - if package_specs = [] then begin 802 - Format.eprintf "Please specify at least one package.@."; 803 - exit 1 804 - end; 805 - 806 - (* Load config and determine vendor repo path *) 807 - let config = Unpac.Config.load_exn config_path in 808 - let vendor_repo = match vendor_repo_arg with 809 - | Some path -> path 810 - | None -> match config.opam.vendor_repo with 811 - | Some path -> path 812 - | None -> 813 - Format.eprintf "Error: No vendor-repo specified. Use --vendor-repo or set opam.vendor_repo in config.@."; 814 - exit 1 815 - in 816 - let vendor_path = Eio.Path.(fs / vendor_repo) in 817 - 818 - (* Initialize vendor repo if needed *) 819 - if not (Unpac.Git.is_repository vendor_path) then begin 820 - Format.printf "Initializing vendor repository at %s@." vendor_repo; 821 - Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 vendor_path; 822 - Unpac.Git.init ~proc_mgr ~cwd:vendor_path 823 - end; 824 - 825 - (* Check for vendor-upstream remote in config (for fetching pre-vendored branches) *) 826 - let vendor_upstream_url = match vendor_repo_arg with 827 - | Some _ -> config.opam.vendor_repo (* If overriding, use config as upstream *) 828 - | None -> None (* No separate upstream if using config directly *) 829 - in 830 - let has_vendor_upstream = Option.is_some vendor_upstream_url in 831 - 832 - (* Setup vendor-upstream remote if configured *) 833 - (match vendor_upstream_url with 834 - | Some url -> 835 - Format.printf "Setting up vendor-upstream remote -> %s@." url; 836 - ignore (Unpac.Git.ensure_remote ~proc_mgr ~cwd:vendor_path ~name:"vendor-upstream" ~url); 837 - Unpac.Git.fetch ~proc_mgr ~cwd:vendor_path ~remote:"vendor-upstream" 838 - | None -> ()); 839 - 840 - (* Load opam index and resolve packages *) 841 - let index = load_index ~fs ~cache_dir config_path in 842 - let compiler = get_compiler_spec config_path in 843 - let selection_result = 844 - if resolve_deps then Unpac.Solver.select_with_deps ?compiler index package_specs 845 - else Unpac.Solver.select_packages index package_specs 846 - in 847 - let packages = match selection_result with 848 - | Error msg -> 849 - Format.eprintf "Error selecting packages: %s@." msg; 850 - exit 1 851 - | Ok selection -> selection.packages 852 - in 853 - 854 - (* Extract sources and group by dev-repo *) 855 - let sources = Unpac.Source.extract_all Unpac.Source.Git packages in 856 - let grouped = Unpac.Source.group_by_dev_repo sources in 857 - 858 - Format.printf "Found %d unique git source(s) to fetch:@." (List.length grouped); 859 - 860 - (* Fetch each unique dev-repo *) 861 - let fetched = ref 0 in 862 - let from_upstream = ref 0 in 863 - let skipped = ref 0 in 864 - List.iter (fun (group : Unpac.Source.grouped_sources) -> 865 - match group.dev_repo with 866 - | None -> 867 - incr skipped; 868 - let pkg_names = List.map (fun (p : Unpac.Source.package_source) -> p.name) group.packages in 869 - Format.printf " [SKIP] No dev-repo: %s@." (String.concat ", " pkg_names) 870 - | Some _dev_repo -> 871 - let opam_packages = List.map (fun (p : Unpac.Source.package_source) -> p.name) group.packages in 872 - let name = match opam_packages with 873 - | first :: _ -> first 874 - | [] -> "unknown" 875 - in 876 - 877 - let upstream_branch = "opam/upstream/" ^ name in 878 - 879 - (* Check if branch already exists from vendor-upstream *) 880 - let found_in_vendor_upstream = 881 - if has_vendor_upstream then 882 - let ref_name = "vendor-upstream/" ^ upstream_branch in 883 - Option.is_some (Unpac.Git.rev_parse ~proc_mgr ~cwd:vendor_path ref_name) 884 - else false 885 - in 886 - 887 - if found_in_vendor_upstream then begin 888 - (* Use branch from vendor-upstream *) 889 - let ref_point = "vendor-upstream/" ^ upstream_branch in 890 - Unpac.Git.branch_force ~proc_mgr ~cwd:vendor_path ~name:upstream_branch ~point:ref_point; 891 - incr fetched; 892 - incr from_upstream; 893 - Format.printf " [OK] %s -> %s (from vendor-upstream)@." name upstream_branch 894 - end else begin 895 - (* Fall back to fetching from original upstream *) 896 - let url = 897 - let raw_url = 898 - let first_pkg = List.hd group.packages in 899 - match first_pkg.source with 900 - | Unpac.Source.GitSource g -> g.url 901 - | _ -> "" 902 - in 903 - Unpac.Git_repo_lookup.rewrite_url raw_url 904 - in 905 - 906 - if url = "" then begin 907 - incr skipped; 908 - Format.printf " [SKIP] No git URL for %s@." name 909 - end else begin 910 - Format.printf " Fetching %s from %s@." name url; 911 - 912 - let remote = "origin-" ^ name in 913 - 914 - try 915 - (* Add/update remote *) 916 - ignore (Unpac.Git.ensure_remote ~proc_mgr ~cwd:vendor_path ~name:remote ~url); 917 - 918 - (* Fetch from remote *) 919 - Unpac.Git.fetch ~proc_mgr ~cwd:vendor_path ~remote; 920 - 921 - (* Detect default branch *) 922 - let default_branch = Unpac.Git.ls_remote_default_branch ~proc_mgr ~url in 923 - let ref_point = remote ^ "/" ^ default_branch in 924 - 925 - (* Create/update upstream branch *) 926 - Unpac.Git.branch_force ~proc_mgr ~cwd:vendor_path ~name:upstream_branch ~point:ref_point; 927 - 928 - incr fetched; 929 - Format.printf " [OK] %s -> %s@." name upstream_branch 930 - with exn -> 931 - Format.eprintf " [FAIL] %s: %s@." name (format_error exn) 932 - end 933 - end 934 - ) grouped; 935 - 936 - if has_vendor_upstream then 937 - Format.printf "Done: %d fetched (%d from vendor-upstream), %d skipped@." !fetched !from_upstream !skipped 938 - else 939 - Format.printf "Done: %d fetched, %d skipped@." !fetched !skipped 940 - in 941 - let info = Cmd.info "vendor-fetch" ~doc ~man in 942 - Cmd.v info 943 - Term.( 944 - const run $ logging_term $ config_file $ cache_dir_term 945 - $ resolve_deps_term $ vendor_repo_arg $ Unpac.Solver.package_specs_term) 946 - 947 - (* Opam subcommand group *) 948 - 176 + (* Opam command group *) 949 177 let opam_cmd = 950 - let doc = "Opam repository operations." in 951 - let man = 952 - [ 953 - `S Manpage.s_description; 954 - `P 955 - "Commands for querying and managing opam repositories defined in the \ 956 - configuration file."; 957 - ] 958 - in 959 - let info = Cmd.info "opam" ~doc ~man in 960 - Cmd.group info [ opam_list_cmd; opam_info_cmd; opam_related_cmd; opam_sources_cmd; opam_vendor_fetch_cmd ] 961 - 962 - (* ============================================================================ 963 - MAIN COMMAND 964 - ============================================================================ *) 178 + let doc = "Opam package vendoring commands." in 179 + let info = Cmd.info "opam" ~doc in 180 + Cmd.group info [opam_add_cmd; opam_list_cmd; opam_update_cmd; opam_merge_cmd] 965 181 182 + (* Main command *) 966 183 let main_cmd = 967 - let doc = "Monorepo management tool." in 968 - let man = 969 - [ 970 - `S Manpage.s_description; 971 - `P "unpac is a tool for managing OCaml monorepos with vendored packages."; 972 - `P "It uses a project-based branch model:"; 973 - `P " - main branch holds the project registry"; 974 - `P " - project/<name> branches hold actual code and vendor packages"; 975 - `S "QUICK START"; 976 - `P "Initialize a new repository:"; 977 - `Pre " unpac init"; 978 - `P "Create a project:"; 979 - `Pre " unpac project create myapp"; 980 - `P "Add packages:"; 981 - `Pre " unpac add opam eio"; 982 - `Pre " unpac add opam lwt --with-deps"; 983 - `P "Check status:"; 984 - `Pre " unpac vendor status"; 985 - `S Manpage.s_bugs; 986 - `P "Report bugs at https://github.com/avsm/unpac/issues"; 987 - ] 988 - in 989 - let info = Cmd.info "unpac" ~version:"0.1.0" ~doc ~man in 990 - Cmd.group info [ init_cmd; project_cmd; add_cmd; vendor_cmd; opam_cmd ] 184 + let doc = "Multi-backend vendoring tool using git worktrees." in 185 + let info = Cmd.info "unpac" ~version:"0.1.0" ~doc in 186 + Cmd.group info [init_cmd; project_cmd; opam_cmd] 991 187 992 - let () = 993 - Unpac.Txn_log.start_session ~args:(List.tl (Array.to_list Sys.argv)); 994 - let exit_code = Cmd.eval main_cmd in 995 - Unpac.Txn_log.end_session ~exit_code; 996 - exit exit_code 188 + let () = exit (Cmd.eval main_cmd)
+16 -5
dune-project
··· 10 10 (license ISC) 11 11 (depends 12 12 (ocaml (>= 5.1.0)) 13 - (cmdliner (>= 1.2.0)) 14 13 (eio_main (>= 1.0)) 15 14 (logs (>= 0.7.0)) 16 15 (fmt (>= 0.9.0)) 17 - tomlt 18 - jsont 19 - xdge 16 + tomlt)) 17 + 18 + (package 19 + (name unpac-opam) 20 + (synopsis "Opam backend for unpac") 21 + (description "Opam package vendoring backend for unpac") 22 + (authors "Anil Madhavapeddy") 23 + (license ISC) 24 + (depends 25 + (ocaml (>= 5.1.0)) 26 + unpac 20 27 opam-format 21 28 opam-core 22 - opam-repository)) 29 + opam-repository 30 + opam-solver 31 + opam-0install-cudf 32 + cudf 33 + (cmdliner (>= 1.2.0))))
+97
lib/backend.ml
··· 1 + (** Backend module signature for package managers. 2 + 3 + Each backend (opam, cargo, etc.) implements this interface to provide 4 + vendoring capabilities. *) 5 + 6 + (** {1 Types} *) 7 + 8 + type package_info = { 9 + name : string; 10 + url : string; 11 + branch : string option; (** Branch/tag/ref to use *) 12 + } 13 + (** Information about a package to vendor. *) 14 + 15 + type add_result = 16 + | Added of { name : string; sha : string } 17 + | Already_exists of string 18 + | Failed of { name : string; error : string } 19 + 20 + type update_result = 21 + | Updated of { name : string; old_sha : string; new_sha : string } 22 + | No_changes of string 23 + | Update_failed of { name : string; error : string } 24 + 25 + (** {1 Backend Signature} *) 26 + 27 + module type S = sig 28 + val name : string 29 + (** Backend name, e.g. "opam", "cargo". *) 30 + 31 + (** {2 Branch Naming} *) 32 + 33 + val upstream_branch : string -> string 34 + (** [upstream_branch pkg] returns branch name, e.g. "opam/upstream/astring". *) 35 + 36 + val vendor_branch : string -> string 37 + (** [vendor_branch pkg] returns branch name, e.g. "opam/vendor/astring". *) 38 + 39 + val patches_branch : string -> string 40 + (** [patches_branch pkg] returns branch name, e.g. "opam/patches/astring". *) 41 + 42 + val vendor_path : string -> string 43 + (** [vendor_path pkg] returns path prefix, e.g. "vendor/opam/astring". *) 44 + 45 + (** {2 Worktree Kinds} *) 46 + 47 + val upstream_kind : string -> Worktree.kind 48 + val vendor_kind : string -> Worktree.kind 49 + val patches_kind : string -> Worktree.kind 50 + 51 + (** {2 Package Operations} *) 52 + 53 + val add_package : 54 + proc_mgr:Git.proc_mgr -> 55 + root:Worktree.root -> 56 + package_info -> 57 + add_result 58 + (** [add_package ~proc_mgr ~root info] vendors a single package. 59 + 60 + 1. Creates/updates opam/upstream/<pkg> from URL 61 + 2. Creates opam/vendor/<pkg> orphan with vendor/ prefix 62 + 3. Creates opam/patches/<pkg> from vendor *) 63 + 64 + val update_package : 65 + proc_mgr:Git.proc_mgr -> 66 + root:Worktree.root -> 67 + string -> 68 + update_result 69 + (** [update_package ~proc_mgr ~root name] updates a package from upstream. 70 + 71 + 1. Fetches latest into opam/upstream/<pkg> 72 + 2. Updates opam/vendor/<pkg> with new content 73 + Does NOT rebase patches - that's a separate operation. *) 74 + 75 + val list_packages : 76 + proc_mgr:Git.proc_mgr -> 77 + root:Worktree.root -> 78 + string list 79 + (** [list_packages ~proc_mgr root] returns all vendored package names. *) 80 + end 81 + 82 + (** {1 Merge Operations} *) 83 + 84 + (** These operations are backend-agnostic and work on any patches branch. *) 85 + 86 + let merge_to_project ~proc_mgr ~root ~project ~patches_branch = 87 + let project_wt = Worktree.path root (Worktree.Project project) in 88 + Git.merge_allow_unrelated ~proc_mgr ~cwd:project_wt 89 + ~branch:patches_branch 90 + ~message:(Printf.sprintf "Merge %s" patches_branch) 91 + 92 + let rebase_patches ~proc_mgr ~root ~patches_kind ~onto = 93 + Worktree.ensure ~proc_mgr root patches_kind; 94 + let patches_wt = Worktree.path root patches_kind in 95 + let result = Git.rebase ~proc_mgr ~cwd:patches_wt ~onto in 96 + Worktree.remove ~proc_mgr root patches_kind; 97 + result
-127
lib/cache.ml
··· 1 - (** Config file modification time - if this changes, config was edited *) 2 - type cache_header = float 3 - 4 - (** Repo content modification times - if any change, repo contents changed *) 5 - type cache_key = float list 6 - 7 - let cache_filename = "repo_index.cache" 8 - 9 - let get_file_mtime path = 10 - try 11 - let stat = Unix.stat path in 12 - stat.Unix.st_mtime 13 - with Unix.Unix_error _ -> 0.0 14 - 15 - let get_repo_mtime path = 16 - let packages_dir = Filename.concat path "packages" in 17 - get_file_mtime packages_dir 18 - 19 - let make_cache_key (repos : Config.repo_config list) = 20 - List.filter_map 21 - (fun (r : Config.repo_config) -> 22 - match r.source with 23 - | Config.Local path -> Some (get_repo_mtime path) 24 - | Config.Remote _ -> None) 25 - repos 26 - 27 - let cache_path cache_dir = 28 - Eio.Path.(cache_dir / cache_filename) 29 - 30 - (* Read just the header to check if config has changed *) 31 - let read_cache_header cache_dir = 32 - let path = cache_path cache_dir in 33 - try 34 - let path_str = Eio.Path.native_exn path in 35 - let ic = open_in_bin path_str in 36 - Fun.protect 37 - ~finally:(fun () -> close_in ic) 38 - (fun () -> 39 - let header : cache_header = Marshal.from_channel ic in 40 - Some header) 41 - with 42 - | Sys_error _ -> None 43 - | End_of_file -> None 44 - | Failure _ -> None 45 - 46 - (* Load full cache if header and key match *) 47 - let load_cached cache_dir expected_header expected_key = 48 - let path = cache_path cache_dir in 49 - try 50 - let path_str = Eio.Path.native_exn path in 51 - let ic = open_in_bin path_str in 52 - Fun.protect 53 - ~finally:(fun () -> close_in ic) 54 - (fun () -> 55 - let header : cache_header = Marshal.from_channel ic in 56 - if not (Float.equal header expected_header) then None 57 - else 58 - let key : cache_key = Marshal.from_channel ic in 59 - if not (List.equal Float.equal key expected_key) then None 60 - else 61 - let index : Repo_index.t = Marshal.from_channel ic in 62 - Some index) 63 - with 64 - | Sys_error _ -> None 65 - | End_of_file -> None 66 - | Failure _ -> None 67 - 68 - let save_cache cache_dir header key (index : Repo_index.t) = 69 - let path = cache_path cache_dir in 70 - try 71 - let path_str = Eio.Path.native_exn path in 72 - let oc = open_out_bin path_str in 73 - Fun.protect 74 - ~finally:(fun () -> close_out oc) 75 - (fun () -> 76 - Marshal.to_channel oc header []; 77 - Marshal.to_channel oc key []; 78 - Marshal.to_channel oc index []) 79 - with 80 - | Sys_error msg -> 81 - Format.eprintf "Warning: Could not save cache: %s@." msg 82 - | Failure msg -> 83 - Format.eprintf "Warning: Could not serialize cache: %s@." msg 84 - 85 - let rec load_index ~cache_dir ~config_path = 86 - let header : cache_header = get_file_mtime config_path in 87 - 88 - (* Quick check: has config file changed? *) 89 - let cached_header = read_cache_header cache_dir in 90 - let config_unchanged = 91 - match cached_header with 92 - | Some h -> Float.equal h header 93 - | None -> false 94 - in 95 - 96 - (* Load config *) 97 - let config = Config.load_exn config_path in 98 - let key = make_cache_key config.opam.repositories in 99 - 100 - (* If config unchanged, try to load from cache *) 101 - if config_unchanged then 102 - match load_cached cache_dir header key with 103 - | Some index -> index 104 - | None -> 105 - (* Cache invalid, rebuild *) 106 - let index = build_index config in 107 - save_cache cache_dir header key index; 108 - index 109 - else begin 110 - (* Config changed, rebuild *) 111 - let index = build_index config in 112 - save_cache cache_dir header key index; 113 - index 114 - end 115 - 116 - and build_index (config : Config.t) = 117 - List.fold_left 118 - (fun acc (repo : Config.repo_config) -> 119 - match repo.source with 120 - | Config.Local path -> 121 - Repo_index.load_local_repo ~name:repo.name ~path acc 122 - | Config.Remote _url -> 123 - Format.eprintf 124 - "Warning: Remote repositories not yet supported: %s@." 125 - repo.name; 126 - acc) 127 - Repo_index.empty config.opam.repositories
-23
lib/cache.mli
··· 1 - (** Cache for repository index. 2 - 3 - This module provides caching for the repository index using Marshal 4 - serialization. The cache is stored in the XDG cache directory and 5 - is invalidated when: 6 - - The config file path or mtime changes 7 - - Repository paths change 8 - - Repository package directories' mtimes change *) 9 - 10 - val load_index : 11 - cache_dir:Eio.Fs.dir_ty Eio.Path.t -> 12 - config_path:string -> 13 - Repo_index.t 14 - (** [load_index ~cache_dir ~config_path] loads the repository index, 15 - using a cached version if available and valid. 16 - 17 - The cache stores the config file path and mtime, along with repository 18 - paths and their package directory mtimes. If any of these change, the 19 - cache is invalidated and rebuilt. 20 - 21 - @param cache_dir The XDG cache directory path 22 - @param config_path Path to the unpac.toml config file 23 - @return The repository index *)
+40 -19
lib/config.ml
··· 1 + (** Configuration file handling for unpac. 2 + 3 + Loads and parses main/unpac.toml configuration files. *) 4 + 5 + (** {1 Types} *) 6 + 1 7 type repo_source = 2 8 | Local of string 3 9 | Remote of string 4 10 5 11 type repo_config = { 6 - name : string; 12 + repo_name : string; 7 13 source : repo_source; 8 14 } 9 15 10 16 type opam_config = { 11 17 repositories : repo_config list; 12 - compiler : string option; (* e.g., "ocaml.5.4.0" or "5.4.0" *) 13 - vendor_repo : string option; (* Path or URL to vendor repository *) 18 + compiler : string option; 14 19 } 15 20 16 - type t = { opam : opam_config } 21 + type project_config = { 22 + project_name : string; 23 + } 17 24 18 - (* TOML Codecs *) 25 + type t = { 26 + opam : opam_config; 27 + projects : project_config list; 28 + } 19 29 20 - let repo_config_codec = 30 + (** {1 TOML Codecs} *) 31 + 32 + let repo_config_codec : repo_config Tomlt.t = 21 33 let open Tomlt in 22 34 let open Table in 23 - let make name path url = 35 + let make repo_name path url : repo_config = 24 36 let source = 25 37 match (path, url) with 26 38 | Some p, None -> Local p ··· 29 41 failwith "Repository cannot have both 'path' and 'url'" 30 42 | None, None -> failwith "Repository must have either 'path' or 'url'" 31 43 in 32 - { name; source } 44 + { repo_name; source } 33 45 in 34 - let enc_path r = 46 + let enc_path (r : repo_config) = 35 47 match r.source with Local p -> Some p | Remote _ -> None 36 48 in 37 - let enc_url r = 49 + let enc_url (r : repo_config) = 38 50 match r.source with Remote u -> Some u | Local _ -> None 39 51 in 40 52 obj make 41 - |> mem "name" string ~enc:(fun r -> r.name) 53 + |> mem "name" string ~enc:(fun (r : repo_config) -> r.repo_name) 42 54 |> opt_mem "path" string ~enc:enc_path 43 55 |> opt_mem "url" string ~enc:enc_url 44 56 |> finish 45 57 46 - let opam_config_codec = 58 + let opam_config_codec : opam_config Tomlt.t = 47 59 let open Tomlt in 48 60 let open Table in 49 - obj (fun repositories compiler vendor_repo -> { repositories; compiler; vendor_repo }) 61 + obj (fun repositories compiler : opam_config -> { repositories; compiler }) 50 62 |> mem "repositories" (list repo_config_codec) 51 - ~enc:(fun c -> c.repositories) 52 - |> opt_mem "compiler" string ~enc:(fun c -> c.compiler) 53 - |> opt_mem "vendor_repo" string ~enc:(fun c -> c.vendor_repo) 63 + ~enc:(fun (c : opam_config) -> c.repositories) 64 + |> opt_mem "compiler" string ~enc:(fun (c : opam_config) -> c.compiler) 54 65 |> finish 55 66 56 - let codec = 67 + (* For now, projects is not parsed from TOML - derived from git branches *) 68 + type config = t 69 + 70 + let codec : config Tomlt.t = 57 71 let open Tomlt in 58 72 let open Table in 59 - obj (fun opam -> { opam }) 60 - |> mem "opam" opam_config_codec ~enc:(fun c -> c.opam) 73 + obj (fun opam : config -> { opam; projects = [] }) 74 + |> mem "opam" opam_config_codec ~enc:(fun (c : config) -> c.opam) 61 75 |> finish 62 76 77 + (** {1 Loading} *) 78 + 63 79 let load path = 64 80 try 65 81 let content = In_channel.with_open_text path In_channel.input_all in ··· 71 87 72 88 let load_exn path = 73 89 match load path with Ok c -> c | Error msg -> failwith msg 90 + 91 + (** {1 Helpers} *) 92 + 93 + let find_project config name = 94 + List.find_opt (fun p -> p.project_name = name) config.projects
+18 -11
lib/config.mli
··· 1 1 (** Configuration file handling for unpac. 2 2 3 - Loads and parses unpac.toml configuration files using tomlt. *) 3 + Loads and parses main/unpac.toml configuration files. *) 4 4 5 5 (** {1 Types} *) 6 6 7 7 type repo_source = 8 - | Local of string (** Local filesystem path *) 9 - | Remote of string (** Remote URL (git+https://..., etc.) *) 10 - (** Source location for an opam repository. *) 8 + | Local of string 9 + | Remote of string 11 10 12 11 type repo_config = { 13 - name : string; 12 + repo_name : string; 14 13 source : repo_source; 15 14 } 16 - (** Configuration for a single opam repository. *) 17 15 18 16 type opam_config = { 19 17 repositories : repo_config list; 20 - compiler : string option; (** Target compiler version, e.g. "5.4.0" or "ocaml.5.4.0" *) 21 - vendor_repo : string option; (** Path or URL to vendor repository with opam/vendor/* branches *) 18 + compiler : string option; 19 + } 20 + 21 + type project_config = { 22 + project_name : string; 22 23 } 23 - (** Opam-specific configuration. *) 24 24 25 - type t = { opam : opam_config } 26 - (** The complete unpac configuration. *) 25 + type t = { 26 + opam : opam_config; 27 + projects : project_config list; 28 + } 27 29 28 30 (** {1 Loading} *) 29 31 ··· 32 34 33 35 val load_exn : string -> t 34 36 (** [load_exn path] is like {!load} but raises on error. *) 37 + 38 + (** {1 Helpers} *) 39 + 40 + val find_project : t -> string -> project_config option 41 + (** [find_project config name] finds a project by name. *) 35 42 36 43 (** {1 Codecs} *) 37 44
lib/dev_repo.ml lib/opam/dev_repo.ml
lib/dev_repo.mli lib/opam/dev_repo.mli
+1 -13
lib/dune
··· 2 2 (name unpac) 3 3 (public_name unpac) 4 4 (libraries 5 - cmdliner 6 5 eio 7 6 logs 8 7 logs.fmt 9 - logs.cli 10 8 fmt 11 - fmt.cli 12 9 fmt.tty 13 10 tomlt 14 - tomlt.bytesrw 15 - jsont 16 - jsont.bytesrw 17 - xdge 18 - opam-format 19 - opam-core 20 - opam-repository 21 - opam-solver 22 - opam-0install-cudf 23 - cudf)) 11 + tomlt.bytesrw))
+4 -8
lib/git.ml
··· 115 115 let status = Eio.Process.await child in 116 116 let stdout = Buffer.contents stdout_buf in 117 117 let stderr = Buffer.contents stderr_buf in 118 - let exit_code, result = match status with 118 + match status with 119 119 | `Exited 0 -> 120 120 Log.debug (fun m -> m "Output: %s" (string_trim stdout)); 121 - 0, Ok stdout 121 + Ok stdout 122 122 | `Exited code -> 123 123 Log.debug (fun m -> m "Failed (exit %d): %s" code (string_trim stderr)); 124 - code, Error (Command_failed { cmd = args; exit_code = code; stdout; stderr }) 124 + Error (Command_failed { cmd = args; exit_code = code; stdout; stderr }) 125 125 | `Signaled signal -> 126 126 Log.debug (fun m -> m "Killed by signal %d" signal); 127 127 let code = 128 + signal in 128 - code, Error (Command_failed { cmd = args; exit_code = code; stdout; stderr }) 129 - in 130 - (* Log to transaction log *) 131 - Txn_log.log_git_command ~args ~exit_code ~stdout ~stderr; 132 - result 128 + Error (Command_failed { cmd = args; exit_code = code; stdout; stderr }) 133 129 with exn -> 134 130 Log.err (fun m -> m "Exception running git: %a" Fmt.exn exn); 135 131 raise exn
+143
lib/init.ml
··· 1 + (** Project initialization for unpac. 2 + 3 + Creates the bare repository structure and initial main worktree. *) 4 + 5 + let default_unpac_toml = {|[opam] 6 + repositories = [] 7 + # compiler = "5.4.0" 8 + 9 + # Vendor cache location (default: XDG cache directory) 10 + # vendor_cache = "/path/to/cache" 11 + 12 + [projects] 13 + # Projects will be added here 14 + |} 15 + 16 + let project_dune_project name = Printf.sprintf {|(lang dune 3.20) 17 + (name %s) 18 + |} name 19 + 20 + let project_dune = {|(vendored_dirs vendor) 21 + |} 22 + 23 + (** Initialize a new unpac project at the given path. *) 24 + let init ~proc_mgr ~fs path = 25 + let root = Eio.Path.(fs / path) in 26 + 27 + (* Create root directory *) 28 + Eio.Path.mkdirs ~exists_ok:false ~perm:0o755 root; 29 + 30 + (* Initialize bare repository *) 31 + let git_path = Eio.Path.(root / "git") in 32 + Eio.Path.mkdirs ~exists_ok:false ~perm:0o755 git_path; 33 + Git.run_exn ~proc_mgr ~cwd:git_path ["init"; "--bare"] |> ignore; 34 + 35 + (* Create initial main branch with unpac.toml *) 36 + (* First create a temporary worktree to make the initial commit *) 37 + let main_path = Eio.Path.(root / "main") in 38 + Eio.Path.mkdirs ~exists_ok:false ~perm:0o755 main_path; 39 + 40 + (* Initialize as a regular repo temporarily to create first commit *) 41 + Git.run_exn ~proc_mgr ~cwd:main_path ["init"] |> ignore; 42 + 43 + (* Write unpac.toml *) 44 + Eio.Path.save ~create:(`Or_truncate 0o644) 45 + Eio.Path.(main_path / "unpac.toml") 46 + default_unpac_toml; 47 + 48 + (* Create initial commit *) 49 + Git.run_exn ~proc_mgr ~cwd:main_path ["add"; "unpac.toml"] |> ignore; 50 + Git.run_exn ~proc_mgr ~cwd:main_path 51 + ["commit"; "-m"; "Initial commit"] |> ignore; 52 + 53 + (* Rename branch to main if needed *) 54 + Git.run_exn ~proc_mgr ~cwd:main_path ["branch"; "-M"; "main"] |> ignore; 55 + 56 + (* Push to bare repo and convert to worktree *) 57 + Git.run_exn ~proc_mgr ~cwd:main_path 58 + ["remote"; "add"; "origin"; "../git"] |> ignore; 59 + Git.run_exn ~proc_mgr ~cwd:main_path 60 + ["push"; "-u"; "origin"; "main"] |> ignore; 61 + 62 + (* Remove the temporary clone and add main as a worktree of the bare repo *) 63 + Eio.Path.rmtree main_path; 64 + 65 + (* Add main as a worktree of the bare repo *) 66 + Git.run_exn ~proc_mgr ~cwd:git_path 67 + ["worktree"; "add"; "../main"; "main"] |> ignore; 68 + 69 + root 70 + 71 + (** Check if a path is an unpac project root. *) 72 + let is_unpac_root path = 73 + Eio.Path.is_directory Eio.Path.(path / "git") && 74 + Eio.Path.is_directory Eio.Path.(path / "main") && 75 + Eio.Path.is_file Eio.Path.(path / "main" / "unpac.toml") 76 + 77 + (** Find the unpac root by walking up from current directory. *) 78 + let find_root ~fs ~cwd = 79 + let rec go path = 80 + if is_unpac_root path then Some path 81 + else match Eio.Path.split path with 82 + | Some (parent, _) -> go parent 83 + | None -> None 84 + in 85 + go Eio.Path.(fs / cwd) 86 + 87 + (** Create a new project branch with template. *) 88 + let create_project ~proc_mgr root name = 89 + let project_path = Worktree.path root (Project name) in 90 + 91 + (* Ensure project directory parent exists *) 92 + let project_dir = Eio.Path.(root / "project") in 93 + Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 project_dir; 94 + 95 + (* Create orphan branch *) 96 + Worktree.ensure_orphan ~proc_mgr root (Project name); 97 + 98 + (* Write template files *) 99 + Eio.Path.save ~create:(`Or_truncate 0o644) 100 + Eio.Path.(project_path / "dune-project") 101 + (project_dune_project name); 102 + 103 + Eio.Path.save ~create:(`Or_truncate 0o644) 104 + Eio.Path.(project_path / "dune") 105 + project_dune; 106 + 107 + (* Create empty vendor directory structure *) 108 + Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 109 + Eio.Path.(project_path / "vendor" / "opam"); 110 + 111 + (* Commit template *) 112 + Git.run_exn ~proc_mgr ~cwd:project_path ["add"; "-A"] |> ignore; 113 + Git.run_exn ~proc_mgr ~cwd:project_path 114 + ["commit"; "-m"; "Initialize project " ^ name] |> ignore; 115 + 116 + (* Update main/unpac.toml to register project *) 117 + let main_path = Worktree.path root Main in 118 + let toml_path = Eio.Path.(main_path / "unpac.toml") in 119 + let content = Eio.Path.load toml_path in 120 + 121 + (* Simple append to [projects] section - a proper implementation would parse TOML *) 122 + let updated = 123 + if content = "" || not (String.ends_with ~suffix:"\n" content) 124 + then content ^ "\n" ^ name ^ " = {}\n" 125 + else content ^ name ^ " = {}\n" 126 + in 127 + Eio.Path.save ~create:(`Or_truncate 0o644) toml_path updated; 128 + 129 + Git.run_exn ~proc_mgr ~cwd:main_path ["add"; "unpac.toml"] |> ignore; 130 + Git.run_exn ~proc_mgr ~cwd:main_path 131 + ["commit"; "-m"; "Add project " ^ name] |> ignore; 132 + 133 + project_path 134 + 135 + (** Remove a project branch and worktree. *) 136 + let remove_project ~proc_mgr root name = 137 + (* Remove worktree if exists *) 138 + Worktree.remove_force ~proc_mgr root (Project name); 139 + 140 + (* Delete the branch *) 141 + let git = Worktree.git_dir root in 142 + let branch = Worktree.branch (Project name) in 143 + Git.run_exn ~proc_mgr ~cwd:git ["branch"; "-D"; branch] |> ignore
+44
lib/init.mli
··· 1 + (** Project initialization for unpac. 2 + 3 + Creates the bare repository structure and initial main worktree. *) 4 + 5 + val init : 6 + proc_mgr:Git.proc_mgr -> 7 + fs:Eio.Fs.dir_ty Eio.Path.t -> 8 + string -> 9 + Worktree.root 10 + (** [init ~proc_mgr ~fs path] creates a new unpac project at [path]. 11 + 12 + Creates: 13 + - [path/git/] - bare git repository 14 + - [path/main/] - worktree for main branch with unpac.toml *) 15 + 16 + val is_unpac_root : Eio.Fs.dir_ty Eio.Path.t -> bool 17 + (** [is_unpac_root path] checks if [path] is an unpac project root. *) 18 + 19 + val find_root : 20 + fs:Eio.Fs.dir_ty Eio.Path.t -> 21 + cwd:string -> 22 + Worktree.root option 23 + (** [find_root ~fs ~cwd] walks up from [cwd] to find the unpac root. *) 24 + 25 + val create_project : 26 + proc_mgr:Git.proc_mgr -> 27 + Worktree.root -> 28 + string -> 29 + Eio.Fs.dir_ty Eio.Path.t 30 + (** [create_project ~proc_mgr root name] creates a new project branch. 31 + 32 + Creates orphan branch [project/<name>] with template: 33 + - dune-project (lang dune 3.20) 34 + - dune with (vendored_dirs vendor) 35 + - vendor/opam/ directory 36 + 37 + Updates main/unpac.toml to register the project. *) 38 + 39 + val remove_project : 40 + proc_mgr:Git.proc_mgr -> 41 + Worktree.root -> 42 + string -> 43 + unit 44 + (** [remove_project ~proc_mgr root name] removes a project branch and worktree. *)
+14
lib/opam/dune
··· 1 + (library 2 + (name unpac_opam) 3 + (public_name unpac-opam) 4 + (libraries 5 + unpac 6 + cmdliner 7 + jsont 8 + jsont.bytesrw 9 + opam-format 10 + opam-core 11 + opam-repository 12 + opam-solver 13 + opam-0install-cudf 14 + cudf))
+205
lib/opam/opam.ml
··· 1 + (** Opam backend for unpac. 2 + 3 + Implements vendoring of opam packages using the three-tier branch model: 4 + - opam/upstream/<pkg> - pristine upstream code 5 + - opam/vendor/<pkg> - orphan branch with vendor/opam/<pkg>/ prefix 6 + - opam/patches/<pkg> - local modifications *) 7 + 8 + module Worktree = Unpac.Worktree 9 + module Git = Unpac.Git 10 + module Git_repo_lookup = Unpac.Git_repo_lookup 11 + module Backend = Unpac.Backend 12 + 13 + let name = "opam" 14 + 15 + (** {1 Branch Naming} *) 16 + 17 + let upstream_branch pkg = "opam/upstream/" ^ pkg 18 + let vendor_branch pkg = "opam/vendor/" ^ pkg 19 + let patches_branch pkg = "opam/patches/" ^ pkg 20 + let vendor_path pkg = "vendor/opam/" ^ pkg 21 + 22 + (** {1 Worktree Kinds} *) 23 + 24 + let upstream_kind pkg = Worktree.Opam_upstream pkg 25 + let vendor_kind pkg = Worktree.Opam_vendor pkg 26 + let patches_kind pkg = Worktree.Opam_patches pkg 27 + 28 + (** {1 Package Operations} *) 29 + 30 + let copy_with_prefix ~src_dir ~dst_dir ~prefix = 31 + (* Recursively copy files from src_dir to dst_dir/prefix/ *) 32 + let prefix_dir = Eio.Path.(dst_dir / prefix) in 33 + Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 prefix_dir; 34 + 35 + let rec copy_dir src dst = 36 + Eio.Path.read_dir src |> List.iter (fun name -> 37 + let src_path = Eio.Path.(src / name) in 38 + let dst_path = Eio.Path.(dst / name) in 39 + if Eio.Path.is_directory src_path then begin 40 + Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 dst_path; 41 + copy_dir src_path dst_path 42 + end else begin 43 + let content = Eio.Path.load src_path in 44 + Eio.Path.save ~create:(`Or_truncate 0o644) dst_path content 45 + end 46 + ) 47 + in 48 + 49 + (* Copy everything except .git *) 50 + Eio.Path.read_dir src_dir |> List.iter (fun name -> 51 + if name <> ".git" then begin 52 + let src_path = Eio.Path.(src_dir / name) in 53 + let dst_path = Eio.Path.(prefix_dir / name) in 54 + if Eio.Path.is_directory src_path then begin 55 + Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 dst_path; 56 + copy_dir src_path dst_path 57 + end else begin 58 + let content = Eio.Path.load src_path in 59 + Eio.Path.save ~create:(`Or_truncate 0o644) dst_path content 60 + end 61 + end 62 + ) 63 + 64 + let add_package ~proc_mgr ~root (info : Backend.package_info) = 65 + let pkg = info.name in 66 + let git = Worktree.git_dir root in 67 + 68 + try 69 + (* Check if already exists *) 70 + if Worktree.branch_exists ~proc_mgr root (patches_kind pkg) then 71 + Backend.Already_exists pkg 72 + else begin 73 + (* Step 1: Create upstream branch and fetch *) 74 + let upstream_wt = Worktree.path root (upstream_kind pkg) in 75 + 76 + (* Add remote for this package *) 77 + let remote = "origin-" ^ pkg in 78 + let url = Git_repo_lookup.rewrite_url info.url in 79 + ignore (Git.ensure_remote ~proc_mgr ~cwd:git ~name:remote ~url); 80 + 81 + (* Fetch from remote *) 82 + Git.fetch ~proc_mgr ~cwd:git ~remote; 83 + 84 + (* Determine the ref to use *) 85 + let branch = match info.branch with 86 + | Some b -> b 87 + | None -> Git.ls_remote_default_branch ~proc_mgr ~url 88 + in 89 + let ref_point = remote ^ "/" ^ branch in 90 + 91 + (* Create upstream branch *) 92 + Git.branch_force ~proc_mgr ~cwd:git 93 + ~name:(upstream_branch pkg) ~point:ref_point; 94 + 95 + (* Create upstream worktree temporarily *) 96 + Worktree.ensure ~proc_mgr root (upstream_kind pkg); 97 + 98 + (* Step 2: Create vendor branch (orphan) with prefix *) 99 + Worktree.ensure_orphan ~proc_mgr root (vendor_kind pkg); 100 + let vendor_wt = Worktree.path root (vendor_kind pkg) in 101 + 102 + (* Copy files with vendor/opam/<pkg>/ prefix *) 103 + copy_with_prefix 104 + ~src_dir:upstream_wt 105 + ~dst_dir:vendor_wt 106 + ~prefix:(vendor_path pkg); 107 + 108 + (* Commit vendor branch *) 109 + Git.add_all ~proc_mgr ~cwd:vendor_wt; 110 + Git.commit ~proc_mgr ~cwd:vendor_wt 111 + ~message:(Printf.sprintf "Vendor %s" pkg); 112 + 113 + let vendor_sha = Git.current_head ~proc_mgr ~cwd:vendor_wt in 114 + 115 + (* Step 3: Create patches branch from vendor *) 116 + Git.branch_create ~proc_mgr ~cwd:git 117 + ~name:(patches_branch pkg) 118 + ~start_point:(vendor_branch pkg); 119 + 120 + (* Cleanup worktrees *) 121 + Worktree.remove ~proc_mgr root (upstream_kind pkg); 122 + Worktree.remove ~proc_mgr root (vendor_kind pkg); 123 + 124 + Backend.Added { name = pkg; sha = vendor_sha } 125 + end 126 + with exn -> 127 + (* Cleanup on failure *) 128 + (try Worktree.remove_force ~proc_mgr root (upstream_kind pkg) with _ -> ()); 129 + (try Worktree.remove_force ~proc_mgr root (vendor_kind pkg) with _ -> ()); 130 + Backend.Failed { name = pkg; error = Printexc.to_string exn } 131 + 132 + let update_package ~proc_mgr ~root pkg = 133 + let git = Worktree.git_dir root in 134 + 135 + try 136 + (* Check if package exists *) 137 + if not (Worktree.branch_exists ~proc_mgr root (patches_kind pkg)) then 138 + Backend.Update_failed { name = pkg; error = "Package not vendored" } 139 + else begin 140 + (* Get remote URL *) 141 + let remote = "origin-" ^ pkg in 142 + let url = match Git.remote_url ~proc_mgr ~cwd:git remote with 143 + | Some u -> u 144 + | None -> failwith ("Remote not found: " ^ remote) 145 + in 146 + 147 + (* Fetch latest *) 148 + Git.fetch ~proc_mgr ~cwd:git ~remote; 149 + 150 + (* Get old SHA *) 151 + let old_sha = match Git.rev_parse ~proc_mgr ~cwd:git (upstream_branch pkg) with 152 + | Some sha -> sha 153 + | None -> failwith "Upstream branch not found" 154 + in 155 + 156 + (* Determine default branch and update upstream *) 157 + let default_branch = Git.ls_remote_default_branch ~proc_mgr ~url in 158 + let ref_point = remote ^ "/" ^ default_branch in 159 + Git.branch_force ~proc_mgr ~cwd:git 160 + ~name:(upstream_branch pkg) ~point:ref_point; 161 + 162 + (* Get new SHA *) 163 + let new_sha = match Git.rev_parse ~proc_mgr ~cwd:git (upstream_branch pkg) with 164 + | Some sha -> sha 165 + | None -> failwith "Upstream branch not found" 166 + in 167 + 168 + if old_sha = new_sha then 169 + Backend.No_changes pkg 170 + else begin 171 + (* Create worktrees *) 172 + Worktree.ensure ~proc_mgr root (upstream_kind pkg); 173 + Worktree.ensure ~proc_mgr root (vendor_kind pkg); 174 + 175 + let upstream_wt = Worktree.path root (upstream_kind pkg) in 176 + let vendor_wt = Worktree.path root (vendor_kind pkg) in 177 + 178 + (* Clear vendor content and copy new *) 179 + let vendor_pkg_path = Eio.Path.(vendor_wt / "vendor" / "opam" / pkg) in 180 + (try Eio.Path.rmtree vendor_pkg_path with _ -> ()); 181 + 182 + copy_with_prefix 183 + ~src_dir:upstream_wt 184 + ~dst_dir:vendor_wt 185 + ~prefix:(vendor_path pkg); 186 + 187 + (* Commit *) 188 + Git.add_all ~proc_mgr ~cwd:vendor_wt; 189 + Git.commit ~proc_mgr ~cwd:vendor_wt 190 + ~message:(Printf.sprintf "Update %s to %s" pkg (String.sub new_sha 0 7)); 191 + 192 + (* Cleanup *) 193 + Worktree.remove ~proc_mgr root (upstream_kind pkg); 194 + Worktree.remove ~proc_mgr root (vendor_kind pkg); 195 + 196 + Backend.Updated { name = pkg; old_sha; new_sha } 197 + end 198 + end 199 + with exn -> 200 + (try Worktree.remove_force ~proc_mgr root (upstream_kind pkg) with _ -> ()); 201 + (try Worktree.remove_force ~proc_mgr root (vendor_kind pkg) with _ -> ()); 202 + Backend.Update_failed { name = pkg; error = Printexc.to_string exn } 203 + 204 + let list_packages ~proc_mgr ~root = 205 + Worktree.list_opam_packages ~proc_mgr root
+60
lib/opam/opam.mli
··· 1 + (** Opam backend for unpac. 2 + 3 + Implements vendoring of opam packages using the three-tier branch model: 4 + - opam/upstream/<pkg> - pristine upstream code 5 + - opam/vendor/<pkg> - orphan branch with vendor/opam/<pkg>/ prefix 6 + - opam/patches/<pkg> - local modifications *) 7 + 8 + val name : string 9 + (** Backend name: "opam" *) 10 + 11 + (** {1 Branch Naming} *) 12 + 13 + val upstream_branch : string -> string 14 + (** [upstream_branch pkg] returns "opam/upstream/<pkg>". *) 15 + 16 + val vendor_branch : string -> string 17 + (** [vendor_branch pkg] returns "opam/vendor/<pkg>". *) 18 + 19 + val patches_branch : string -> string 20 + (** [patches_branch pkg] returns "opam/patches/<pkg>". *) 21 + 22 + val vendor_path : string -> string 23 + (** [vendor_path pkg] returns "vendor/opam/<pkg>". *) 24 + 25 + (** {1 Worktree Kinds} *) 26 + 27 + val upstream_kind : string -> Unpac.Worktree.kind 28 + val vendor_kind : string -> Unpac.Worktree.kind 29 + val patches_kind : string -> Unpac.Worktree.kind 30 + 31 + (** {1 Package Operations} *) 32 + 33 + val add_package : 34 + proc_mgr:Unpac.Git.proc_mgr -> 35 + root:Unpac.Worktree.root -> 36 + Unpac.Backend.package_info -> 37 + Unpac.Backend.add_result 38 + (** [add_package ~proc_mgr ~root info] vendors a single package. 39 + 40 + 1. Fetches upstream into opam/upstream/<pkg> 41 + 2. Creates opam/vendor/<pkg> orphan with vendor/opam/<pkg>/ prefix 42 + 3. Creates opam/patches/<pkg> from vendor *) 43 + 44 + val update_package : 45 + proc_mgr:Unpac.Git.proc_mgr -> 46 + root:Unpac.Worktree.root -> 47 + string -> 48 + Unpac.Backend.update_result 49 + (** [update_package ~proc_mgr ~root name] updates a package from upstream. 50 + 51 + 1. Fetches latest into opam/upstream/<pkg> 52 + 2. Updates opam/vendor/<pkg> with new content 53 + 54 + Does NOT rebase patches - call [Backend.rebase_patches] separately. *) 55 + 56 + val list_packages : 57 + proc_mgr:Unpac.Git.proc_mgr -> 58 + root:Unpac.Worktree.root -> 59 + string list 60 + (** [list_packages ~proc_mgr root] returns all vendored opam package names. *)
-221
lib/output.ml
··· 1 - type format = Text | Json | Toml 2 - 3 - (* JSON Codecs *) 4 - 5 - let dev_repo_jsont = 6 - Jsont.( 7 - map 8 - ~dec:(fun s -> Dev_repo.of_string s) 9 - ~enc:Dev_repo.to_string string) 10 - 11 - let package_name_jsont = 12 - Jsont.( 13 - map 14 - ~dec:OpamPackage.Name.of_string 15 - ~enc:OpamPackage.Name.to_string 16 - string) 17 - 18 - let package_version_jsont = 19 - Jsont.( 20 - map 21 - ~dec:OpamPackage.Version.of_string 22 - ~enc:OpamPackage.Version.to_string 23 - string) 24 - 25 - let package_info_jsont : Repo_index.package_info Jsont.t = 26 - let open Jsont in 27 - let open Repo_index in 28 - Object.map 29 - ~kind:"package_info" 30 - (fun name version dev_repo source_repo -> 31 - (* Create a minimal opam record - we don't encode the full opam file *) 32 - let opam = OpamFile.OPAM.empty in 33 - { name; version; opam; dev_repo; source_repo }) 34 - |> Object.mem "name" package_name_jsont 35 - ~enc:(fun p -> p.name) 36 - |> Object.mem "version" package_version_jsont 37 - ~enc:(fun p -> p.version) 38 - |> Object.opt_mem "dev_repo" dev_repo_jsont 39 - ~enc:(fun p -> p.dev_repo) 40 - |> Object.mem "source_repo" string 41 - ~enc:(fun p -> p.source_repo) 42 - |> Object.finish 43 - 44 - let package_list_jsont = Jsont.list package_info_jsont 45 - 46 - (* Text Output *) 47 - 48 - let pp_package_info fmt (info : Repo_index.package_info) = 49 - Format.fprintf fmt "%s.%s" 50 - (OpamPackage.Name.to_string info.name) 51 - (OpamPackage.Version.to_string info.version) 52 - 53 - let pp_package_info_detailed fmt (info : Repo_index.package_info) = 54 - Format.fprintf fmt "@[<v>%s.%s@, repo: %s" 55 - (OpamPackage.Name.to_string info.name) 56 - (OpamPackage.Version.to_string info.version) 57 - info.source_repo; 58 - (match info.dev_repo with 59 - | Some dr -> Format.fprintf fmt "@, dev-repo: %s" (Dev_repo.to_string dr) 60 - | None -> ()); 61 - Format.fprintf fmt "@]" 62 - 63 - (* JSON encoding helper *) 64 - let encode_json codec value = 65 - match Jsont_bytesrw.encode_string codec value with 66 - | Ok s -> s 67 - | Error e -> failwith e 68 - 69 - (* Output functions *) 70 - 71 - let output_package_list format packages = 72 - match format with 73 - | Text -> 74 - List.iter 75 - (fun info -> Format.printf "%a@." pp_package_info info) 76 - packages 77 - | Json -> 78 - let json = encode_json package_list_jsont packages in 79 - print_endline json 80 - | Toml -> 81 - (* For TOML, we output as array of inline tables *) 82 - Format.printf "# Package list@."; 83 - List.iter 84 - (fun (info : Repo_index.package_info) -> 85 - Format.printf "[[packages]]@."; 86 - Format.printf "name = %S@." (OpamPackage.Name.to_string info.name); 87 - Format.printf "version = %S@." 88 - (OpamPackage.Version.to_string info.version); 89 - Format.printf "@.") 90 - packages 91 - 92 - let output_package_info format packages = 93 - match format with 94 - | Text -> 95 - List.iter 96 - (fun info -> Format.printf "%a@.@." pp_package_info_detailed info) 97 - packages 98 - | Json -> 99 - let json = encode_json package_list_jsont packages in 100 - print_endline json 101 - | Toml -> 102 - List.iter 103 - (fun (info : Repo_index.package_info) -> 104 - Format.printf "[[packages]]@."; 105 - Format.printf "name = %S@." (OpamPackage.Name.to_string info.name); 106 - Format.printf "version = %S@." 107 - (OpamPackage.Version.to_string info.version); 108 - Format.printf "source_repo = %S@." info.source_repo; 109 - (match info.dev_repo with 110 - | Some dr -> Format.printf "dev_repo = %S@." (Dev_repo.to_string dr) 111 - | None -> ()); 112 - Format.printf "@.") 113 - packages 114 - 115 - let output_related format pkg_name packages = 116 - match format with 117 - | Text -> 118 - Format.printf "Packages related to %s:@." pkg_name; 119 - List.iter 120 - (fun info -> Format.printf " %a@." pp_package_info info) 121 - packages 122 - | Json -> 123 - let json_obj = 124 - let open Jsont in 125 - Object.map ~kind:"related_packages" (fun pkg related -> 126 - (pkg, related)) 127 - |> Object.mem "package" string ~enc:fst 128 - |> Object.mem "related" package_list_jsont ~enc:snd 129 - |> Object.finish 130 - in 131 - let json = encode_json json_obj (pkg_name, packages) in 132 - print_endline json 133 - | Toml -> 134 - Format.printf "package = %S@." pkg_name; 135 - Format.printf "@."; 136 - List.iter 137 - (fun (info : Repo_index.package_info) -> 138 - Format.printf "[[related]]@."; 139 - Format.printf "name = %S@." (OpamPackage.Name.to_string info.name); 140 - Format.printf "version = %S@." 141 - (OpamPackage.Version.to_string info.version); 142 - Format.printf "@.") 143 - packages 144 - 145 - let pp_grouped_source fmt (group : Source.grouped_sources) = 146 - (match group.dev_repo with 147 - | Some dr -> 148 - Format.fprintf fmt "@[<v>## %s@," (Dev_repo.to_string dr) 149 - | None -> 150 - Format.fprintf fmt "@[<v>## (no dev-repo)@,"); 151 - List.iter 152 - (fun (src : Source.package_source) -> 153 - Format.fprintf fmt " %s.%s" src.name src.version; 154 - (match src.source with 155 - | Source.ArchiveSource a -> 156 - Format.fprintf fmt " [%s]" a.url 157 - | Source.GitSource g -> 158 - Format.fprintf fmt " [git: %s]" g.url 159 - | Source.NoSource -> ()); 160 - Format.fprintf fmt "@,") 161 - group.packages; 162 - Format.fprintf fmt "@]" 163 - 164 - let output_sources format sources = 165 - let grouped = Source.group_by_dev_repo sources in 166 - match format with 167 - | Text -> 168 - List.iter (fun g -> Format.printf "%a@." pp_grouped_source g) grouped 169 - | Json -> 170 - let json = encode_json Source.grouped_sources_list_jsont grouped in 171 - print_endline json 172 - | Toml -> 173 - (* Format as array of tables with nested packages *) 174 - List.iter 175 - (fun (group : Source.grouped_sources) -> 176 - Format.printf "[[repos]]@."; 177 - (match group.dev_repo with 178 - | Some dr -> Format.printf "dev_repo = %S@." (Dev_repo.to_string dr) 179 - | None -> Format.printf "# no dev-repo@."); 180 - Format.printf "@."; 181 - List.iter 182 - (fun (src : Source.package_source) -> 183 - Format.printf "[[repos.packages]]@."; 184 - Format.printf "name = %S@." src.name; 185 - Format.printf "version = %S@." src.version; 186 - (match src.source with 187 - | Source.ArchiveSource a -> 188 - Format.printf "[repos.packages.source]@."; 189 - Format.printf "type = \"archive\"@."; 190 - Format.printf "url = %S@." a.url; 191 - if a.checksums <> [] then begin 192 - Format.printf "checksums = ["; 193 - List.iteri 194 - (fun i cs -> 195 - if i > 0 then Format.printf ", "; 196 - Format.printf "%S" cs) 197 - a.checksums; 198 - Format.printf "]@." 199 - end; 200 - if a.mirrors <> [] then begin 201 - Format.printf "mirrors = ["; 202 - List.iteri 203 - (fun i m -> 204 - if i > 0 then Format.printf ", "; 205 - Format.printf "%S" m) 206 - a.mirrors; 207 - Format.printf "]@." 208 - end 209 - | Source.GitSource g -> 210 - Format.printf "[repos.packages.source]@."; 211 - Format.printf "type = \"git\"@."; 212 - Format.printf "url = %S@." g.url; 213 - (match g.branch with 214 - | Some b -> Format.printf "branch = %S@." b 215 - | None -> ()) 216 - | Source.NoSource -> 217 - Format.printf "[repos.packages.source]@."; 218 - Format.printf "type = \"none\"@."); 219 - Format.printf "@.") 220 - group.packages) 221 - grouped
-35
lib/output.mli
··· 1 - (** Output formatting for unpac commands. 2 - 3 - Provides plain text, JSON, and TOML output formats. *) 4 - 5 - (** {1 Output Format} *) 6 - 7 - type format = 8 - | Text (** Human-readable text output *) 9 - | Json (** Machine-readable JSON output *) 10 - | Toml (** TOML output *) 11 - (** Output format selection. *) 12 - 13 - (** {1 Package Output} *) 14 - 15 - val output_package_list : format -> Repo_index.package_info list -> unit 16 - (** [output_package_list fmt packages] outputs a list of packages. *) 17 - 18 - val output_package_info : format -> Repo_index.package_info list -> unit 19 - (** [output_package_info fmt packages] outputs detailed package information. *) 20 - 21 - val output_related : format -> string -> Repo_index.package_info list -> unit 22 - (** [output_related fmt pkg_name packages] outputs related packages. *) 23 - 24 - (** {1 Source Output} *) 25 - 26 - val output_sources : format -> Source.package_source list -> unit 27 - (** [output_sources fmt sources] outputs package sources. *) 28 - 29 - (** {1 JSON Codecs} *) 30 - 31 - val package_info_jsont : Repo_index.package_info Jsont.t 32 - (** JSON codec for package info. *) 33 - 34 - val package_list_jsont : Repo_index.package_info list Jsont.t 35 - (** JSON codec for package list. *)
-329
lib/project.ml
··· 1 - (** Project management - handling project branches. *) 2 - 3 - let src = Logs.Src.create "unpac.project" ~doc:"Project operations" 4 - module Log = (val Logs.src_log src : Logs.LOG) 5 - 6 - (* Option helper for compatibility *) 7 - let option_value ~default = function 8 - | Some x -> x 9 - | None -> default 10 - 11 - (* Types *) 12 - 13 - type project_info = { 14 - name : string; 15 - branch : string; 16 - description : string; 17 - created : string; 18 - } 19 - 20 - type registry = { 21 - version : string; 22 - projects : project_info list; 23 - } 24 - 25 - (* Branch conventions *) 26 - 27 - let project_prefix = "project/" 28 - 29 - let project_branch name = project_prefix ^ name 30 - 31 - let is_project_branch branch = 32 - String.starts_with ~prefix:project_prefix branch 33 - 34 - let project_name_of_branch branch = 35 - if is_project_branch branch then 36 - Some (String.sub branch (String.length project_prefix) 37 - (String.length branch - String.length project_prefix)) 38 - else 39 - None 40 - 41 - (* Get current timestamp in ISO 8601 format *) 42 - let iso_timestamp () = 43 - let t = Unix.gettimeofday () in 44 - let tm = Unix.gmtime t in 45 - Printf.sprintf "%04d-%02d-%02dT%02d:%02d:%02dZ" 46 - (tm.Unix.tm_year + 1900) 47 - (tm.Unix.tm_mon + 1) 48 - tm.Unix.tm_mday 49 - tm.Unix.tm_hour 50 - tm.Unix.tm_min 51 - tm.Unix.tm_sec 52 - 53 - (* TOML encoding for registry *) 54 - 55 - let project_info_codec = 56 - let open Tomlt in 57 - let open Table in 58 - obj (fun name branch description created -> 59 - { name; branch; description; created }) 60 - |> mem "name" string ~enc:(fun p -> p.name) 61 - |> mem "branch" string ~enc:(fun p -> p.branch) 62 - |> mem "description" string ~dec_absent:"" ~enc:(fun p -> p.description) 63 - |> mem "created" string ~dec_absent:"" ~enc:(fun p -> p.created) 64 - |> finish 65 - 66 - let registry_codec = 67 - let open Tomlt in 68 - let open Table in 69 - obj (fun version projects -> { version; projects }) 70 - |> mem "version" string ~dec_absent:"0.1.0" ~enc:(fun r -> r.version) 71 - |> mem "projects" (list project_info_codec) ~dec_absent:[] ~enc:(fun r -> r.projects) 72 - |> finish 73 - 74 - let unpac_toml_codec = 75 - let open Tomlt in 76 - let open Table in 77 - obj (fun unpac -> unpac) 78 - |> mem "unpac" registry_codec ~enc:Fun.id 79 - |> finish 80 - 81 - (* Configuration *) 82 - 83 - let config_file = "unpac.toml" 84 - 85 - let load_registry ~cwd = 86 - let path = Eio.Path.(cwd / config_file) in 87 - match Eio.Path.load path with 88 - | content -> 89 - begin match Tomlt_bytesrw.decode_string unpac_toml_codec content with 90 - | Ok registry -> Some registry 91 - | Error e -> 92 - Log.warn (fun m -> m "Failed to parse %s: %s" config_file 93 - (Tomlt.Toml.Error.to_string e)); 94 - None 95 - end 96 - | exception Eio.Io (Eio.Fs.E (Eio.Fs.Not_found _), _) -> 97 - None 98 - | exception exn -> 99 - Log.warn (fun m -> m "Failed to load %s: %a" config_file Fmt.exn exn); 100 - None 101 - 102 - let save_registry ~cwd registry = 103 - let path = Eio.Path.(cwd / config_file) in 104 - let content = Tomlt_bytesrw.encode_string unpac_toml_codec registry in 105 - Eio.Path.save ~create:(`Or_truncate 0o644) path content; 106 - Log.debug (fun m -> m "Saved registry to %s" config_file) 107 - 108 - (* Queries *) 109 - 110 - let current_project ~proc_mgr ~cwd = 111 - match Git.current_branch ~proc_mgr ~cwd with 112 - | None -> None 113 - | Some branch -> project_name_of_branch branch 114 - 115 - let require_project_branch ~proc_mgr ~cwd = 116 - match Git.current_branch ~proc_mgr ~cwd with 117 - | None -> 118 - Log.err (fun m -> m "Not on any branch (detached HEAD)"); 119 - failwith "Not on any branch. Switch to a project branch first." 120 - | Some branch -> 121 - match project_name_of_branch branch with 122 - | Some name -> name 123 - | None -> 124 - Log.err (fun m -> m "Not on a project branch. Current branch: %s" branch); 125 - failwith (Printf.sprintf 126 - "Not on a project branch (current: %s).\n\ 127 - Switch to a project: unpac project switch <name>\n\ 128 - Or create one: unpac project create <name>" branch) 129 - 130 - let is_main_branch ~proc_mgr ~cwd = 131 - match Git.current_branch ~proc_mgr ~cwd with 132 - | Some "main" | Some "master" -> true 133 - | _ -> false 134 - 135 - let list_projects ~proc_mgr ~cwd = 136 - (* First try to load from registry on current branch *) 137 - match load_registry ~cwd with 138 - | Some registry -> registry.projects 139 - | None -> 140 - (* Fallback: scan for project branches *) 141 - let branches = Git.run_lines ~proc_mgr ~cwd 142 - ["for-each-ref"; "--format=%(refname:short)"; "refs/heads/project/"] 143 - in 144 - List.filter_map (fun branch -> 145 - match project_name_of_branch branch with 146 - | Some name -> Some { name; branch; description = ""; created = "" } 147 - | None -> None 148 - ) branches 149 - 150 - let project_exists ~proc_mgr ~cwd name = 151 - Git.branch_exists ~proc_mgr ~cwd (project_branch name) 152 - 153 - (* Operations *) 154 - 155 - let init ~proc_mgr ~cwd = 156 - if Git.is_repository cwd then begin 157 - Log.warn (fun m -> m "Git repository already exists"); 158 - (* Check if we have a registry *) 159 - if Option.is_some (load_registry ~cwd) then 160 - Log.info (fun m -> m "Registry already exists") 161 - else begin 162 - (* Create registry on current branch *) 163 - let registry = { version = "0.1.0"; projects = [] } in 164 - save_registry ~cwd registry; 165 - if Git.has_uncommitted_changes ~proc_mgr ~cwd then begin 166 - Git.add_all ~proc_mgr ~cwd; 167 - Git.commit ~proc_mgr ~cwd ~message:"unpac: initialize project registry" 168 - end 169 - end 170 - end else begin 171 - Log.info (fun m -> m "Initializing git repository..."); 172 - Git.init ~proc_mgr ~cwd; 173 - 174 - (* Create README *) 175 - let readme_path = Eio.Path.(cwd / "README.md") in 176 - let readme_content = {|# Unpac Vendor Repository 177 - 178 - This repository uses unpac's project-based branch model for vendoring OCaml packages. 179 - 180 - ## Branch Structure 181 - 182 - - `main` - Project registry (metadata only) 183 - - `project/<name>` - Individual project branches with vendored code 184 - 185 - ## Quick Start 186 - 187 - ```bash 188 - # Create a new project 189 - unpac project create myapp 190 - 191 - # Add packages (must be on a project branch) 192 - unpac add opam eio 193 - unpac add opam lwt --with-deps 194 - 195 - # Check status 196 - unpac vendor status 197 - ``` 198 - 199 - ## Commands 200 - 201 - ```bash 202 - unpac init # Initialize repository 203 - unpac project create <name> # Create new project 204 - unpac project switch <name> # Switch to project 205 - unpac add opam <pkg> # Add package from opam 206 - unpac vendor status # Show vendored packages 207 - unpac vendor update <pkg> # Update from upstream 208 - ``` 209 - |} 210 - in 211 - Eio.Path.save ~create:(`Or_truncate 0o644) readme_path readme_content; 212 - 213 - (* Create .gitignore *) 214 - let gitignore_path = Eio.Path.(cwd / ".gitignore") in 215 - let gitignore_content = {|_build/ 216 - *.install 217 - .merlin 218 - *.byte 219 - *.native 220 - *.cmo 221 - *.cmi 222 - *.cma 223 - *.cmx 224 - *.cmxa 225 - *.cmxs 226 - *.o 227 - *.a 228 - .unpac/ 229 - .unpac.log 230 - |} 231 - in 232 - Eio.Path.save ~create:(`Or_truncate 0o644) gitignore_path gitignore_content; 233 - 234 - (* Create registry *) 235 - let registry = { version = "0.1.0"; projects = [] } in 236 - save_registry ~cwd registry; 237 - 238 - (* Initial commit *) 239 - Git.add_all ~proc_mgr ~cwd; 240 - Git.commit ~proc_mgr ~cwd ~message:"Initial unpac repository setup"; 241 - 242 - Log.info (fun m -> m "Repository initialized") 243 - end 244 - 245 - let create ~proc_mgr ~cwd ~name ?(description="") () = 246 - if project_exists ~proc_mgr ~cwd name then begin 247 - Log.err (fun m -> m "Project %s already exists" name); 248 - failwith (Printf.sprintf "Project '%s' already exists" name) 249 - end; 250 - 251 - let branch = project_branch name in 252 - let created = iso_timestamp () in 253 - 254 - Log.info (fun m -> m "Creating project: %s" name); 255 - 256 - (* Load current registry (might be on main or another branch) *) 257 - let registry = load_registry ~cwd |> option_value 258 - ~default:{ version = "0.1.0"; projects = [] } 259 - in 260 - 261 - (* Add project to registry *) 262 - let project = { name; branch; description; created } in 263 - let registry = { registry with projects = project :: registry.projects } in 264 - 265 - (* Create the project branch from current HEAD *) 266 - let current = Git.current_branch ~proc_mgr ~cwd in 267 - let start_point = Git.current_head ~proc_mgr ~cwd in 268 - 269 - Git.branch_create ~proc_mgr ~cwd ~name:branch ~start_point; 270 - Git.checkout ~proc_mgr ~cwd branch; 271 - 272 - (* Create project-specific config *) 273 - let project_config_path = Eio.Path.(cwd / config_file) in 274 - let project_config = Printf.sprintf {|[project] 275 - name = "%s" 276 - description = "%s" 277 - 278 - [opam] 279 - # Repositories are listed in priority order (later ones take priority). 280 - # repositories = [ 281 - # { name = "default", path = "/path/to/opam-repository" }, 282 - # { name = "custom", path = "/path/to/custom-repo" }, 283 - # ] 284 - repositories = [] 285 - # compiler = "ocaml.5.3.0" 286 - 287 - [vendor] 288 - # Vendored packages will be listed here 289 - |} name description 290 - in 291 - Eio.Path.save ~create:(`Or_truncate 0o644) project_config_path project_config; 292 - 293 - Git.add_all ~proc_mgr ~cwd; 294 - Git.commit ~proc_mgr ~cwd ~message:(Printf.sprintf "project: create %s" name); 295 - 296 - (* Update registry on main branch if it exists *) 297 - begin match current with 298 - | Some "main" | Some "master" as main_branch -> 299 - let main = Option.get main_branch in 300 - Git.checkout ~proc_mgr ~cwd main; 301 - save_registry ~cwd registry; 302 - if Git.has_uncommitted_changes ~proc_mgr ~cwd then begin 303 - Git.add_all ~proc_mgr ~cwd; 304 - Git.commit ~proc_mgr ~cwd ~message:(Printf.sprintf "registry: add project %s" name) 305 - end; 306 - (* Switch back to project branch *) 307 - Git.checkout ~proc_mgr ~cwd branch 308 - | _ -> 309 - (* Not on main, just save registry to current project branch too *) 310 - save_registry ~cwd registry 311 - end; 312 - 313 - Log.info (fun m -> m "Created project '%s' on branch '%s'" name branch); 314 - Log.info (fun m -> m "Add packages with: unpac add opam <pkg>") 315 - 316 - let switch ~proc_mgr ~cwd name = 317 - let branch = project_branch name in 318 - if not (Git.branch_exists ~proc_mgr ~cwd branch) then begin 319 - Log.err (fun m -> m "Project %s does not exist" name); 320 - failwith (Printf.sprintf "Project '%s' does not exist. Create it with: unpac project create %s" name name) 321 - end; 322 - 323 - if Git.has_uncommitted_changes ~proc_mgr ~cwd then begin 324 - Log.warn (fun m -> m "You have uncommitted changes"); 325 - Log.warn (fun m -> m "Commit or stash them before switching projects") 326 - end; 327 - 328 - Log.info (fun m -> m "Switching to project: %s" name); 329 - Git.checkout ~proc_mgr ~cwd branch
-100
lib/project.mli
··· 1 - (** Project management - handling project branches. 2 - 3 - The main branch serves as a registry of all projects. 4 - Each project has its own branch [project/<name>] where actual work happens. *) 5 - 6 - (** {1 Types} *) 7 - 8 - type project_info = { 9 - name : string; 10 - branch : string; 11 - description : string; 12 - created : string; (** ISO 8601 timestamp *) 13 - } 14 - 15 - (** {1 Branch conventions} *) 16 - 17 - val project_branch : string -> string 18 - (** [project_branch name] returns ["project/<name>"] *) 19 - 20 - val is_project_branch : string -> bool 21 - (** [is_project_branch branch] checks if [branch] is a project branch. *) 22 - 23 - val project_name_of_branch : string -> string option 24 - (** [project_name_of_branch branch] extracts project name from branch. *) 25 - 26 - (** {1 Queries} *) 27 - 28 - val current_project : 29 - proc_mgr:Git.proc_mgr -> 30 - cwd:Git.path -> 31 - string option 32 - (** [current_project ~proc_mgr ~cwd] returns the current project name if on a project branch. *) 33 - 34 - val require_project_branch : 35 - proc_mgr:Git.proc_mgr -> 36 - cwd:Git.path -> 37 - string 38 - (** [require_project_branch ~proc_mgr ~cwd] returns project name or raises error. *) 39 - 40 - val is_main_branch : 41 - proc_mgr:Git.proc_mgr -> 42 - cwd:Git.path -> 43 - bool 44 - (** [is_main_branch ~proc_mgr ~cwd] checks if currently on main branch. *) 45 - 46 - val list_projects : 47 - proc_mgr:Git.proc_mgr -> 48 - cwd:Git.path -> 49 - project_info list 50 - (** [list_projects ~proc_mgr ~cwd] returns all projects from the registry. *) 51 - 52 - val project_exists : 53 - proc_mgr:Git.proc_mgr -> 54 - cwd:Git.path -> 55 - string -> 56 - bool 57 - (** [project_exists ~proc_mgr ~cwd name] checks if project [name] exists. *) 58 - 59 - (** {1 Operations} *) 60 - 61 - val init : 62 - proc_mgr:Git.proc_mgr -> 63 - cwd:Git.path -> 64 - unit 65 - (** [init ~proc_mgr ~cwd] initializes the repository with main branch and registry. *) 66 - 67 - val create : 68 - proc_mgr:Git.proc_mgr -> 69 - cwd:Git.path -> 70 - name:string -> 71 - ?description:string -> 72 - unit -> 73 - unit 74 - (** [create ~proc_mgr ~cwd ~name ()] creates a new project and switches to it. 75 - The project is registered in main branch's unpac.toml. *) 76 - 77 - val switch : 78 - proc_mgr:Git.proc_mgr -> 79 - cwd:Git.path -> 80 - string -> 81 - unit 82 - (** [switch ~proc_mgr ~cwd name] switches to project [name]. *) 83 - 84 - (** {1 Configuration} *) 85 - 86 - type registry = { 87 - version : string; 88 - projects : project_info list; 89 - } 90 - 91 - val load_registry : 92 - cwd:Git.path -> 93 - registry option 94 - (** [load_registry ~cwd] loads the project registry from main branch's unpac.toml. *) 95 - 96 - val save_registry : 97 - cwd:Git.path -> 98 - registry -> 99 - unit 100 - (** [save_registry ~cwd registry] saves the project registry. *)
-315
lib/recovery.ml
··· 1 - (** Recovery state for error recovery during multi-step operations. *) 2 - 3 - let src = Logs.Src.create "unpac.recovery" ~doc:"Recovery operations" 4 - module Log = (val Logs.src_log src : Logs.LOG) 5 - 6 - (* Step types *) 7 - 8 - type step = 9 - | Remote_add of { remote : string; url : string } 10 - | Fetch of { remote : string } 11 - | Create_upstream of { branch : string; start_point : string } 12 - | Create_vendor of { name : string; upstream : string } 13 - | Create_patches of { branch : string; vendor : string } 14 - | Merge_to_project of { patches : string } 15 - | Update_toml of { package_name : string } 16 - | Commit of { message : string } 17 - 18 - let step_name = function 19 - | Remote_add _ -> "remote_add" 20 - | Fetch _ -> "fetch" 21 - | Create_upstream _ -> "create_upstream" 22 - | Create_vendor _ -> "create_vendor" 23 - | Create_patches _ -> "create_patches" 24 - | Merge_to_project _ -> "merge_to_project" 25 - | Update_toml _ -> "update_toml" 26 - | Commit _ -> "commit" 27 - 28 - let pp_step fmt = function 29 - | Remote_add { remote; url } -> 30 - Format.fprintf fmt "remote_add(%s -> %s)" remote url 31 - | Fetch { remote } -> 32 - Format.fprintf fmt "fetch(%s)" remote 33 - | Create_upstream { branch; start_point } -> 34 - Format.fprintf fmt "create_upstream(%s from %s)" branch start_point 35 - | Create_vendor { name; upstream } -> 36 - Format.fprintf fmt "create_vendor(%s from %s)" name upstream 37 - | Create_patches { branch; vendor } -> 38 - Format.fprintf fmt "create_patches(%s from %s)" branch vendor 39 - | Merge_to_project { patches } -> 40 - Format.fprintf fmt "merge_to_project(%s)" patches 41 - | Update_toml { package_name } -> 42 - Format.fprintf fmt "update_toml(%s)" package_name 43 - | Commit { message } -> 44 - let msg = if String.length message > 30 then String.sub message 0 30 ^ "..." else message in 45 - Format.fprintf fmt "commit(%s)" msg 46 - 47 - (* Operation types *) 48 - 49 - type operation = 50 - | Add_package of { 51 - name : string; 52 - url : string; 53 - branch : string; 54 - opam_packages : string list 55 - } 56 - | Update_package of { name : string } 57 - | Rebase_patches of { name : string } 58 - 59 - let pp_operation fmt = function 60 - | Add_package { name; _ } -> 61 - Format.fprintf fmt "add_package(%s)" name 62 - | Update_package { name } -> 63 - Format.fprintf fmt "update_package(%s)" name 64 - | Rebase_patches { name } -> 65 - Format.fprintf fmt "rebase_patches(%s)" name 66 - 67 - (* State *) 68 - 69 - type state = { 70 - operation : operation; 71 - original_branch : string; 72 - original_head : string; 73 - started : string; 74 - completed : step list; 75 - pending : step list; 76 - } 77 - 78 - let pp_state fmt state = 79 - Format.fprintf fmt "@[<v>Operation: %a@,Original: %s @ %s@,Started: %s@,Completed: %d steps@,Pending: %d steps@]" 80 - pp_operation state.operation 81 - state.original_branch state.original_head 82 - state.started 83 - (List.length state.completed) 84 - (List.length state.pending) 85 - 86 - (* Persistence *) 87 - 88 - let recovery_dir = ".unpac" 89 - let recovery_file = ".unpac/recovery.toml" 90 - 91 - (* TOML encoding for steps - uses Tomlt.Toml for raw value construction *) 92 - module T = Tomlt.Toml 93 - 94 - let step_to_toml step = 95 - let typ = step_name step in 96 - let data = match step with 97 - | Remote_add { remote; url } -> 98 - [("remote", T.string remote); ("url", T.string url)] 99 - | Fetch { remote } -> 100 - [("remote", T.string remote)] 101 - | Create_upstream { branch; start_point } -> 102 - [("branch", T.string branch); ("start_point", T.string start_point)] 103 - | Create_vendor { name; upstream } -> 104 - [("name", T.string name); ("upstream", T.string upstream)] 105 - | Create_patches { branch; vendor } -> 106 - [("branch", T.string branch); ("vendor", T.string vendor)] 107 - | Merge_to_project { patches } -> 108 - [("patches", T.string patches)] 109 - | Update_toml { package_name } -> 110 - [("package_name", T.string package_name)] 111 - | Commit { message } -> 112 - [("message", T.string message)] 113 - in 114 - T.table (("type", T.string typ) :: data) 115 - 116 - let step_of_toml toml = 117 - let get_string key = 118 - match T.find_opt key toml with 119 - | Some (T.String s) -> s 120 - | _ -> failwith ("missing key: " ^ key) 121 - in 122 - match get_string "type" with 123 - | "remote_add" -> 124 - Remote_add { remote = get_string "remote"; url = get_string "url" } 125 - | "fetch" -> 126 - Fetch { remote = get_string "remote" } 127 - | "create_upstream" -> 128 - Create_upstream { branch = get_string "branch"; start_point = get_string "start_point" } 129 - | "create_vendor" -> 130 - Create_vendor { name = get_string "name"; upstream = get_string "upstream" } 131 - | "create_patches" -> 132 - Create_patches { branch = get_string "branch"; vendor = get_string "vendor" } 133 - | "merge_to_project" -> 134 - Merge_to_project { patches = get_string "patches" } 135 - | "update_toml" -> 136 - Update_toml { package_name = get_string "package_name" } 137 - | "commit" -> 138 - Commit { message = get_string "message" } 139 - | typ -> 140 - failwith ("unknown step type: " ^ typ) 141 - 142 - let operation_to_toml op = 143 - match op with 144 - | Add_package { name; url; branch; opam_packages } -> 145 - T.table [ 146 - ("type", T.string "add_package"); 147 - ("name", T.string name); 148 - ("url", T.string url); 149 - ("branch", T.string branch); 150 - ("opam_packages", T.array (List.map T.string opam_packages)); 151 - ] 152 - | Update_package { name } -> 153 - T.table [ 154 - ("type", T.string "update_package"); 155 - ("name", T.string name); 156 - ] 157 - | Rebase_patches { name } -> 158 - T.table [ 159 - ("type", T.string "rebase_patches"); 160 - ("name", T.string name); 161 - ] 162 - 163 - let operation_of_toml toml = 164 - let get_string key = 165 - match T.find_opt key toml with 166 - | Some (T.String s) -> s 167 - | _ -> failwith ("missing key: " ^ key) 168 - in 169 - let get_string_list key = 170 - match T.find_opt key toml with 171 - | Some (T.Array arr) -> 172 - List.filter_map (function T.String s -> Some s | _ -> None) arr 173 - | _ -> [] 174 - in 175 - match get_string "type" with 176 - | "add_package" -> 177 - Add_package { 178 - name = get_string "name"; 179 - url = get_string "url"; 180 - branch = get_string "branch"; 181 - opam_packages = get_string_list "opam_packages"; 182 - } 183 - | "update_package" -> 184 - Update_package { name = get_string "name" } 185 - | "rebase_patches" -> 186 - Rebase_patches { name = get_string "name" } 187 - | typ -> 188 - failwith ("unknown operation type: " ^ typ) 189 - 190 - let state_to_toml state = 191 - T.table [ 192 - ("operation", operation_to_toml state.operation); 193 - ("original_branch", T.string state.original_branch); 194 - ("original_head", T.string state.original_head); 195 - ("started", T.string state.started); 196 - ("completed", T.array (List.map step_to_toml state.completed)); 197 - ("pending", T.array (List.map step_to_toml state.pending)); 198 - ] 199 - 200 - let state_of_toml toml = 201 - let get_string key = 202 - match T.find_opt key toml with 203 - | Some (T.String s) -> s 204 - | _ -> failwith ("missing key: " ^ key) 205 - in 206 - let get_table key = 207 - match T.find_opt key toml with 208 - | Some (T.Table t) -> T.table t 209 - | _ -> failwith ("missing table: " ^ key) 210 - in 211 - let get_step_list key = 212 - match T.find_opt key toml with 213 - | Some (T.Array arr) -> 214 - List.filter_map (function 215 - | T.Table t -> Some (step_of_toml (T.table t)) 216 - | _ -> None 217 - ) arr 218 - | _ -> [] 219 - in 220 - { 221 - operation = operation_of_toml (get_table "operation"); 222 - original_branch = get_string "original_branch"; 223 - original_head = get_string "original_head"; 224 - started = get_string "started"; 225 - completed = get_step_list "completed"; 226 - pending = get_step_list "pending"; 227 - } 228 - 229 - let save ~cwd state = 230 - let dir_path = Eio.Path.(cwd / recovery_dir) in 231 - Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 dir_path; 232 - let file_path = Eio.Path.(cwd / recovery_file) in 233 - let toml = state_to_toml state in 234 - let content = Tomlt_bytesrw.to_string toml in 235 - Eio.Path.save ~create:(`Or_truncate 0o644) file_path content; 236 - Log.debug (fun m -> m "Saved recovery state to %s" recovery_file) 237 - 238 - let load ~cwd = 239 - let file_path = Eio.Path.(cwd / recovery_file) in 240 - match Eio.Path.load file_path with 241 - | content -> 242 - begin match Tomlt_bytesrw.of_string content with 243 - | Ok toml -> 244 - let state = state_of_toml toml in 245 - Log.debug (fun m -> m "Loaded recovery state: %a" pp_state state); 246 - Some state 247 - | Error e -> 248 - Log.warn (fun m -> m "Failed to parse recovery file: %s" 249 - (Tomlt.Toml.Error.to_string e)); 250 - None 251 - end 252 - | exception Eio.Io (Eio.Fs.E (Eio.Fs.Not_found _), _) -> 253 - None 254 - | exception exn -> 255 - Log.warn (fun m -> m "Failed to load recovery file: %a" Fmt.exn exn); 256 - None 257 - 258 - let clear ~cwd = 259 - let file_path = Eio.Path.(cwd / recovery_file) in 260 - begin try Eio.Path.unlink file_path 261 - with Eio.Io (Eio.Fs.E (Eio.Fs.Not_found _), _) -> () 262 - end; 263 - Log.debug (fun m -> m "Cleared recovery state") 264 - 265 - let has_recovery ~cwd = 266 - let file_path = Eio.Path.(cwd / recovery_file) in 267 - match Eio.Path.kind ~follow:false file_path with 268 - | `Regular_file -> true 269 - | _ -> false 270 - | exception _ -> false 271 - 272 - (* State transitions *) 273 - 274 - let mark_step_complete state = 275 - match state.pending with 276 - | [] -> state 277 - | step :: rest -> 278 - { state with 279 - completed = step :: state.completed; 280 - pending = rest; 281 - } 282 - 283 - let current_step state = 284 - match state.pending with 285 - | [] -> None 286 - | step :: _ -> Some step 287 - 288 - (* Abort and resume *) 289 - 290 - let abort ~proc_mgr ~cwd state = 291 - Log.info (fun m -> m "Aborting operation: %a" pp_operation state.operation); 292 - Log.info (fun m -> m "Restoring to: %s @ %s" state.original_branch state.original_head); 293 - 294 - (* Abort any in-progress operations *) 295 - Git.rebase_abort ~proc_mgr ~cwd; 296 - Git.merge_abort ~proc_mgr ~cwd; 297 - 298 - (* Reset to original state *) 299 - Git.reset_hard ~proc_mgr ~cwd state.original_head; 300 - Git.clean_fd ~proc_mgr ~cwd; 301 - 302 - (* Switch back to original branch if possible *) 303 - begin try 304 - Git.checkout ~proc_mgr ~cwd state.original_branch 305 - with _ -> 306 - Log.warn (fun m -> m "Could not switch back to %s" state.original_branch) 307 - end; 308 - 309 - (* Clear recovery state *) 310 - clear ~cwd; 311 - 312 - Log.info (fun m -> m "Aborted. Repository restored to previous state.") 313 - 314 - let can_resume state = 315 - state.pending <> []
-93
lib/recovery.mli
··· 1 - (** Recovery state for error recovery during multi-step operations. 2 - 3 - When a multi-step operation (like adding a package) fails partway through, 4 - the recovery state allows us to either: 5 - - Resume from where we left off 6 - - Abort and rollback to the original state *) 7 - 8 - (** {1 Step Types} *) 9 - 10 - type step = 11 - | Remote_add of { remote : string; url : string } 12 - | Fetch of { remote : string } 13 - | Create_upstream of { branch : string; start_point : string } 14 - | Create_vendor of { name : string; upstream : string } 15 - | Create_patches of { branch : string; vendor : string } 16 - | Merge_to_project of { patches : string } 17 - | Update_toml of { package_name : string } 18 - | Commit of { message : string } 19 - 20 - val pp_step : Format.formatter -> step -> unit 21 - val step_name : step -> string 22 - 23 - (** {1 Operation Types} *) 24 - 25 - type operation = 26 - | Add_package of { 27 - name : string; 28 - url : string; 29 - branch : string; 30 - opam_packages : string list 31 - } 32 - | Update_package of { name : string } 33 - | Rebase_patches of { name : string } 34 - 35 - val pp_operation : Format.formatter -> operation -> unit 36 - 37 - (** {1 State} *) 38 - 39 - type state = { 40 - operation : operation; 41 - original_branch : string; 42 - original_head : string; 43 - started : string; (** ISO 8601 timestamp *) 44 - completed : step list; 45 - pending : step list; 46 - } 47 - 48 - val pp_state : Format.formatter -> state -> unit 49 - 50 - (** {1 Persistence} *) 51 - 52 - val recovery_dir : string 53 - (** [".unpac"] - directory for recovery state *) 54 - 55 - val recovery_file : string 56 - (** [".unpac/recovery.toml"] - recovery state file *) 57 - 58 - val save : cwd:Git.path -> state -> unit 59 - (** [save ~cwd state] persists recovery state to disk. *) 60 - 61 - val load : cwd:Git.path -> state option 62 - (** [load ~cwd] loads recovery state if it exists. *) 63 - 64 - val clear : cwd:Git.path -> unit 65 - (** [clear ~cwd] removes recovery state file. *) 66 - 67 - val has_recovery : cwd:Git.path -> bool 68 - (** [has_recovery ~cwd] checks if there's pending recovery state. *) 69 - 70 - (** {1 State Transitions} *) 71 - 72 - val mark_step_complete : state -> state 73 - (** [mark_step_complete state] moves the first pending step to completed. *) 74 - 75 - val current_step : state -> step option 76 - (** [current_step state] returns the next step to execute. *) 77 - 78 - (** {1 Abort and Resume} *) 79 - 80 - val abort : 81 - proc_mgr:Git.proc_mgr -> 82 - cwd:Git.path -> 83 - state -> 84 - unit 85 - (** [abort ~proc_mgr ~cwd state] aborts the operation and restores original state. 86 - This will: 87 - - Abort any in-progress merge or rebase 88 - - Reset to original HEAD 89 - - Clean up partial state 90 - - Remove recovery file *) 91 - 92 - val can_resume : state -> bool 93 - (** [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
··· 1 - (** Transaction log for debugging unpac operations. 2 - 3 - Uses Unix I/O so it works both inside and outside Eio context. *) 4 - 5 - let log_file = ".unpac.log" 6 - 7 - let timestamp () = 8 - let t = Unix.gettimeofday () in 9 - let tm = Unix.localtime t in 10 - let ms = int_of_float ((t -. floor t) *. 1000.) in 11 - Printf.sprintf "%04d-%02d-%02d %02d:%02d:%02d.%03d" 12 - (tm.Unix.tm_year + 1900) 13 - (tm.Unix.tm_mon + 1) 14 - tm.Unix.tm_mday 15 - tm.Unix.tm_hour 16 - tm.Unix.tm_min 17 - tm.Unix.tm_sec 18 - ms 19 - 20 - let append lines = 21 - try 22 - let oc = open_out_gen [Open_append; Open_creat; Open_text] 0o644 log_file in 23 - List.iter (fun line -> output_string oc (line ^ "\n")) lines; 24 - close_out oc 25 - with _ -> 26 - (* Silently ignore logging failures - don't break the main operation *) 27 - () 28 - 29 - let separator = "----------------------------------------" 30 - 31 - let start_session ~args = 32 - let ts = timestamp () in 33 - let cmd = String.concat " " ("unpac" :: args) in 34 - let cwd = Sys.getcwd () in 35 - append [ 36 - ""; 37 - separator; 38 - Printf.sprintf "[%s] SESSION START" ts; 39 - Printf.sprintf "Command: %s" cmd; 40 - Printf.sprintf "CWD: %s" cwd; 41 - separator; 42 - ] 43 - 44 - let end_session ~exit_code = 45 - let ts = timestamp () in 46 - let status = if exit_code = 0 then "SUCCESS" else Printf.sprintf "FAILED (exit %d)" exit_code in 47 - append [ 48 - separator; 49 - Printf.sprintf "[%s] SESSION END: %s" ts status; 50 - separator; 51 - ] 52 - 53 - let log_git_command ~args ~exit_code ~stdout ~stderr = 54 - let ts = timestamp () in 55 - let cmd = String.concat " " ("git" :: args) in 56 - let status = if exit_code = 0 then "OK" else Printf.sprintf "FAILED (exit %d)" exit_code in 57 - let lines = [ 58 - Printf.sprintf "[%s] GIT: %s" ts cmd; 59 - Printf.sprintf " Status: %s" status; 60 - ] in 61 - let lines = 62 - if String.trim stdout <> "" then 63 - lines @ [ 64 - " --- stdout ---"; 65 - String.concat "\n" (List.map (fun l -> " " ^ l) (String.split_on_char '\n' (String.trim stdout))); 66 - ] 67 - else lines 68 - in 69 - let lines = 70 - if String.trim stderr <> "" then 71 - lines @ [ 72 - " --- stderr ---"; 73 - String.concat "\n" (List.map (fun l -> " " ^ l) (String.split_on_char '\n' (String.trim stderr))); 74 - ] 75 - else lines 76 - in 77 - append lines 78 - 79 - let log_message msg = 80 - let ts = timestamp () in 81 - append [Printf.sprintf "[%s] INFO: %s" ts msg] 82 - 83 - let log_error msg = 84 - let ts = timestamp () in 85 - append [Printf.sprintf "[%s] ERROR: %s" ts msg]
-31
lib/txn_log.mli
··· 1 - (** Transaction log for debugging unpac operations. 2 - 3 - Maintains a persistent .unpac.log file with a trace of all operations 4 - and git commands for debugging purposes. Uses Unix I/O so it works 5 - both inside and outside Eio context. *) 6 - 7 - (** {1 Session Management} *) 8 - 9 - val start_session : args:string list -> unit 10 - (** [start_session ~args] logs the start of an unpac session with the 11 - given command-line arguments. *) 12 - 13 - val end_session : exit_code:int -> unit 14 - (** [end_session ~exit_code] logs the end of the current session. *) 15 - 16 - (** {1 Command Logging} *) 17 - 18 - val log_git_command : 19 - args:string list -> 20 - exit_code:int -> 21 - stdout:string -> 22 - stderr:string -> 23 - unit 24 - (** [log_git_command ~args ~exit_code ~stdout ~stderr] logs a git 25 - command execution with its full output. *) 26 - 27 - val log_message : string -> unit 28 - (** [log_message msg] logs an informational message. *) 29 - 30 - val log_error : string -> unit 31 - (** [log_error msg] logs an error message. *)
+7 -16
lib/unpac.ml
··· 1 - (** Unpac - Monorepo management library. *) 1 + (** Unpac - Multi-backend vendoring library using git worktrees. *) 2 2 3 - module Config = Config 4 - module Dev_repo = Dev_repo 5 - module Git_repo_lookup = Git_repo_lookup 6 - module Repo_index = Repo_index 7 - module Output = Output 8 - module Source = Source 9 - module Solver = Solver 10 - module Cache = Cache 3 + (** {1 Core Modules} *) 11 4 12 - (** Vendor operations *) 13 5 module Git = Git 14 - module Recovery = Recovery 15 - module Vendor = Vendor 16 - module Project = Project 17 - 18 - (** Logging *) 19 - module Txn_log = Txn_log 6 + module Git_repo_lookup = Git_repo_lookup 7 + module Worktree = Worktree 8 + module Config = Config 9 + module Init = Init 10 + module Backend = Backend
-520
lib/vendor.ml
··· 1 - (** Vendor package management operations. *) 2 - 3 - let src = Logs.Src.create "unpac.vendor" ~doc:"Vendor operations" 4 - module Log = (val Logs.src_log src : Logs.LOG) 5 - 6 - (* Option helper for compatibility *) 7 - let option_value ~default = function 8 - | Some x -> x 9 - | None -> default 10 - 11 - (* Types *) 12 - 13 - type package_status = { 14 - name : string; 15 - url : string; 16 - branch : string; 17 - upstream_sha : string option; 18 - vendor_sha : string option; 19 - patches_sha : string option; 20 - patch_count : int; 21 - in_project : bool; 22 - opam_packages : string list; 23 - } 24 - 25 - let pp_package_status fmt s = 26 - Format.fprintf fmt "@[<v>%s:@, url: %s@, branch: %s@, upstream: %a@, vendor: %a@, patches: %a (%d)@, in_project: %b@, opam_packages: %a@]" 27 - s.name s.url s.branch 28 - Fmt.(option string) s.upstream_sha 29 - Fmt.(option string) s.vendor_sha 30 - Fmt.(option string) s.patches_sha 31 - s.patch_count 32 - s.in_project 33 - Fmt.(list ~sep:comma string) s.opam_packages 34 - 35 - type add_result = 36 - | Success of { 37 - canonical_name : string; 38 - opam_packages : string list; 39 - upstream_sha : string; 40 - vendor_sha : string; 41 - } 42 - | Already_vendored of string 43 - | Failed of { 44 - step : string; 45 - error : exn; 46 - recovery_hint : string; 47 - } 48 - 49 - type update_result = 50 - | Updated of { 51 - old_sha : string; 52 - new_sha : string; 53 - commit_count : int; 54 - } 55 - | No_changes 56 - | Update_failed of { 57 - step : string; 58 - error : exn; 59 - recovery_hint : string; 60 - } 61 - 62 - (* Branch naming conventions *) 63 - 64 - let remote_name pkg = "origin-" ^ pkg 65 - let upstream_branch pkg = "opam/upstream/" ^ pkg 66 - let vendor_branch pkg = "opam/vendor/" ^ pkg 67 - let patches_branch pkg = "opam/patches/" ^ pkg 68 - let vendor_path pkg = "opam/vendor/" ^ pkg ^ "/" 69 - 70 - (* Queries *) 71 - 72 - let is_vendored ~proc_mgr ~cwd name = 73 - Git.remote_exists ~proc_mgr ~cwd (remote_name name) 74 - 75 - let get_vendored_packages ~proc_mgr ~cwd = 76 - Git.list_remotes ~proc_mgr ~cwd 77 - |> List.filter_map (fun remote -> 78 - if String.starts_with ~prefix:"origin-" remote then 79 - Some (String.sub remote 7 (String.length remote - 7)) 80 - else 81 - None) 82 - |> List.sort String.compare 83 - 84 - let package_status ~proc_mgr ~cwd name = 85 - if not (is_vendored ~proc_mgr ~cwd name) then None 86 - else 87 - let remote = remote_name name in 88 - let url = Git.remote_url ~proc_mgr ~cwd remote |> option_value ~default:"" in 89 - let upstream_sha = Git.rev_parse ~proc_mgr ~cwd (upstream_branch name) in 90 - let vendor_sha = Git.rev_parse ~proc_mgr ~cwd (vendor_branch name) in 91 - let patches_sha = Git.rev_parse ~proc_mgr ~cwd (patches_branch name) in 92 - let patch_count = 93 - match (vendor_sha, patches_sha) with 94 - | Some v, Some p -> 95 - begin try Git.rev_list_count ~proc_mgr ~cwd v p 96 - with _ -> 0 97 - end 98 - | _ -> 0 99 - in 100 - (* Check if vendor directory exists in current branch *) 101 - let current = Git.current_branch ~proc_mgr ~cwd in 102 - let in_project = 103 - match current with 104 - | Some branch -> Git.ls_tree ~proc_mgr ~cwd ~tree:branch ~path:(vendor_path name) 105 - | None -> false 106 - in 107 - (* Detect branch from remote tracking *) 108 - let branch = 109 - try Git.ls_remote_default_branch ~proc_mgr ~url 110 - with _ -> "main" 111 - in 112 - Some { 113 - name; 114 - url; 115 - branch; 116 - upstream_sha = Option.map (fun s -> String.sub s 0 (min 7 (String.length s))) upstream_sha; 117 - vendor_sha = Option.map (fun s -> String.sub s 0 (min 7 (String.length s))) vendor_sha; 118 - patches_sha = Option.map (fun s -> String.sub s 0 (min 7 (String.length s))) patches_sha; 119 - patch_count; 120 - in_project; 121 - opam_packages = []; (* TODO: load from config *) 122 - } 123 - 124 - let all_status ~proc_mgr ~cwd = 125 - get_vendored_packages ~proc_mgr ~cwd 126 - |> List.filter_map (fun name -> package_status ~proc_mgr ~cwd name) 127 - 128 - (* Conflict prompt generation *) 129 - 130 - let generate_conflict_prompt ~operation ~pkg_name ~files = 131 - let files_list = String.concat "\n" (List.map (fun f -> "- " ^ f) files) in 132 - Printf.sprintf {| 133 - ## Git %s Conflict Resolution Needed 134 - 135 - Package `%s` has conflicts in the following files: 136 - %s 137 - 138 - ### To resolve manually: 139 - 1. Edit the conflicting files to resolve conflicts (look for <<<<<<< markers) 140 - 2. Stage resolved files: git add <resolved-files> 141 - 3. Continue the operation: unpac vendor continue 142 - 143 - ### To abort: 144 - Run: unpac vendor abort 145 - 146 - ### Or ask Claude to help: 147 - ``` 148 - Please help me resolve the %s conflicts for the %s package. 149 - 150 - The conflicting files are: 151 - %s 152 - 153 - Show me the resolved versions of each file. The conflicts are between 154 - the vendor branch (upstream code with path prefix vendor/%s/) and 155 - my project branch modifications. 156 - ``` 157 - |} operation pkg_name files_list 158 - operation pkg_name files_list pkg_name 159 - 160 - (* Get current timestamp in ISO 8601 format *) 161 - let iso_timestamp () = 162 - let t = Unix.gettimeofday () in 163 - let tm = Unix.gmtime t in 164 - Printf.sprintf "%04d-%02d-%02dT%02d:%02d:%02dZ" 165 - (tm.Unix.tm_year + 1900) 166 - (tm.Unix.tm_mon + 1) 167 - tm.Unix.tm_mday 168 - tm.Unix.tm_hour 169 - tm.Unix.tm_min 170 - tm.Unix.tm_sec 171 - 172 - (* Execute a single step - Git module handles its own logging *) 173 - let execute_step ~proc_mgr ~cwd step = 174 - let open Recovery in 175 - match step with 176 - | Remote_add { remote; url } -> 177 - ignore (Git.ensure_remote ~proc_mgr ~cwd ~name:remote ~url) 178 - 179 - | Fetch { remote } -> 180 - Git.fetch ~proc_mgr ~cwd ~remote 181 - 182 - | Create_upstream { branch; start_point } -> 183 - ignore (Git.ensure_branch ~proc_mgr ~cwd ~name:branch ~start_point) 184 - 185 - | Create_vendor { name; upstream } -> 186 - Log.info (fun m -> m "Creating vendor branch: %s" (vendor_branch name)); 187 - let current = Git.current_branch ~proc_mgr ~cwd in 188 - let vbranch = vendor_branch name in 189 - 190 - (* Create orphan branch *) 191 - Git.checkout_orphan ~proc_mgr ~cwd vbranch; 192 - 193 - (* Clear everything from index *) 194 - Git.rm_cached_rf ~proc_mgr ~cwd; 195 - 196 - (* Read tree with prefix *) 197 - Git.read_tree_prefix ~proc_mgr ~cwd ~prefix:(vendor_path name) ~tree:upstream; 198 - 199 - (* Checkout files to working directory *) 200 - Git.checkout_index ~proc_mgr ~cwd; 201 - 202 - (* Commit *) 203 - let short_sha = Git.rev_parse_short ~proc_mgr ~cwd upstream in 204 - let full_sha = Git.rev_parse_exn ~proc_mgr ~cwd upstream in 205 - let url = Git.remote_url ~proc_mgr ~cwd (remote_name name) |> option_value ~default:"" in 206 - let message = Printf.sprintf "vendor/%s: import at %s\n\nUpstream: %s\nCommit: %s" 207 - name short_sha url full_sha 208 - in 209 - Git.commit ~proc_mgr ~cwd ~message; 210 - 211 - (* Return to original branch *) 212 - begin match current with 213 - | Some b -> Git.checkout ~proc_mgr ~cwd b 214 - | None -> () 215 - end 216 - 217 - | Create_patches { branch; vendor } -> 218 - ignore (Git.ensure_branch ~proc_mgr ~cwd ~name:branch ~start_point:vendor) 219 - 220 - | Merge_to_project { patches } -> 221 - let pkg_name = 222 - if String.starts_with ~prefix:"patches/" patches then 223 - String.sub patches 8 (String.length patches - 8) 224 - else patches 225 - in 226 - let message = Printf.sprintf "Merge %s\n\nVendor package: %s" patches pkg_name in 227 - begin match Git.merge_allow_unrelated ~proc_mgr ~cwd ~branch:patches ~message with 228 - | Ok () -> () 229 - | Error (`Conflict files) -> 230 - let prompt = generate_conflict_prompt ~operation:"merge" ~pkg_name ~files in 231 - Log.warn (fun m -> m "Merge conflict in %s" patches); 232 - Log.info (fun m -> m "%s" prompt); 233 - raise (Git.err (Git.Merge_conflict { branch = patches; conflicting_files = files })) 234 - end 235 - 236 - | Update_toml { package_name = _ } -> 237 - (* TODO: Actually update the TOML file *) 238 - () 239 - 240 - | Commit { message } -> 241 - if Git.has_uncommitted_changes ~proc_mgr ~cwd then begin 242 - Git.add_all ~proc_mgr ~cwd; 243 - Git.commit ~proc_mgr ~cwd ~message 244 - end 245 - 246 - (* Build steps for add_package *) 247 - let build_add_steps ~name ~url ~branch ~opam_packages:_ = 248 - let remote = remote_name name in 249 - let upstream = upstream_branch name in 250 - let vendor = vendor_branch name in 251 - let patches = patches_branch name in 252 - let start_point = remote ^ "/" ^ branch in 253 - Recovery.[ 254 - Remote_add { remote; url }; 255 - Fetch { remote }; 256 - Create_upstream { branch = upstream; start_point }; 257 - Create_vendor { name; upstream }; 258 - Create_patches { branch = patches; vendor }; 259 - Merge_to_project { patches }; 260 - Update_toml { package_name = name }; 261 - Commit { message = Printf.sprintf "vendor: add %s" name }; 262 - ] 263 - 264 - (* Operations *) 265 - 266 - let add_package ~proc_mgr ~cwd ~name ~url ~branch ~opam_packages = 267 - (* Check if already vendored *) 268 - if is_vendored ~proc_mgr ~cwd name then begin 269 - Log.warn (fun m -> m "Package %s is already vendored" name); 270 - Already_vendored name 271 - end else begin 272 - (* Get current state for recovery *) 273 - let original_branch = Git.current_branch_exn ~proc_mgr ~cwd in 274 - let original_head = Git.current_head ~proc_mgr ~cwd in 275 - 276 - (* Build recovery state *) 277 - let steps = build_add_steps ~name ~url ~branch ~opam_packages in 278 - let state = Recovery.{ 279 - operation = Add_package { name; url; branch; opam_packages }; 280 - original_branch; 281 - original_head; 282 - started = iso_timestamp (); 283 - completed = []; 284 - pending = steps; 285 - } in 286 - Recovery.save ~cwd state; 287 - 288 - (* Execute steps with recovery tracking *) 289 - let rec execute_steps state = 290 - match state.Recovery.pending with 291 - | [] -> 292 - Recovery.clear ~cwd; 293 - let upstream_sha = Git.rev_parse_exn ~proc_mgr ~cwd (upstream_branch name) in 294 - let vendor_sha = Git.rev_parse_exn ~proc_mgr ~cwd (vendor_branch name) in 295 - Success { 296 - canonical_name = name; 297 - opam_packages; 298 - upstream_sha; 299 - vendor_sha; 300 - } 301 - | step :: rest -> 302 - begin try 303 - execute_step ~proc_mgr ~cwd step; 304 - let state = { state with 305 - Recovery.completed = step :: state.completed; 306 - pending = rest; 307 - } in 308 - Recovery.save ~cwd state; 309 - execute_steps state 310 - with exn -> 311 - let hint = match step with 312 - | Recovery.Merge_to_project _ -> 313 - "Merge conflict. Resolve conflicts then run: unpac vendor continue" 314 - | Recovery.Create_vendor _ -> 315 - "Failed creating vendor branch. Run: unpac vendor abort" 316 - | _ -> 317 - "Run 'unpac vendor abort' to rollback, or fix and 'unpac vendor continue'" 318 - in 319 - Failed { 320 - step = Recovery.step_name step; 321 - error = exn; 322 - recovery_hint = hint; 323 - } 324 - end 325 - in 326 - execute_steps state 327 - end 328 - 329 - let update_package ~proc_mgr ~cwd ~name = 330 - if not (is_vendored ~proc_mgr ~cwd name) then begin 331 - Log.err (fun m -> m "Package %s is not vendored" name); 332 - Update_failed { 333 - step = "check_vendored"; 334 - error = Git.err (Git.Remote_not_found (remote_name name)); 335 - recovery_hint = Printf.sprintf "Use 'unpac add opam %s' to add it first" name; 336 - } 337 - end else begin 338 - let remote = remote_name name in 339 - let upstream = upstream_branch name in 340 - let vendor = vendor_branch name in 341 - 342 - try 343 - (* Get old SHA *) 344 - let old_sha = Git.rev_parse_exn ~proc_mgr ~cwd upstream in 345 - 346 - (* Fetch latest *) 347 - Log.info (fun m -> m "Fetching from %s..." remote); 348 - Git.fetch ~proc_mgr ~cwd ~remote; 349 - 350 - (* Get URL and detect branch *) 351 - let url = Git.remote_url ~proc_mgr ~cwd remote |> option_value ~default:"" in 352 - let branch = Git.ls_remote_default_branch ~proc_mgr ~url in 353 - let remote_ref = remote ^ "/" ^ branch in 354 - 355 - (* Update upstream branch *) 356 - Log.info (fun m -> m "Updating %s..." upstream); 357 - Git.branch_force ~proc_mgr ~cwd ~name:upstream ~point:remote_ref; 358 - 359 - let new_sha = Git.rev_parse_exn ~proc_mgr ~cwd upstream in 360 - 361 - if old_sha = new_sha then begin 362 - Log.info (fun m -> m "No changes in upstream"); 363 - No_changes 364 - end else begin 365 - (* Show changelog *) 366 - let commits = Git.log_oneline ~proc_mgr ~cwd ~max_count:20 old_sha new_sha in 367 - let commit_count = List.length commits in 368 - Log.info (fun m -> m "Changes in upstream (%d commits):" commit_count); 369 - List.iter (fun line -> Log.info (fun m -> m " %s" line)) commits; 370 - 371 - (* Update vendor branch *) 372 - Log.info (fun m -> m "Updating %s with path rewrite..." vendor); 373 - let current = Git.current_branch ~proc_mgr ~cwd in 374 - 375 - Git.checkout ~proc_mgr ~cwd vendor; 376 - 377 - (* Remove old vendor files *) 378 - Git.rm_rf ~proc_mgr ~cwd ~target:(vendor_path name); 379 - 380 - (* Read new tree *) 381 - Git.read_tree_prefix ~proc_mgr ~cwd ~prefix:(vendor_path name) ~tree:upstream; 382 - Git.checkout_index ~proc_mgr ~cwd; 383 - 384 - (* Commit *) 385 - let old_short = String.sub old_sha 0 7 in 386 - let new_short = String.sub new_sha 0 7 in 387 - let changelog = String.concat "\n" (List.filteri (fun i _ -> i < 10) commits) in 388 - let message = Printf.sprintf "vendor/%s: update to %s\n\nChanges from %s to %s:\n%s" 389 - name new_short old_short new_short changelog 390 - in 391 - Git.add_all ~proc_mgr ~cwd; 392 - Git.commit ~proc_mgr ~cwd ~message; 393 - 394 - (* Return to original branch *) 395 - begin match current with 396 - | Some b -> Git.checkout ~proc_mgr ~cwd b 397 - | None -> () 398 - end; 399 - 400 - Updated { old_sha; new_sha; commit_count } 401 - end 402 - with exn -> 403 - Update_failed { 404 - step = "update"; 405 - error = exn; 406 - recovery_hint = "Check git status and resolve manually"; 407 - } 408 - end 409 - 410 - let rebase_patches ~proc_mgr ~cwd ~name = 411 - let vendor = vendor_branch name in 412 - let patches = patches_branch name in 413 - 414 - if not (Git.branch_exists ~proc_mgr ~cwd patches) then begin 415 - Log.err (fun m -> m "Patches branch %s does not exist" patches); 416 - Error (`Conflict "Patches branch does not exist") 417 - end else begin 418 - let current = Git.current_branch ~proc_mgr ~cwd in 419 - 420 - Git.checkout ~proc_mgr ~cwd patches; 421 - 422 - (* Count patches *) 423 - let patch_count = Git.rev_list_count ~proc_mgr ~cwd vendor patches in 424 - 425 - if patch_count = 0 then begin 426 - Log.info (fun m -> m "No patches to rebase"); 427 - begin match current with 428 - | Some b -> Git.checkout ~proc_mgr ~cwd b 429 - | None -> () 430 - end; 431 - Ok () 432 - end else begin 433 - Log.info (fun m -> m "Rebasing %d patch(es)..." patch_count); 434 - match Git.rebase ~proc_mgr ~cwd ~onto:vendor with 435 - | Ok () -> 436 - Log.info (fun m -> m "Rebase completed successfully"); 437 - begin match current with 438 - | Some b -> Git.checkout ~proc_mgr ~cwd b 439 - | None -> () 440 - end; 441 - Ok () 442 - | Error (`Conflict hint) -> 443 - let prompt = generate_conflict_prompt ~operation:"rebase" ~pkg_name:name ~files:[] in 444 - Log.warn (fun m -> m "Rebase has conflicts"); 445 - Log.info (fun m -> m "%s" prompt); 446 - Error (`Conflict hint) 447 - end 448 - end 449 - 450 - let merge_to_project ~proc_mgr ~cwd ~name = 451 - let patches = patches_branch name in 452 - 453 - if not (Git.branch_exists ~proc_mgr ~cwd patches) then begin 454 - Log.err (fun m -> m "Patches branch %s does not exist" patches); 455 - Error (`Conflict ["Patches branch does not exist"]) 456 - end else begin 457 - let message = Printf.sprintf "Merge %s\n\nVendor package: %s" patches name in 458 - match Git.merge_allow_unrelated ~proc_mgr ~cwd ~branch:patches ~message with 459 - | Ok () -> 460 - Log.info (fun m -> m "Merge completed successfully"); 461 - Ok () 462 - | Error (`Conflict files) -> 463 - let prompt = generate_conflict_prompt ~operation:"merge" ~pkg_name:name ~files in 464 - Log.warn (fun m -> m "Merge has conflicts"); 465 - Log.info (fun m -> m "%s" prompt); 466 - Error (`Conflict files) 467 - end 468 - 469 - (* Recovery *) 470 - 471 - let continue ~proc_mgr ~cwd state = 472 - Log.info (fun m -> m "Continuing operation: %a" Recovery.pp_operation state.Recovery.operation); 473 - 474 - let rec execute_steps state = 475 - match state.Recovery.pending with 476 - | [] -> 477 - Recovery.clear ~cwd; 478 - begin match state.operation with 479 - | Recovery.Add_package { name; opam_packages; _ } -> 480 - let upstream_sha = Git.rev_parse_exn ~proc_mgr ~cwd (upstream_branch name) in 481 - let vendor_sha = Git.rev_parse_exn ~proc_mgr ~cwd (vendor_branch name) in 482 - Success { 483 - canonical_name = name; 484 - opam_packages; 485 - upstream_sha; 486 - vendor_sha; 487 - } 488 - | _ -> 489 - (* For other operations, return a generic success *) 490 - Success { 491 - canonical_name = "unknown"; 492 - opam_packages = []; 493 - upstream_sha = ""; 494 - vendor_sha = ""; 495 - } 496 - end 497 - | step :: rest -> 498 - begin try 499 - execute_step ~proc_mgr ~cwd step; 500 - let state = { state with 501 - Recovery.completed = step :: state.completed; 502 - pending = rest; 503 - } in 504 - Recovery.save ~cwd state; 505 - execute_steps state 506 - with exn -> 507 - let hint = match step with 508 - | Recovery.Merge_to_project _ -> 509 - "Merge conflict. Resolve conflicts then run: unpac vendor continue" 510 - | _ -> 511 - "Run 'unpac vendor abort' to rollback, or fix and 'unpac vendor continue'" 512 - in 513 - Failed { 514 - step = Recovery.step_name step; 515 - error = exn; 516 - recovery_hint = hint; 517 - } 518 - end 519 - in 520 - execute_steps state
-157
lib/vendor.mli
··· 1 - (** Vendor package management operations. 2 - 3 - This module implements the three-tier branch model for vendoring packages: 4 - - [opam/upstream/<pkg>] - pristine upstream with original paths 5 - - [opam/vendor/<pkg>] - orphan branch with path-rewritten files 6 - - [opam/patches/<pkg>] - local modifications on top of vendor *) 7 - 8 - (** {1 Types} *) 9 - 10 - type package_status = { 11 - name : string; 12 - url : string; 13 - branch : string; 14 - upstream_sha : string option; 15 - vendor_sha : string option; 16 - patches_sha : string option; 17 - patch_count : int; 18 - in_project : bool; 19 - opam_packages : string list; 20 - } 21 - 22 - val pp_package_status : Format.formatter -> package_status -> unit 23 - 24 - type add_result = 25 - | Success of { 26 - canonical_name : string; 27 - opam_packages : string list; 28 - upstream_sha : string; 29 - vendor_sha : string; 30 - } 31 - | Already_vendored of string 32 - | Failed of { 33 - step : string; 34 - error : exn; 35 - recovery_hint : string; 36 - } 37 - 38 - type update_result = 39 - | Updated of { 40 - old_sha : string; 41 - new_sha : string; 42 - commit_count : int; 43 - } 44 - | No_changes 45 - | Update_failed of { 46 - step : string; 47 - error : exn; 48 - recovery_hint : string; 49 - } 50 - 51 - (** {1 Branch naming conventions} *) 52 - 53 - val remote_name : string -> string 54 - (** [remote_name pkg] returns ["origin-<pkg>"] *) 55 - 56 - val upstream_branch : string -> string 57 - (** [upstream_branch pkg] returns ["opam/upstream/<pkg>"] *) 58 - 59 - val vendor_branch : string -> string 60 - (** [vendor_branch pkg] returns ["opam/vendor/<pkg>"] *) 61 - 62 - val patches_branch : string -> string 63 - (** [patches_branch pkg] returns ["opam/patches/<pkg>"] *) 64 - 65 - val vendor_path : string -> string 66 - (** [vendor_path pkg] returns ["opam/vendor/<pkg>/"] *) 67 - 68 - (** {1 Queries} *) 69 - 70 - val is_vendored : 71 - proc_mgr:Git.proc_mgr -> 72 - cwd:Git.path -> 73 - string -> 74 - bool 75 - (** [is_vendored ~proc_mgr ~cwd name] checks if package [name] is vendored. *) 76 - 77 - val get_vendored_packages : 78 - proc_mgr:Git.proc_mgr -> 79 - cwd:Git.path -> 80 - string list 81 - (** [get_vendored_packages ~proc_mgr ~cwd] returns list of vendored package names. *) 82 - 83 - val package_status : 84 - proc_mgr:Git.proc_mgr -> 85 - cwd:Git.path -> 86 - string -> 87 - package_status option 88 - (** [package_status ~proc_mgr ~cwd name] returns status of vendored package. *) 89 - 90 - val all_status : 91 - proc_mgr:Git.proc_mgr -> 92 - cwd:Git.path -> 93 - package_status list 94 - (** [all_status ~proc_mgr ~cwd] returns status of all vendored packages. *) 95 - 96 - (** {1 Operations} *) 97 - 98 - val add_package : 99 - proc_mgr:Git.proc_mgr -> 100 - cwd:Git.path -> 101 - name:string -> 102 - url:string -> 103 - branch:string -> 104 - opam_packages:string list -> 105 - add_result 106 - (** [add_package ~proc_mgr ~cwd ~name ~url ~branch ~opam_packages] vendors a package. 107 - 108 - This: 109 - 1. Adds remote [origin-<name>] 110 - 2. Fetches from the remote 111 - 3. Creates [opam/upstream/<name>] branch (pristine) 112 - 4. Creates [opam/vendor/<name>] orphan branch with path rewrite 113 - 5. Creates [opam/patches/<name>] branch 114 - 6. Merges [opam/patches/<name>] into current project branch 115 - 7. Updates unpac.toml with package info *) 116 - 117 - val update_package : 118 - proc_mgr:Git.proc_mgr -> 119 - cwd:Git.path -> 120 - name:string -> 121 - update_result 122 - (** [update_package ~proc_mgr ~cwd ~name] updates a vendored package from upstream. 123 - 124 - This: 125 - 1. Fetches latest from [origin-<name>] 126 - 2. Updates [opam/upstream/<name>] branch 127 - 3. Updates [opam/vendor/<name>] branch with new import *) 128 - 129 - val rebase_patches : 130 - proc_mgr:Git.proc_mgr -> 131 - cwd:Git.path -> 132 - name:string -> 133 - (unit, [ `Conflict of string ]) result 134 - (** [rebase_patches ~proc_mgr ~cwd ~name] rebases patches onto updated vendor. *) 135 - 136 - val merge_to_project : 137 - proc_mgr:Git.proc_mgr -> 138 - cwd:Git.path -> 139 - name:string -> 140 - (unit, [ `Conflict of string list ]) result 141 - (** [merge_to_project ~proc_mgr ~cwd ~name] merges patches branch into project. *) 142 - 143 - (** {1 Recovery} *) 144 - 145 - val continue : 146 - proc_mgr:Git.proc_mgr -> 147 - cwd:Git.path -> 148 - Recovery.state -> 149 - add_result 150 - (** [continue ~proc_mgr ~cwd state] continues an interrupted operation. *) 151 - 152 - val generate_conflict_prompt : 153 - operation:string -> 154 - pkg_name:string -> 155 - files:string list -> 156 - string 157 - (** [generate_conflict_prompt] generates a prompt for Claude to help resolve conflicts. *)
+183
lib/worktree.ml
··· 1 + (** Git worktree lifecycle management for unpac. 2 + 3 + Manages creation, cleanup, and paths of worktrees within the unpac 4 + directory structure. All branch operations happen in isolated worktrees. *) 5 + 6 + (** {1 Types} *) 7 + 8 + type root = Eio.Fs.dir_ty Eio.Path.t 9 + (** The unpac project root directory (contains git/, main/, etc.) *) 10 + 11 + type kind = 12 + | Main 13 + | Project of string 14 + | Opam_upstream of string 15 + | Opam_vendor of string 16 + | Opam_patches of string 17 + (** Worktree kinds with their associated names. *) 18 + 19 + (** {1 Path and Branch Helpers} *) 20 + 21 + let git_dir root = Eio.Path.(root / "git") 22 + (** Path to the bare git repository. *) 23 + 24 + let path root = function 25 + | Main -> Eio.Path.(root / "main") 26 + | Project name -> Eio.Path.(root / "project" / name) 27 + | Opam_upstream name -> Eio.Path.(root / "opam" / "upstream" / name) 28 + | Opam_vendor name -> Eio.Path.(root / "opam" / "vendor" / name) 29 + | Opam_patches name -> Eio.Path.(root / "opam" / "patches" / name) 30 + 31 + let branch = function 32 + | Main -> "main" 33 + | Project name -> "project/" ^ name 34 + | Opam_upstream name -> "opam/upstream/" ^ name 35 + | Opam_vendor name -> "opam/vendor/" ^ name 36 + | Opam_patches name -> "opam/patches/" ^ name 37 + 38 + let relative_path = function 39 + | Main -> "main" 40 + | Project name -> "project/" ^ name 41 + | Opam_upstream name -> "opam/upstream/" ^ name 42 + | Opam_vendor name -> "opam/vendor/" ^ name 43 + | Opam_patches name -> "opam/patches/" ^ name 44 + 45 + (** {1 Queries} *) 46 + 47 + let exists root kind = 48 + let p = path root kind in 49 + Eio.Path.is_directory p 50 + 51 + let branch_exists ~proc_mgr root kind = 52 + let git = git_dir root in 53 + Git.branch_exists ~proc_mgr ~cwd:git (branch kind) 54 + 55 + (** {1 Operations} *) 56 + 57 + let ensure ~proc_mgr root kind = 58 + if exists root kind then () 59 + else begin 60 + let git = git_dir root in 61 + let wt_path = path root kind in 62 + let rel_path = "../" ^ relative_path kind in (* Relative to git/ dir *) 63 + let br = branch kind in 64 + 65 + (* Ensure parent directories exist *) 66 + let parent = Eio.Path.split wt_path |> Option.map fst in 67 + Option.iter (fun p -> Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 p) parent; 68 + 69 + (* Create worktree *) 70 + Git.run_exn ~proc_mgr ~cwd:git 71 + ["worktree"; "add"; rel_path; br] |> ignore 72 + end 73 + 74 + let ensure_orphan ~proc_mgr root kind = 75 + if exists root kind then () 76 + else begin 77 + let git = git_dir root in 78 + let wt_path = path root kind in 79 + let rel_path = "../" ^ relative_path kind in (* Relative to git/ dir *) 80 + let br = branch kind in 81 + 82 + (* Ensure parent directories exist *) 83 + let parent = Eio.Path.split wt_path |> Option.map fst in 84 + Option.iter (fun p -> Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 p) parent; 85 + 86 + (* Create a detached worktree from main branch, then make it an orphan *) 87 + let start_commit = Git.run_exn ~proc_mgr ~cwd:git ["rev-parse"; "main"] |> String.trim in 88 + Git.run_exn ~proc_mgr ~cwd:git 89 + ["worktree"; "add"; "--detach"; rel_path; start_commit] |> ignore; 90 + 91 + (* Now in the worktree, create an orphan branch and clear files *) 92 + Git.run_exn ~proc_mgr ~cwd:wt_path ["checkout"; "--orphan"; br] |> ignore; 93 + (* Remove all tracked files from index *) 94 + Git.run_exn ~proc_mgr ~cwd:wt_path ["rm"; "-rf"; "--cached"; "."] |> ignore; 95 + (* Clean the working directory *) 96 + Git.run_exn ~proc_mgr ~cwd:wt_path ["clean"; "-fd"] |> ignore 97 + end 98 + 99 + let ensure_detached ~proc_mgr root kind ~commit = 100 + if exists root kind then () 101 + else begin 102 + let git = git_dir root in 103 + let wt_path = path root kind in 104 + let rel_path = "../" ^ relative_path kind in (* Relative to git/ dir *) 105 + 106 + (* Ensure parent directories exist *) 107 + let parent = Eio.Path.split wt_path |> Option.map fst in 108 + Option.iter (fun p -> Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 p) parent; 109 + 110 + (* Create detached worktree at commit *) 111 + Git.run_exn ~proc_mgr ~cwd:git 112 + ["worktree"; "add"; "--detach"; rel_path; commit] |> ignore 113 + end 114 + 115 + let remove ~proc_mgr root kind = 116 + if not (exists root kind) then () 117 + else begin 118 + let git = git_dir root in 119 + let rel_path = "../" ^ relative_path kind in (* Relative to git/ dir *) 120 + Git.run_exn ~proc_mgr ~cwd:git 121 + ["worktree"; "remove"; rel_path] |> ignore 122 + end 123 + 124 + let remove_force ~proc_mgr root kind = 125 + if not (exists root kind) then () 126 + else begin 127 + let git = git_dir root in 128 + let rel_path = "../" ^ relative_path kind in (* Relative to git/ dir *) 129 + Git.run_exn ~proc_mgr ~cwd:git 130 + ["worktree"; "remove"; "--force"; rel_path] |> ignore 131 + end 132 + 133 + let with_temp ~proc_mgr root kind f = 134 + ensure ~proc_mgr root kind; 135 + Fun.protect 136 + ~finally:(fun () -> remove ~proc_mgr root kind) 137 + (fun () -> f (path root kind)) 138 + 139 + let with_temp_orphan ~proc_mgr root kind f = 140 + ensure_orphan ~proc_mgr root kind; 141 + Fun.protect 142 + ~finally:(fun () -> remove ~proc_mgr root kind) 143 + (fun () -> f (path root kind)) 144 + 145 + (** {1 Listing} *) 146 + 147 + let list_worktrees ~proc_mgr root = 148 + let git = git_dir root in 149 + Git.run_lines ~proc_mgr ~cwd:git ["worktree"; "list"; "--porcelain"] 150 + |> List.filter_map (fun line -> 151 + if String.starts_with ~prefix:"worktree " line then 152 + Some (String.sub line 9 (String.length line - 9)) 153 + else None) 154 + 155 + let list_projects ~proc_mgr root = 156 + let git = git_dir root in 157 + Git.run_lines ~proc_mgr ~cwd:git ["branch"; "--list"; "project/*"] 158 + |> List.filter_map (fun line -> 159 + let line = String.trim line in 160 + (* Strip "* " (current) or "+ " (linked worktree) prefix *) 161 + let line = 162 + if String.starts_with ~prefix:"* " line || String.starts_with ~prefix:"+ " line 163 + then String.sub line 2 (String.length line - 2) 164 + else line 165 + in 166 + if String.starts_with ~prefix:"project/" line then 167 + Some (String.sub line 8 (String.length line - 8)) 168 + else None) 169 + 170 + let list_opam_packages ~proc_mgr root = 171 + let git = git_dir root in 172 + Git.run_lines ~proc_mgr ~cwd:git ["branch"; "--list"; "opam/patches/*"] 173 + |> List.filter_map (fun line -> 174 + let line = String.trim line in 175 + (* Strip "* " (current) or "+ " (linked worktree) prefix *) 176 + let line = 177 + if String.starts_with ~prefix:"* " line || String.starts_with ~prefix:"+ " line 178 + then String.sub line 2 (String.length line - 2) 179 + else line 180 + in 181 + if String.starts_with ~prefix:"opam/patches/" line then 182 + Some (String.sub line 13 (String.length line - 13)) 183 + else None)
+94
lib/worktree.mli
··· 1 + (** Git worktree lifecycle management for unpac. 2 + 3 + Manages creation, cleanup, and paths of worktrees within the unpac 4 + directory structure. All branch operations happen in isolated worktrees. 5 + 6 + {2 Directory Structure} 7 + 8 + An unpac project has this layout: 9 + {v 10 + my-project/ 11 + ├── git/ # Bare repository 12 + ├── main/ # Worktree → main branch 13 + ├── project/ 14 + │ └── myapp/ # Worktree → project/myapp 15 + └── opam/ 16 + ├── upstream/ 17 + │ └── pkg/ # Worktree → opam/upstream/pkg 18 + ├── vendor/ 19 + │ └── pkg/ # Worktree → opam/vendor/pkg 20 + └── patches/ 21 + └── pkg/ # Worktree → opam/patches/pkg 22 + v} *) 23 + 24 + (** {1 Types} *) 25 + 26 + type root = Eio.Fs.dir_ty Eio.Path.t 27 + (** The unpac project root directory (contains git/, main/, etc.) *) 28 + 29 + type kind = 30 + | Main 31 + | Project of string 32 + | Opam_upstream of string 33 + | Opam_vendor of string 34 + | Opam_patches of string 35 + (** Worktree kinds with their associated names. *) 36 + 37 + (** {1 Path and Branch Helpers} *) 38 + 39 + val git_dir : root -> Eio.Fs.dir_ty Eio.Path.t 40 + (** [git_dir root] returns the path to the bare git repository. *) 41 + 42 + val path : root -> kind -> Eio.Fs.dir_ty Eio.Path.t 43 + (** [path root kind] returns the filesystem path for the worktree. *) 44 + 45 + val branch : kind -> string 46 + (** [branch kind] returns the git branch name for the worktree kind. *) 47 + 48 + (** {1 Queries} *) 49 + 50 + val exists : root -> kind -> bool 51 + (** [exists root kind] checks if the worktree directory exists. *) 52 + 53 + val branch_exists : proc_mgr:Git.proc_mgr -> root -> kind -> bool 54 + (** [branch_exists ~proc_mgr root kind] checks if the branch exists in git. *) 55 + 56 + (** {1 Operations} *) 57 + 58 + val ensure : proc_mgr:Git.proc_mgr -> root -> kind -> unit 59 + (** [ensure ~proc_mgr root kind] creates the worktree if it doesn't exist. 60 + The branch must already exist. *) 61 + 62 + val ensure_orphan : proc_mgr:Git.proc_mgr -> root -> kind -> unit 63 + (** [ensure_orphan ~proc_mgr root kind] creates an orphan worktree. 64 + Creates a new orphan branch. *) 65 + 66 + val ensure_detached : proc_mgr:Git.proc_mgr -> root -> kind -> commit:string -> unit 67 + (** [ensure_detached ~proc_mgr root kind ~commit] creates a detached worktree 68 + at the given commit. Does not create a branch. *) 69 + 70 + val remove : proc_mgr:Git.proc_mgr -> root -> kind -> unit 71 + (** [remove ~proc_mgr root kind] removes the worktree (keeps the branch). *) 72 + 73 + val remove_force : proc_mgr:Git.proc_mgr -> root -> kind -> unit 74 + (** [remove_force ~proc_mgr root kind] forcibly removes the worktree. *) 75 + 76 + val with_temp : proc_mgr:Git.proc_mgr -> root -> kind -> (Eio.Fs.dir_ty Eio.Path.t -> 'a) -> 'a 77 + (** [with_temp ~proc_mgr root kind f] creates the worktree, runs [f] with 78 + the worktree path, then removes the worktree. *) 79 + 80 + val with_temp_orphan : proc_mgr:Git.proc_mgr -> root -> kind -> (Eio.Fs.dir_ty Eio.Path.t -> 'a) -> 'a 81 + (** [with_temp_orphan ~proc_mgr root kind f] creates an orphan worktree, 82 + runs [f], then removes the worktree. *) 83 + 84 + (** {1 Listing} *) 85 + 86 + val list_worktrees : proc_mgr:Git.proc_mgr -> root -> string list 87 + (** [list_worktrees ~proc_mgr root] returns paths of all worktrees. *) 88 + 89 + val list_projects : proc_mgr:Git.proc_mgr -> root -> string list 90 + (** [list_projects ~proc_mgr root] returns names of all project branches. *) 91 + 92 + val list_opam_packages : proc_mgr:Git.proc_mgr -> root -> string list 93 + (** [list_opam_packages ~proc_mgr root] returns names of all vendored opam packages 94 + (packages with opam/patches/* branches). *)
+34
unpac-opam.opam
··· 1 + # This file is generated by dune, edit dune-project instead 2 + opam-version: "2.0" 3 + synopsis: "Opam backend for unpac" 4 + description: "Opam package vendoring backend for unpac" 5 + authors: ["Anil Madhavapeddy"] 6 + license: "ISC" 7 + depends: [ 8 + "dune" {>= "3.20"} 9 + "ocaml" {>= "5.1.0"} 10 + "unpac" 11 + "opam-format" 12 + "opam-core" 13 + "opam-repository" 14 + "opam-solver" 15 + "opam-0install-cudf" 16 + "cudf" 17 + "cmdliner" {>= "1.2.0"} 18 + "odoc" {with-doc} 19 + ] 20 + build: [ 21 + ["dune" "subst"] {dev} 22 + [ 23 + "dune" 24 + "build" 25 + "-p" 26 + name 27 + "-j" 28 + jobs 29 + "@install" 30 + "@runtest" {with-test} 31 + "@doc" {with-doc} 32 + ] 33 + ] 34 + x-maintenance-intent: ["(latest)"]
-6
unpac.opam
··· 8 8 depends: [ 9 9 "dune" {>= "3.20"} 10 10 "ocaml" {>= "5.1.0"} 11 - "cmdliner" {>= "1.2.0"} 12 11 "eio_main" {>= "1.0"} 13 12 "logs" {>= "0.7.0"} 14 13 "fmt" {>= "0.9.0"} 15 14 "tomlt" 16 - "jsont" 17 - "xdge" 18 - "opam-format" 19 - "opam-core" 20 - "opam-repository" 21 15 "odoc" {with-doc} 22 16 ] 23 17 build: [