A monorepo management tool for the agentic ages

Compare changes

Choose any two refs to compare.

+589 -784
+52
.devcontainer/devcontainer.json
··· 1 + { 2 + "name": "Claude Code OCaml Sandbox", 3 + "image": "ghcr.io/avsm/claude-ocaml-devcontainer:main", 4 + "runArgs": [ 5 + "--cap-add=NET_ADMIN", 6 + "--cap-add=NET_RAW" 7 + ], 8 + "customizations": { 9 + "vscode": { 10 + "extensions": [ 11 + "anthropic.claude-code", 12 + "dbaeumer.vscode-eslint", 13 + "esbenp.prettier-vscode", 14 + "eamodio.gitlens", 15 + "ocamllabs.ocaml-platform" 16 + ], 17 + "settings": { 18 + "editor.formatOnSave": true, 19 + "editor.defaultFormatter": "esbenp.prettier-vscode", 20 + "editor.codeActionsOnSave": { 21 + "source.fixAll.eslint": "explicit" 22 + }, 23 + "terminal.integrated.defaultProfile.linux": "zsh", 24 + "terminal.integrated.profiles.linux": { 25 + "bash": { 26 + "path": "bash", 27 + "icon": "terminal-bash" 28 + }, 29 + "zsh": { 30 + "path": "zsh" 31 + } 32 + } 33 + } 34 + } 35 + }, 36 + "remoteUser": "node", 37 + "mounts": [ 38 + "source=claude-code-bashhistory-${devcontainerId},target=/commandhistory,type=volume", 39 + "source=${localEnv:HOME}/.claude,target=/home/node/.claude,type=bind", 40 + "source=${localEnv:HOME}/.ssh,target=/home/node/.ssh,type=bind,readonly", 41 + "source=${localEnv:HOME}/.gitconfig,target=/home/node/.gitconfig,type=bind,readonly" 42 + ], 43 + "containerEnv": { 44 + "NODE_OPTIONS": "--max-old-space-size=4096", 45 + "CLAUDE_CONFIG_DIR": "/home/node/.claude", 46 + "POWERLEVEL9K_DISABLE_GITSTATUS": "true" 47 + }, 48 + "workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=delegated", 49 + "workspaceFolder": "/workspace", 50 + "postCreateCommand": "sudo /usr/local/bin/init-firewall.sh", 51 + "waitFor": "postStartCommand" 52 + }
+37
.github/workflows/build.yml
··· 1 + # This is a basic workflow to help you get started with Actions 2 + 3 + name: CI 4 + 5 + # Controls when the workflow will run 6 + on: 7 + # Triggers the workflow on push or pull request events but only for the "main" branch 8 + push: 9 + branches: [ "project/unpac" ] 10 + pull_request: 11 + branches: [ "project/unpac" ] 12 + 13 + # Allows you to run this workflow manually from the Actions tab 14 + workflow_dispatch: 15 + 16 + # A workflow run is made up of one or more jobs that can run sequentially or in parallel 17 + jobs: 18 + # This workflow contains a single job called "build" 19 + build: 20 + # The type of runner that the job will run on 21 + runs-on: ubuntu-latest 22 + 23 + # Steps represent a sequence of tasks that will be executed as part of the job 24 + steps: 25 + # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 26 + - uses: actions/checkout@v4 27 + 28 + - name: Set-up OCaml 29 + uses: ocaml/setup-ocaml@v3 30 + with: 31 + ocaml-compiler: 5 32 + 33 + - name: Install dune 34 + run: opam install -y dune 35 + 36 + - name: Build unpac 37 + run: opam exec -- dune build
+2 -4
.gitignore
··· 1 - *.toml 2 - _build 3 - *.sh 4 - .unpac.log 1 + _build/ 2 + *.install
+18 -50
ARCHITECTURE.md
··· 22 22 โ”‚ โ”‚ โ””โ”€โ”€ dune 23 23 โ”‚ โ””โ”€โ”€ feature-x/ # Worktree โ†’ project/feature-x 24 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 25 โ”‚ โ””โ”€โ”€ patches/ 30 26 โ”‚ โ””โ”€โ”€ astring/ # On-demand: created for editing 31 27 โ””โ”€โ”€ cargo/ # Future: cargo backend worktrees ··· 41 37 โ”‚ 42 38 โ”œโ”€โ”€ opam/upstream/astring # Pristine upstream (files at root) 43 39 โ”œโ”€โ”€ 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 40 + โ”œโ”€โ”€ opam/patches/astring # Forked from upstream, local modifications 47 41 โ”œโ”€โ”€ opam/patches/eio 48 42 โ”‚ 49 43 โ””โ”€โ”€ cargo/... # Future ··· 63 57 โ””โ”€โ”€ astring.opam 64 58 ``` 65 59 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 60 ### `opam/patches/<pkg>` 82 61 83 - Forked from vendor branch. Same structure, may contain local modifications: 62 + Forked from upstream branch. Files at root, may contain local modifications. 63 + When merged into a project, git subtree places files under `vendor/opam/<pkg>/`: 84 64 85 65 ``` 86 66 (root) 87 - โ””โ”€โ”€ vendor/ 88 - โ””โ”€โ”€ opam/ 89 - โ””โ”€โ”€ astring/ 90 - โ”œโ”€โ”€ src/ 91 - โ”‚ โ””โ”€โ”€ astring.ml # May be patched 92 - โ”œโ”€โ”€ dune 93 - โ””โ”€โ”€ astring.opam 67 + โ”œโ”€โ”€ src/ 68 + โ”‚ โ””โ”€โ”€ astring.ml # May be patched 69 + โ”œโ”€โ”€ dune 70 + โ””โ”€โ”€ astring.opam 94 71 ``` 95 72 96 73 ### `project/<name>` (orphan branch) ··· 215 192 | Vendored packages (global) | List `opam/patches/*` branches | 216 193 | Packages in a project | List `vendor/opam/*/` directories | 217 194 | Package versions | Commit metadata on `opam/upstream/*` | 218 - | Patch status | Diff `opam/patches/*` vs `opam/vendor/*` | 195 + | Patch status | Diff `opam/patches/*` vs `opam/upstream/*` | 219 196 | Merge status | Git merge history | 220 197 221 198 ## Workflows ··· 253 230 2. If cache configured: fetch upstream โ†’ cache โ†’ project 254 231 3. If no cache: fetch upstream โ†’ project directly 255 232 4. Create `opam/upstream/astring` branch 256 - 5. Create `opam/vendor/astring` orphan branch (with vendor/opam/astring/ prefix) 257 - 6. Create `opam/patches/astring` branch (from vendor) 258 - 7. Cleanup temporary worktrees 233 + 5. Create `opam/patches/astring` branch (from upstream) 259 234 260 235 ### Add Package (by name) 261 236 ··· 300 275 2. Compare old vs new upstream SHA 301 276 3. If changed: 302 277 - Update `opam/upstream/astring` branch 303 - - Update `opam/vendor/astring` with new content 304 - 4. Note: patches branch must be rebased separately 278 + - Rebase `opam/patches/astring` onto new upstream (or report conflicts) 305 279 306 280 ### Edit Patches 307 281 ··· 310 284 ``` 311 285 312 286 1. Create worktree `opam/patches/astring/` 313 - 2. User edits files in `vendor/opam/astring/` 287 + 2. User edits files (at root of worktree) 314 288 3. User commits changes 315 289 316 290 ```bash ··· 326 300 unpac opam diff astring 327 301 ``` 328 302 329 - Shows diff between `opam/vendor/astring` and `opam/patches/astring`. 303 + Shows diff between `opam/upstream/astring` and `opam/patches/astring`. 330 304 331 305 ### Remove Package 332 306 ··· 335 309 ``` 336 310 337 311 1. Remove any existing worktrees 338 - 2. Delete `opam/upstream/astring`, `opam/vendor/astring`, `opam/patches/astring` branches 312 + 2. Delete `opam/upstream/astring`, `opam/patches/astring` branches 339 313 3. Remove remote `origin-astring` 340 314 341 315 ## Module Structure ··· 373 347 | Main 374 348 | Project of string 375 349 | Opam_upstream of string 376 - | Opam_vendor of string 377 350 | Opam_patches of string 378 351 379 352 val path : root -> kind -> Eio.Fs.dir_ty Eio.Path.t ··· 481 454 Show package information 482 455 483 456 unpac opam diff <pkg> 484 - Show local changes (patches vs vendor) 457 + Show local changes (patches vs upstream) 485 458 486 459 unpac opam edit <pkg> 487 460 Create patches worktree for editing ··· 516 489 517 490 ``` 518 491 cargo/upstream/<crate> # Pristine from crates.io 519 - cargo/vendor/<crate> # Files under vendor/cargo/<crate>/ 520 - cargo/patches/<crate> # Local modifications 492 + cargo/patches/<crate> # Local modifications on top of upstream 521 493 ``` 522 494 523 495 With corresponding worktree paths: ··· 525 497 ``` 526 498 my-project/ 527 499 โ”œโ”€โ”€ cargo/ 528 - โ”‚ โ”œโ”€โ”€ upstream/ 529 - โ”‚ โ”‚ โ””โ”€โ”€ serde/ 530 - โ”‚ โ”œโ”€โ”€ vendor/ 531 - โ”‚ โ”‚ โ””โ”€โ”€ serde/ 532 500 โ”‚ โ””โ”€โ”€ patches/ 533 - โ”‚ โ””โ”€โ”€ serde/ 501 + โ”‚ โ””โ”€โ”€ serde/ # On-demand: created for editing 534 502 โ””โ”€โ”€ project/ 535 503 โ””โ”€โ”€ myapp/ 536 504 โ””โ”€โ”€ vendor/ 537 505 โ”œโ”€โ”€ opam/ 538 - โ”‚ โ””โ”€โ”€ eio/ 506 + โ”‚ โ””โ”€โ”€ eio/ # Merged via git subtree 539 507 โ””โ”€โ”€ cargo/ 540 - โ””โ”€โ”€ serde/ 508 + โ””โ”€โ”€ serde/ # Merged via git subtree 541 509 ```
+5 -6
CLI.md
··· 121 121 unpac opam add https://github.com/dbuenzli/cmdliner.git --name cmdliner 122 122 ``` 123 123 124 - This creates three branches: 124 + This creates two branches: 125 125 - `opam/upstream/<pkg>` - pristine upstream code 126 - - `opam/vendor/<pkg>` - code with `vendor/opam/<pkg>/` path prefix 127 - - `opam/patches/<pkg>` - your local modifications (initially same as vendor) 126 + - `opam/patches/<pkg>` - your local modifications (initially same as upstream) 128 127 129 128 #### `unpac opam list` 130 129 ··· 169 168 170 169 This: 171 170 1. Fetches latest from upstream 172 - 2. Updates `opam/upstream/<pkg>` and `opam/vendor/<pkg>` 173 - 3. Prints instructions for rebasing patches if needed 171 + 2. Updates `opam/upstream/<pkg>` 172 + 3. Rebases `opam/patches/<pkg>` onto the new upstream (or prints conflict instructions) 174 173 175 174 #### `unpac opam rebase <package>` 176 175 177 - Rebase your patches onto the updated vendor branch. 176 + Rebase your patches onto the updated upstream branch. 178 177 179 178 ```bash 180 179 unpac opam rebase cmdliner
+120 -160
bin/main.ml
··· 145 145 git/ # Git repository worktrees 146 146 project/ # Project worktrees"; 147 147 `P "The workspace uses git worktrees to maintain isolated views of \ 148 - vendored dependencies. Each vendored item has three branches:"; 148 + vendored dependencies. Each vendored item has two branches:"; 149 149 `I ("upstream/*", "Tracks original repository state"); 150 - `I ("vendor/*", "Clean snapshot for merging"); 151 - `I ("patches/*", "Local modifications"); 150 + `I ("patches/*", "Local modifications on top of upstream"); 152 151 `S Manpage.s_examples; 153 152 `P "Create a new workspace:"; 154 153 `Pre " unpac init my-project ··· 262 261 let doc = "Override the vendor library name (defaults to project name)." in 263 262 Arg.(value & opt (some string) None & info ["name"; "n"] ~docv:"NAME" ~doc) 264 263 in 265 - let run () project backend_str vendor_name = 264 + let prefix_arg = 265 + let doc = "Subdirectory path within the project to extract (e.g., 'src/mylib')." in 266 + Arg.(required & opt (some string) None & info ["prefix"; "p"] ~docv:"PATH" ~doc) 267 + in 268 + let run () project backend_str vendor_name prefix = 266 269 with_root @@ fun ~env:_ ~fs:_ ~proc_mgr ~root -> 267 270 (* Parse backend *) 268 271 let backend = match Unpac.Promote.backend_of_string backend_str with ··· 273 276 in 274 277 with_audit ~proc_mgr ~root ~operation_type:Unpac.Audit.Project_promote 275 278 ~args:( 276 - [project; "--backend"; backend_str] @ 279 + [project; "--backend"; backend_str; "--prefix"; prefix] @ 277 280 (match vendor_name with Some n -> ["--name"; n] | None -> []) 278 281 ) @@ fun _ctx -> 279 - match Unpac.Promote.promote ~proc_mgr ~root ~project ~backend ~vendor_name with 280 - | Unpac.Promote.Promoted { name; backend; original_commits; filtered_commits } -> 282 + match Unpac.Promote.promote ~proc_mgr ~root ~project ~backend ~vendor_name ~prefix with 283 + | Unpac.Promote.Promoted { name; backend; source_prefix } -> 281 284 Format.printf "Promoted %s as %s vendor@." project (Unpac.Promote.backend_to_string backend); 282 - Format.printf "@.Filtered history: %d โ†’ %d commits (removed vendor/ directory)@." 283 - original_commits filtered_commits; 285 + Format.printf "@.Extracted from prefix: %s@." source_prefix; 284 286 Format.printf "@.Created branches:@."; 285 287 Format.printf " %s@." (Unpac.Promote.upstream_branch backend name); 286 - Format.printf " %s@." (Unpac.Promote.vendor_branch backend name); 287 288 Format.printf " %s@." (Unpac.Promote.patches_branch backend name); 288 289 Format.printf "@.%s can now be merged into other projects:@." name; 289 290 (match backend with ··· 302 303 exit 1 303 304 in 304 305 let info = Cmd.info "promote" ~doc ~man in 305 - Cmd.v info Term.(const run $ logging_term $ name_arg $ backend_arg $ vendor_name_arg) 306 + Cmd.v info Term.(const run $ logging_term $ name_arg $ backend_arg $ vendor_name_arg $ prefix_arg) 306 307 307 308 (* Project set-remote command *) 308 309 let project_set_remote_cmd = ··· 465 466 let doc = "Vendor backend type: opam or git." in 466 467 Arg.(required & opt (some string) None & info ["backend"; "b"] ~docv:"BACKEND" ~doc) 467 468 in 468 - let from_patches_arg = 469 - let doc = "Export from patches/* branch (includes local modifications) \ 470 - instead of vendor/* branch (pristine upstream)." in 471 - Arg.(value & flag & info ["from-patches"; "p"] ~doc) 472 - in 473 - let run () name backend_str from_patches = 469 + let run () name backend_str = 474 470 with_root @@ fun ~env:_ ~fs:_ ~proc_mgr ~root -> 475 471 let backend = match Unpac.Promote.backend_of_string backend_str with 476 472 | Some b -> b ··· 478 474 Format.eprintf "Error: Unknown backend '%s'. Use 'opam' or 'git'.@." backend_str; 479 475 exit 1 480 476 in 481 - match Unpac.Promote.export ~proc_mgr ~root ~name ~backend ~from_patches with 477 + match Unpac.Promote.export ~proc_mgr ~root ~name ~backend with 482 478 | Unpac.Promote.Exported { name; backend; source_branch; export_branch; commits } -> 483 479 Format.printf "Exported %s (%s backend)@." name (Unpac.Promote.backend_to_string backend); 484 480 Format.printf " Source: %s@." source_branch; 485 481 Format.printf " Export: %s (%d commits)@." export_branch commits; 486 - Format.printf "@.Files moved from vendor/%s/%s/ to repository root.@." 487 - (Unpac.Promote.backend_to_string backend) name; 488 482 Format.printf "@.Next steps:@."; 489 483 Format.printf " unpac export-set-remote %s <url>@." name; 490 484 Format.printf " unpac export-push %s --backend %s@." name backend_str 491 485 | Unpac.Promote.Not_vendored name -> 492 - Format.eprintf "Error: No vendor branch found for '%s'.@." name; 486 + Format.eprintf "Error: No patches branch found for '%s'.@." name; 493 487 Format.eprintf "Check available packages with: unpac opam list / unpac git list@."; 494 488 exit 1 495 489 | Unpac.Promote.Already_exported name -> ··· 502 496 exit 1 503 497 in 504 498 let info = Cmd.info "export" ~doc ~man in 505 - Cmd.v info Term.(const run $ logging_term $ name_arg $ backend_arg $ from_patches_arg) 499 + Cmd.v info Term.(const run $ logging_term $ name_arg $ backend_arg) 506 500 507 501 (* Export set-remote command *) 508 502 let export_set_remote_cmd = ··· 980 974 981 975 (* Opam edit command *) 982 976 let opam_edit_cmd = 983 - let doc = "Open a package's patches worktree for editing. \ 984 - Also creates a vendor worktree for reference." in 977 + let doc = "Open a package's patches worktree for editing." in 985 978 let pkg_arg = 986 979 let doc = "Package name to edit." in 987 980 Arg.(required & pos 0 (some string) None & info [] ~docv:"PACKAGE" ~doc) ··· 994 987 Format.eprintf "Package '%s' is not vendored@." pkg; 995 988 exit 1 996 989 end; 997 - (* Ensure both patches and vendor worktrees exist *) 990 + (* Create patches worktree for editing *) 998 991 Unpac.Worktree.ensure ~proc_mgr root (Unpac.Worktree.Opam_patches pkg); 999 - Unpac.Worktree.ensure ~proc_mgr root (Unpac.Worktree.Opam_vendor pkg); 1000 992 let patches_path = snd (Unpac.Worktree.path root (Unpac.Worktree.Opam_patches pkg)) in 1001 - let vendor_path = snd (Unpac.Worktree.path root (Unpac.Worktree.Opam_vendor pkg)) in 993 + let upstream_branch = Unpac_opam.Opam.upstream_branch pkg in 1002 994 Format.printf "Editing %s@." pkg; 1003 995 Format.printf "@."; 1004 - Format.printf "Worktrees created:@."; 1005 - Format.printf " patches: %s (make changes here)@." patches_path; 1006 - Format.printf " vendor: %s (original for reference)@." vendor_path; 996 + Format.printf "Worktree created:@."; 997 + Format.printf " patches: %s@." patches_path; 998 + Format.printf "@."; 999 + Format.printf "To compare with upstream:@."; 1000 + Format.printf " git diff %s..HEAD (from patches worktree)@." upstream_branch; 1007 1001 Format.printf "@."; 1008 - Format.printf "Make your changes in the patches worktree, then:@."; 1002 + Format.printf "Make your changes, then:@."; 1009 1003 Format.printf " cd %s@." patches_path; 1010 1004 Format.printf " git add -A && git commit -m 'your message'@."; 1011 1005 Format.printf "@."; ··· 1016 1010 1017 1011 (* Opam done command *) 1018 1012 let opam_done_cmd = 1019 - let doc = "Close a package's patches and vendor worktrees after editing." in 1013 + let doc = "Close a package's patches worktree after editing." in 1020 1014 let pkg_arg = 1021 1015 let doc = "Package name." in 1022 1016 Arg.(required & pos 0 (some string) None & info [] ~docv:"PACKAGE" ~doc) ··· 1024 1018 let run () pkg = 1025 1019 with_root @@ fun ~env:_ ~fs:_ ~proc_mgr ~root -> 1026 1020 let patches_kind = Unpac.Worktree.Opam_patches pkg in 1027 - let vendor_kind = Unpac.Worktree.Opam_vendor pkg in 1028 1021 if not (Unpac.Worktree.exists root patches_kind) then begin 1029 1022 Format.eprintf "No editing session for '%s'@." pkg; 1030 1023 exit 1 ··· 1037 1030 Format.eprintf "Commit or discard them before closing.@."; 1038 1031 exit 1 1039 1032 end; 1040 - (* Remove both worktrees *) 1033 + (* Remove patches worktree *) 1041 1034 Unpac.Worktree.remove ~proc_mgr root patches_kind; 1042 - if Unpac.Worktree.exists root vendor_kind then 1043 - Unpac.Worktree.remove ~proc_mgr root vendor_kind; 1044 1035 Format.printf "Closed editing session for %s@." pkg; 1045 1036 Format.printf "@.Next steps:@."; 1046 1037 Format.printf " unpac opam diff %s # view your changes@." pkg; ··· 1137 1128 1138 1129 let merge_one ~project pkg = 1139 1130 let patches_branch = Unpac_opam.Opam.patches_branch pkg in 1140 - match Unpac.Backend.merge_to_project ~proc_mgr ~root ~project ~patches_branch with 1141 - | Ok () -> 1142 - Format.printf "Merged %s@." pkg; 1131 + let prefix = Unpac_opam.Opam.vendor_path pkg in 1132 + match Unpac.Backend.merge_to_project ~proc_mgr ~root ~project ~patches_branch ~prefix with 1133 + | Unpac.Git.Subtree_ok -> 1134 + Format.printf "Merged %s to %s@." pkg prefix; 1143 1135 true 1144 - | Error (`Conflict files) -> 1136 + | Unpac.Git.Subtree_conflict files -> 1145 1137 Format.eprintf "Merge conflict in %s:@." pkg; 1146 1138 List.iter (Format.eprintf " %s@.") files; 1147 1139 false ··· 1264 1256 | None -> ()); 1265 1257 (* Get branch SHAs *) 1266 1258 let upstream = Unpac_opam.Opam.upstream_branch pkg in 1267 - let vendor = Unpac_opam.Opam.vendor_branch pkg in 1268 1259 let patches = Unpac_opam.Opam.patches_branch pkg in 1269 1260 (match Unpac.Git.rev_parse ~proc_mgr ~cwd:git upstream with 1270 1261 | Some sha -> Format.printf "Upstream: %s@." (String.sub sha 0 7) 1271 - | None -> ()); 1272 - (match Unpac.Git.rev_parse ~proc_mgr ~cwd:git vendor with 1273 - | Some sha -> Format.printf "Vendor: %s@." (String.sub sha 0 7) 1274 1262 | None -> ()); 1275 1263 (match Unpac.Git.rev_parse ~proc_mgr ~cwd:git patches with 1276 1264 | Some sha -> Format.printf "Patches: %s@." (String.sub sha 0 7) 1277 1265 | None -> ()); 1278 - (* Count commits ahead *) 1266 + (* Count commits ahead of upstream *) 1279 1267 let log_output = Unpac.Git.run_exn ~proc_mgr ~cwd:git 1280 - ["log"; "--oneline"; vendor ^ ".." ^ patches] in 1268 + ["log"; "--oneline"; upstream ^ ".." ^ patches] in 1281 1269 let commits = List.length (String.split_on_char '\n' log_output |> 1282 1270 List.filter (fun s -> String.trim s <> "")) in 1283 1271 Format.printf "Local commits: %d@." commits; ··· 1291 1279 1292 1280 (* Opam diff command *) 1293 1281 let opam_diff_cmd = 1294 - let doc = "Show diff between vendor and patches branches." in 1282 + let doc = "Show diff between upstream and patches branches." in 1295 1283 let pkg_arg = 1296 1284 let doc = "Package name." in 1297 1285 Arg.(required & pos 0 (some string) None & info [] ~docv:"PACKAGE" ~doc) ··· 1305 1293 Format.eprintf "Package '%s' is not vendored@." pkg; 1306 1294 exit 1 1307 1295 end; 1308 - let vendor = Unpac_opam.Opam.vendor_branch pkg in 1296 + let upstream = Unpac_opam.Opam.upstream_branch pkg in 1309 1297 let patches = Unpac_opam.Opam.patches_branch pkg in 1310 1298 let diff = Unpac.Git.run_exn ~proc_mgr ~cwd:git 1311 - ["diff"; vendor; patches] in 1299 + ["diff"; upstream; patches] in 1312 1300 if String.trim diff = "" then begin 1313 1301 Format.printf "No local changes@."; 1314 1302 Format.printf "@.Hint: unpac opam edit %s # to make changes@." pkg ··· 1336 1324 Format.eprintf "Package '%s' is not vendored@." pkg; 1337 1325 exit 1 1338 1326 end; 1339 - (* Remove worktrees if exist *) 1340 - (try Unpac.Worktree.remove_force ~proc_mgr root (Unpac.Worktree.Opam_upstream pkg) with _ -> ()); 1341 - (try Unpac.Worktree.remove_force ~proc_mgr root (Unpac.Worktree.Opam_vendor pkg) with _ -> ()); 1327 + (* Remove patches worktree if exists (from editing) *) 1342 1328 (try Unpac.Worktree.remove_force ~proc_mgr root (Unpac.Worktree.Opam_patches pkg) with _ -> ()); 1343 1329 (* Delete branches *) 1344 1330 let upstream = Unpac_opam.Opam.upstream_branch pkg in 1345 - let vendor = Unpac_opam.Opam.vendor_branch pkg in 1346 1331 let patches = Unpac_opam.Opam.patches_branch pkg in 1347 1332 (try Unpac.Git.run_exn ~proc_mgr ~cwd:git ["branch"; "-D"; upstream] |> ignore with _ -> ()); 1348 - (try Unpac.Git.run_exn ~proc_mgr ~cwd:git ["branch"; "-D"; vendor] |> ignore with _ -> ()); 1349 1333 (try Unpac.Git.run_exn ~proc_mgr ~cwd:git ["branch"; "-D"; patches] |> ignore with _ -> ()); 1350 1334 (* Remove remote *) 1351 1335 let remote = "origin-" ^ pkg in ··· 1367 1351 `I ("Internal packages", "Creating packages that will never be published"); 1368 1352 `I ("Agent-created packages", "AI agents can create new dependencies on-demand"); 1369 1353 `P "The package is created with a minimal scaffold including dune-project \ 1370 - and a .opam file. It uses the standard three-tier branch model but \ 1354 + and a .opam file. It uses the standard two-tier branch model but \ 1371 1355 with no upstream branch (url='local' in config)."; 1372 1356 `S "PACKAGE STRUCTURE"; 1373 1357 `P "The created package will have:"; ··· 1425 1409 exit 1 1426 1410 end; 1427 1411 1428 - (* Create an orphan branch for vendor *) 1429 - let vendor_branch = Unpac_opam.Opam.vendor_branch name in 1430 - let patches_branch = Unpac_opam.Opam.patches_branch name in 1431 - let vendor_path = "vendor/opam/" ^ name in 1412 + (* Create an orphan branch for upstream *) 1413 + let upstream_branch = Unpac_opam.Opam.upstream_branch name in 1414 + let ml_name = String.map (fun c -> if c = '-' then '_' else c) name in 1432 1415 1433 1416 (* Create orphan branch with initial content *) 1434 - Unpac.Git.checkout_orphan ~proc_mgr ~cwd:git vendor_branch; 1417 + Unpac.Git.checkout_orphan ~proc_mgr ~cwd:git upstream_branch; 1435 1418 1436 1419 (* Remove any existing index content *) 1437 1420 Unpac.Git.rm_cached_rf ~proc_mgr ~cwd:git; 1438 1421 1439 - (* Create scaffold files in a temporary worktree *) 1440 - let wt_path = Unpac.Worktree.path root (Unpac.Worktree.Opam_vendor name) in 1441 - Unpac.Worktree.ensure ~proc_mgr root (Unpac.Worktree.Opam_vendor name); 1422 + (* Create a temporary worktree for the patches branch to add files *) 1423 + let patches_kind = Unpac.Worktree.Opam_patches name in 1424 + Unpac.Worktree.ensure ~proc_mgr root patches_kind; 1425 + let wt_path = Unpac.Worktree.path root patches_kind in 1442 1426 1443 - (* Create directory structure *) 1444 - let pkg_dir = Eio.Path.(wt_path / vendor_path) in 1445 - let lib_dir = Eio.Path.(pkg_dir / "lib") in 1427 + (* Create directory structure - files at root, not under vendor/ *) 1428 + let lib_dir = Eio.Path.(wt_path / "lib") in 1446 1429 Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 lib_dir; 1447 1430 1448 1431 (* Create dune-project *) ··· 1459 1442 (ocaml (>= 4.14)))) 1460 1443 |} name name synopsis in 1461 1444 Eio.Path.save ~create:(`Or_truncate 0o644) 1462 - Eio.Path.(pkg_dir / "dune-project") dune_project; 1445 + Eio.Path.(wt_path / "dune-project") dune_project; 1463 1446 1464 1447 (* Create lib/dune *) 1465 1448 let lib_dune = Printf.sprintf {|(library 1466 1449 (name %s) 1467 1450 (public_name %s)) 1468 - |} (String.map (fun c -> if c = '-' then '_' else c) name) name in 1451 + |} ml_name name in 1469 1452 Eio.Path.save ~create:(`Or_truncate 0o644) 1470 1453 Eio.Path.(lib_dir / "dune") lib_dune; 1471 1454 ··· 1475 1458 (** This module was created by [unpac opam init]. 1476 1459 Add your implementation here. *) 1477 1460 |} name in 1478 - let ml_name = String.map (fun c -> if c = '-' then '_' else c) name in 1479 1461 Eio.Path.save ~create:(`Or_truncate 0o644) 1480 1462 Eio.Path.(lib_dir / (ml_name ^ ".ml")) ml_file; 1481 1463 ··· 1496 1478 (* Get the commit SHA *) 1497 1479 let sha = Unpac.Git.current_head ~proc_mgr ~cwd:wt_path in 1498 1480 1499 - (* Create patches branch from vendor *) 1500 - Unpac.Git.branch_create ~proc_mgr ~cwd:git 1501 - ~name:patches_branch ~start_point:vendor_branch; 1481 + (* Now set up upstream branch pointing to same commit *) 1482 + Unpac.Git.run_exn ~proc_mgr ~cwd:git 1483 + ["update-ref"; "refs/heads/" ^ upstream_branch; sha] |> ignore; 1502 1484 1503 1485 (* Cleanup worktree *) 1504 - Unpac.Worktree.remove ~proc_mgr root (Unpac.Worktree.Opam_vendor name); 1486 + Unpac.Worktree.remove ~proc_mgr root patches_kind; 1505 1487 1506 1488 (* Switch back to main *) 1507 1489 Unpac.Git.checkout ~proc_mgr ~cwd:git "main"; ··· 1514 1496 save_config ~proc_mgr root config (Printf.sprintf "Add local package %s" name); 1515 1497 1516 1498 Format.printf "Created local package %s (%s)@." name (String.sub sha 0 7); 1517 - Format.printf "@.Package structure:@."; 1518 - Format.printf " %s/@." vendor_path; 1519 - Format.printf " dune-project@."; 1520 - Format.printf " lib/dune@."; 1521 - Format.printf " lib/%s.ml@." ml_name; 1522 - Format.printf " lib/%s.mli@." ml_name; 1499 + Format.printf "@.Package structure (in patches branch at root):@."; 1500 + Format.printf " dune-project@."; 1501 + Format.printf " lib/dune@."; 1502 + Format.printf " lib/%s.ml@." ml_name; 1503 + Format.printf " lib/%s.mli@." ml_name; 1504 + Format.printf "@.When merged to a project, files will be at vendor/opam/%s/@." name; 1523 1505 Format.printf "@.Next steps:@."; 1524 1506 Format.printf " unpac opam edit %s # add code to the package@." name; 1525 1507 Format.printf " unpac opam merge %s <project> # use in a project@." name ··· 1538 1520 become a shared library"); 1539 1521 `I ("Needs reuse", "A project that other projects want to depend on"); 1540 1522 `I ("Agent refactoring", "AI agents can extract common code into libraries"); 1541 - `P "The project's content is copied to create opam/vendor/<name> and \ 1523 + `P "The project's content is copied to create opam/upstream/<name> and \ 1542 1524 opam/patches/<name> branches. The original project remains unchanged \ 1543 1525 and can be deleted if no longer needed."; 1544 1526 `S "REQUIREMENTS"; ··· 1590 1572 exit 1 1591 1573 end; 1592 1574 1593 - let vendor_branch = Unpac_opam.Opam.vendor_branch pkg_name in 1594 - let patches_branch = Unpac_opam.Opam.patches_branch pkg_name in 1595 - let vendor_path = "vendor/opam/" ^ pkg_name in 1575 + let upstream_branch = Unpac_opam.Opam.upstream_branch pkg_name in 1596 1576 1597 - (* Create orphan branch for vendor *) 1598 - Unpac.Git.checkout_orphan ~proc_mgr ~cwd:git vendor_branch; 1577 + (* Create orphan branch for upstream *) 1578 + Unpac.Git.checkout_orphan ~proc_mgr ~cwd:git upstream_branch; 1599 1579 Unpac.Git.rm_cached_rf ~proc_mgr ~cwd:git; 1600 1580 1601 - (* Create vendor worktree *) 1602 - Unpac.Worktree.ensure ~proc_mgr root (Unpac.Worktree.Opam_vendor pkg_name); 1603 - let vendor_wt = Unpac.Worktree.path root (Unpac.Worktree.Opam_vendor pkg_name) in 1581 + (* Create patches worktree *) 1582 + let patches_kind = Unpac.Worktree.Opam_patches pkg_name in 1583 + Unpac.Worktree.ensure ~proc_mgr root patches_kind; 1584 + let patches_wt = Unpac.Worktree.path root patches_kind in 1604 1585 1605 1586 (* Get project worktree or create temporary one *) 1606 1587 let project_wt = Unpac.Worktree.path root (Unpac.Worktree.Project project) in ··· 1608 1589 if created_project_wt then 1609 1590 Unpac.Worktree.ensure ~proc_mgr root (Unpac.Worktree.Project project); 1610 1591 1611 - (* Create target directory *) 1612 - let pkg_dir = Eio.Path.(vendor_wt / vendor_path) in 1613 - Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 pkg_dir; 1614 - 1615 - (* Copy project content to vendor path *) 1592 + (* Copy project content to patches worktree (at root, not under vendor/) *) 1616 1593 let rec copy_dir src dst = 1617 1594 Eio.Path.read_dir src |> List.iter (fun name -> 1618 1595 if name <> ".git" then begin ··· 1628 1605 end 1629 1606 ) 1630 1607 in 1631 - copy_dir project_wt pkg_dir; 1608 + copy_dir project_wt patches_wt; 1632 1609 1633 1610 (* Commit *) 1634 - Unpac.Git.add_all ~proc_mgr ~cwd:vendor_wt; 1635 - Unpac.Git.commit ~proc_mgr ~cwd:vendor_wt 1611 + Unpac.Git.add_all ~proc_mgr ~cwd:patches_wt; 1612 + Unpac.Git.commit ~proc_mgr ~cwd:patches_wt 1636 1613 ~message:(Printf.sprintf "Promote project %s to package %s" project pkg_name); 1637 1614 1638 1615 (* Get SHA *) 1639 - let sha = Unpac.Git.current_head ~proc_mgr ~cwd:vendor_wt in 1616 + let sha = Unpac.Git.current_head ~proc_mgr ~cwd:patches_wt in 1640 1617 1641 - (* Create patches branch from vendor *) 1642 - Unpac.Git.branch_create ~proc_mgr ~cwd:git 1643 - ~name:patches_branch ~start_point:vendor_branch; 1618 + (* Set upstream branch to same commit *) 1619 + Unpac.Git.run_exn ~proc_mgr ~cwd:git 1620 + ["update-ref"; "refs/heads/" ^ upstream_branch; sha] |> ignore; 1644 1621 1645 1622 (* Cleanup *) 1646 - Unpac.Worktree.remove ~proc_mgr root (Unpac.Worktree.Opam_vendor pkg_name); 1623 + Unpac.Worktree.remove ~proc_mgr root patches_kind; 1647 1624 if created_project_wt then 1648 1625 Unpac.Worktree.remove ~proc_mgr root (Unpac.Worktree.Project project); 1649 1626 ··· 1659 1636 1660 1637 Format.printf "Promoted project %s to package %s (%s)@." project pkg_name (String.sub sha 0 7); 1661 1638 Format.printf "@.The package is now available as a vendored dependency.@."; 1639 + Format.printf "@.When merged to a project, files will be at vendor/opam/%s/@." pkg_name; 1662 1640 Format.printf "@.Next steps:@."; 1663 1641 Format.printf " unpac opam merge %s <other-project> # use in another project@." pkg_name; 1664 1642 Format.printf " unpac opam edit %s # make changes@." pkg_name; ··· 1674 1652 let man = [ 1675 1653 `S Manpage.s_description; 1676 1654 `P "Vendor OCaml packages from opam repositories or create new local packages. \ 1677 - Uses a three-tier branch model for conflict-free vendoring:"; 1655 + Uses a two-tier branch model for vendoring:"; 1678 1656 `I ("opam/upstream/<pkg>", "Tracks the original repository state (empty for local packages)"); 1679 - `I ("opam/vendor/<pkg>", "Clean snapshot used as merge base"); 1680 - `I ("opam/patches/<pkg>", "Local modifications on top of vendor"); 1657 + `I ("opam/patches/<pkg>", "Local modifications on top of upstream"); 1681 1658 `S "PACKAGE SOURCES"; 1682 1659 `P "Packages can come from three sources:"; 1683 1660 `I ("External (unpac opam add)", "Vendor from opam repository or git URL. \ ··· 1778 1755 let doc = "Git branch or tag to vendor (default: remote default)." in 1779 1756 Arg.(value & opt (some string) None & info ["b"; "branch"] ~docv:"REF" ~doc) 1780 1757 in 1781 - let subdir_arg = 1782 - let doc = "Extract only this subdirectory from the repository." in 1783 - Arg.(value & opt (some string) None & info ["subdir"] ~docv:"PATH" ~doc) 1784 - in 1785 1758 let cache_arg = 1786 1759 let doc = "Path to vendor cache." in 1787 1760 Arg.(value & opt (some string) None & info ["cache"] ~docv:"PATH" ~doc) 1788 1761 in 1789 - let run () url name_opt branch_opt subdir_opt cli_cache = 1762 + let run () url name_opt branch_opt cli_cache = 1790 1763 with_root @@ fun ~env:_ ~fs ~proc_mgr ~root -> 1791 1764 let config = load_config root in 1792 1765 let cache = resolve_cache ~proc_mgr ~fs ~config ~cli_cache in ··· 1803 1776 in 1804 1777 1805 1778 let info : Unpac.Git_backend.repo_info = { 1806 - name; url; branch = branch_opt; subdir = subdir_opt; 1779 + name; url; branch = branch_opt; 1807 1780 } in 1808 1781 1809 1782 match Unpac.Git_backend.add_repo ~proc_mgr ~root ?cache info with ··· 1811 1784 Format.printf "Added %s (%s)@." repo_name (String.sub sha 0 7); 1812 1785 let repo_config : Unpac.Config.git_repo_config = { 1813 1786 git_name = name; git_url = url; 1814 - git_branch = branch_opt; git_subdir = subdir_opt; 1787 + git_branch = branch_opt; 1815 1788 } in 1816 1789 let config' = Unpac.Config.add_git_repo config repo_config in 1817 1790 save_config ~proc_mgr root config' (Printf.sprintf "Add git repo %s" name); ··· 1825 1798 exit 1 1826 1799 in 1827 1800 let info = Cmd.info "add" ~doc in 1828 - Cmd.v info Term.(const run $ logging_term $ url_arg $ name_arg $ branch_arg $ subdir_arg $ cache_arg) 1801 + Cmd.v info Term.(const run $ logging_term $ url_arg $ name_arg $ branch_arg $ cache_arg) 1829 1802 1830 1803 (* Git list command *) 1831 1804 let git_list_cmd = ··· 1880 1853 with_root @@ fun ~env:_ ~fs:_ ~proc_mgr ~root -> 1881 1854 with_audit ~proc_mgr ~root ~operation_type:Unpac.Audit.Git_merge ~args:[name; project] @@ fun _ctx -> 1882 1855 let patches_branch = Unpac.Git_backend.patches_branch name in 1883 - match Unpac.Backend.merge_to_project ~proc_mgr ~root ~project ~patches_branch with 1884 - | Ok () -> 1885 - Format.printf "Merged %s into %s@." name project; 1856 + let prefix = Unpac.Git_backend.vendor_path name in 1857 + match Unpac.Backend.merge_to_project ~proc_mgr ~root ~project ~patches_branch ~prefix with 1858 + | Unpac.Git.Subtree_ok -> 1859 + Format.printf "Merged %s into %s at %s@." name project prefix; 1886 1860 Format.printf "@.Next: Build your project in project/%s@." project 1887 - | Error (`Conflict files) -> 1861 + | Unpac.Git.Subtree_conflict files -> 1888 1862 Format.eprintf "Merge conflict in %s:@." name; 1889 1863 List.iter (Format.eprintf " %s@.") files; 1890 1864 Format.eprintf "Resolve conflicts in project/%s and commit.@." project; ··· 1913 1887 Format.printf "Repository: %s@." name; 1914 1888 (match url with Some u -> Format.printf "URL: %s@." u | None -> ()); 1915 1889 let upstream = Unpac.Git_backend.upstream_branch name in 1916 - let vendor = Unpac.Git_backend.vendor_branch name in 1917 1890 let patches = Unpac.Git_backend.patches_branch name in 1918 1891 (match Unpac.Git.rev_parse ~proc_mgr ~cwd:git upstream with 1919 1892 | Some sha -> Format.printf "Upstream: %s@." (String.sub sha 0 7) | None -> ()); 1920 - (match Unpac.Git.rev_parse ~proc_mgr ~cwd:git vendor with 1921 - | Some sha -> Format.printf "Vendor: %s@." (String.sub sha 0 7) | None -> ()); 1922 1893 (match Unpac.Git.rev_parse ~proc_mgr ~cwd:git patches with 1923 1894 | Some sha -> Format.printf "Patches: %s@." (String.sub sha 0 7) | None -> ()); 1924 1895 let log_output = Unpac.Git.run_exn ~proc_mgr ~cwd:git 1925 - ["log"; "--oneline"; vendor ^ ".." ^ patches] in 1896 + ["log"; "--oneline"; upstream ^ ".." ^ patches] in 1926 1897 let commits = List.length (String.split_on_char '\n' log_output |> 1927 1898 List.filter (fun s -> String.trim s <> "")) in 1928 1899 Format.printf "Local commits: %d@." commits ··· 1932 1903 1933 1904 (* Git diff command *) 1934 1905 let git_diff_cmd = 1935 - let doc = "Show diff between vendor and patches branches." in 1906 + let doc = "Show diff between upstream and patches branches." in 1936 1907 let name_arg = 1937 1908 let doc = "Repository name." in 1938 1909 Arg.(required & pos 0 (some string) None & info [] ~docv:"NAME" ~doc) ··· 1945 1916 Format.eprintf "Repository '%s' is not vendored@." name; 1946 1917 exit 1 1947 1918 end; 1948 - let vendor = Unpac.Git_backend.vendor_branch name in 1919 + let upstream = Unpac.Git_backend.upstream_branch name in 1949 1920 let patches = Unpac.Git_backend.patches_branch name in 1950 - let diff = Unpac.Git.run_exn ~proc_mgr ~cwd:git ["diff"; vendor; patches] in 1921 + let diff = Unpac.Git.run_exn ~proc_mgr ~cwd:git ["diff"; upstream; patches] in 1951 1922 if String.trim diff = "" then 1952 1923 Format.printf "No local changes@." 1953 1924 else ··· 1971 1942 exit 1 1972 1943 end; 1973 1944 Unpac.Worktree.ensure ~proc_mgr root (Unpac.Worktree.Git_patches name); 1974 - Unpac.Worktree.ensure ~proc_mgr root (Unpac.Worktree.Git_vendor name); 1975 1945 let patches_path = snd (Unpac.Worktree.path root (Unpac.Worktree.Git_patches name)) in 1976 - let vendor_path = snd (Unpac.Worktree.path root (Unpac.Worktree.Git_vendor name)) in 1946 + let upstream_branch = Unpac.Git_backend.upstream_branch name in 1977 1947 Format.printf "Editing %s@.@." name; 1978 - Format.printf "Worktrees created:@."; 1979 - Format.printf " patches: %s (make changes here)@." patches_path; 1980 - Format.printf " vendor: %s (original for reference)@." vendor_path; 1948 + Format.printf "Worktree created:@."; 1949 + Format.printf " patches: %s@." patches_path; 1950 + Format.printf "@.To compare with upstream:@."; 1951 + Format.printf " git diff %s..HEAD (from patches worktree)@." upstream_branch; 1981 1952 Format.printf "@.When done: unpac git done %s@." name 1982 1953 in 1983 1954 let info = Cmd.info "edit" ~doc in ··· 1985 1956 1986 1957 (* Git done command *) 1987 1958 let git_done_cmd = 1988 - let doc = "Close a repository's patches and vendor worktrees." in 1959 + let doc = "Close a repository's patches worktree." in 1989 1960 let name_arg = 1990 1961 let doc = "Repository name." in 1991 1962 Arg.(required & pos 0 (some string) None & info [] ~docv:"NAME" ~doc) ··· 1993 1964 let run () name = 1994 1965 with_root @@ fun ~env:_ ~fs:_ ~proc_mgr ~root -> 1995 1966 let patches_kind = Unpac.Worktree.Git_patches name in 1996 - let vendor_kind = Unpac.Worktree.Git_vendor name in 1997 1967 if not (Unpac.Worktree.exists root patches_kind) then begin 1998 1968 Format.eprintf "No editing session for '%s'@." name; 1999 1969 exit 1 ··· 2006 1976 exit 1 2007 1977 end; 2008 1978 Unpac.Worktree.remove ~proc_mgr root patches_kind; 2009 - if Unpac.Worktree.exists root vendor_kind then 2010 - Unpac.Worktree.remove ~proc_mgr root vendor_kind; 2011 1979 Format.printf "Closed editing session for %s@." name 2012 1980 in 2013 1981 let info = Cmd.info "done" ~doc in ··· 2041 2009 let doc = "Git repository vendoring commands." in 2042 2010 let man = [ 2043 2011 `S Manpage.s_description; 2044 - `P "Vendor arbitrary git repositories with full history preservation. \ 2045 - Uses the three-tier branch model:"; 2012 + `P "Vendor arbitrary git repositories. Uses a two-tier branch model:"; 2046 2013 `I ("git/upstream/<name>", "Tracks the original repository state"); 2047 - `I ("git/vendor/<name>", "Clean snapshot used as merge base"); 2048 - `I ("git/patches/<name>", "Local modifications on top of vendor"); 2049 - `S "REQUIREMENTS"; 2050 - `P "git-filter-repo must be installed and in PATH. Install with:"; 2051 - `Pre " curl -o ~/.local/bin/git-filter-repo \\ 2052 - https://raw.githubusercontent.com/newren/git-filter-repo/refs/heads/main/git-filter-repo 2053 - chmod +x ~/.local/bin/git-filter-repo"; 2014 + `I ("git/patches/<name>", "Local modifications on top of upstream"); 2015 + `P "Repositories are merged into projects using git subtree, placing files \ 2016 + under vendor/git/<name>/ without polluting project history."; 2054 2017 `S "TYPICAL WORKFLOW"; 2055 2018 `P "1. Vendor a git repository:"; 2056 2019 `Pre " unpac git add https://github.com/owner/repo.git"; 2057 - `P "2. Optionally extract only a subdirectory:"; 2058 - `Pre " unpac git add https://github.com/owner/monorepo.git --subdir lib/component"; 2059 - `P "3. Create a project and merge:"; 2020 + `P "2. Create a project and merge:"; 2060 2021 `Pre " unpac project new myapp 2061 2022 unpac git merge repo myapp"; 2062 2023 `S "MAKING LOCAL CHANGES"; 2063 2024 `P "1. Open repository for editing:"; 2064 2025 `Pre " unpac git edit repo"; 2065 - `P "2. Make changes in vendor/git/repo-patches/"; 2026 + `P "2. Make changes in the patches worktree"; 2066 2027 `P "3. Close the editing session:"; 2067 2028 `Pre " unpac git done repo"; 2068 2029 `S "COMMANDS"; ··· 2237 2198 2238 2199 (* For each package, get patch count and merge status *) 2239 2200 List.iter (fun pkg -> 2240 - let vendor_branch = Unpac_opam.Opam.vendor_branch pkg in 2201 + let upstream_branch = Unpac_opam.Opam.upstream_branch pkg in 2241 2202 let patches_branch = Unpac_opam.Opam.patches_branch pkg in 2242 2203 2243 - (* Count commits on patches that aren't on vendor *) 2204 + (* Count commits on patches that aren't on upstream *) 2244 2205 let patch_count = 2245 2206 let output = Unpac.Git.run_exn ~proc_mgr ~cwd:git 2246 - ["rev-list"; "--count"; vendor_branch ^ ".." ^ patches_branch] in 2207 + ["rev-list"; "--count"; upstream_branch ^ ".." ^ patches_branch] in 2247 2208 int_of_string (String.trim output) 2248 2209 in 2249 2210 ··· 2409 2370 (* Count total patches - parallel *) 2410 2371 Log.debug (fun m -> m "Counting opam patches (%d packages) in parallel..." (List.length opam_packages)); 2411 2372 let opam_patch_counts = parallel_commit_counts opam_packages 2412 - Unpac_opam.Opam.vendor_branch Unpac_opam.Opam.patches_branch in 2373 + Unpac_opam.Opam.upstream_branch Unpac_opam.Opam.patches_branch in 2413 2374 let opam_patches = List.fold_left (fun acc (_, n) -> acc + n) 0 opam_patch_counts in 2414 2375 Log.debug (fun m -> m "Counting git patches (%d repos) in parallel..." (List.length git_repos)); 2415 2376 let git_patch_counts = parallel_commit_counts git_repos 2416 - Unpac.Git_backend.vendor_branch Unpac.Git_backend.patches_branch in 2377 + Unpac.Git_backend.upstream_branch Unpac.Git_backend.patches_branch in 2417 2378 let git_patches = List.fold_left (fun acc (_, n) -> acc + n) 0 git_patch_counts in 2418 2379 if opam_patches + git_patches > 0 then 2419 2380 Format.printf "Local patches: %d commits@." (opam_patches + git_patches); ··· 2507 2468 else begin 2508 2469 (* Parallel commit counts *) 2509 2470 let opam_counts = parallel_commit_counts opam_packages 2510 - Unpac_opam.Opam.vendor_branch Unpac_opam.Opam.patches_branch in 2471 + Unpac_opam.Opam.upstream_branch Unpac_opam.Opam.patches_branch in 2511 2472 let opam_count_table = Hashtbl.create (List.length opam_counts) in 2512 2473 List.iter (fun (pkg, count) -> Hashtbl.add opam_count_table pkg count) opam_counts; 2513 2474 ··· 2560 2521 else begin 2561 2522 (* Parallel commit counts *) 2562 2523 let git_counts = parallel_commit_counts git_repos 2563 - Unpac.Git_backend.vendor_branch Unpac.Git_backend.patches_branch in 2524 + Unpac.Git_backend.upstream_branch Unpac.Git_backend.patches_branch in 2564 2525 let git_count_table = Hashtbl.create (List.length git_counts) in 2565 2526 List.iter (fun (repo, count) -> Hashtbl.add git_count_table repo count) git_counts; 2566 2527 ··· 2762 2723 else begin 2763 2724 (* Parallel commit counts *) 2764 2725 let readme_opam_counts = parallel_commit_counts opam_packages 2765 - Unpac_opam.Opam.vendor_branch Unpac_opam.Opam.patches_branch in 2726 + Unpac_opam.Opam.upstream_branch Unpac_opam.Opam.patches_branch in 2766 2727 let readme_opam_count_table = Hashtbl.create (List.length readme_opam_counts) in 2767 2728 List.iter (fun (pkg, count) -> Hashtbl.add readme_opam_count_table pkg count) readme_opam_counts; 2768 2729 ··· 2814 2775 else begin 2815 2776 (* Parallel commit counts *) 2816 2777 let readme_git_counts = parallel_commit_counts git_repos 2817 - Unpac.Git_backend.vendor_branch Unpac.Git_backend.patches_branch in 2778 + Unpac.Git_backend.upstream_branch Unpac.Git_backend.patches_branch in 2818 2779 let readme_git_count_table = Hashtbl.create (List.length readme_git_counts) in 2819 2780 List.iter (fun (repo, count) -> Hashtbl.add readme_git_count_table repo count) readme_git_counts; 2820 2781 ··· 3086 3047 `S Manpage.s_description; 3087 3048 `P "Unpac is a vendoring tool that maintains third-party dependencies \ 3088 3049 as git branches with full history. It uses git worktrees to provide \ 3089 - isolated views for editing, and a three-tier branch model \ 3090 - (upstream/vendor/patches) for conflict-free updates."; 3050 + isolated views for editing, and a two-tier branch model \ 3051 + (upstream/patches) for conflict-free updates."; 3091 3052 `S "VENDORING MODES"; 3092 3053 `I ("unpac opam", "Vendor OCaml packages from opam repositories with \ 3093 3054 dependency solving."); 3094 3055 `I ("unpac git", "Vendor arbitrary git repositories directly by URL."); 3095 - `S "THREE-TIER BRANCH MODEL"; 3096 - `P "Each vendored item has three branches:"; 3056 + `S "TWO-TIER BRANCH MODEL"; 3057 + `P "Each vendored item has two branches:"; 3097 3058 `I ("upstream/*", "Tracks the original repository"); 3098 - `I ("vendor/*", "Clean snapshot used as merge base"); 3099 - `I ("patches/*", "Your local modifications"); 3059 + `I ("patches/*", "Your local modifications on top of upstream"); 3100 3060 `S "QUICK START"; 3101 3061 `Pre " unpac init myproject && cd myproject 3102 3062 unpac opam repo add default /path/to/opam-repository
claude.dev

This is a binary file and will not be displayed.

+1 -5
dune
··· 1 - ; Root dune file 2 - 3 - ; Ignore third_party directory (for fetched dependency sources) 4 - 5 - (data_only_dirs third_party) 1 + (vendored_dirs vendor)
+3 -3
dune-project
··· 1 - (lang dune 3.20) 1 + (lang dune 3.18) 2 2 (name unpac) 3 3 (generate_opam_files true) 4 4 5 5 (package 6 6 (name unpac) 7 7 (synopsis "Monorepo management tool") 8 - (description "A tool for managing OCaml monorepos with opam repository integration") 8 + (description "A tool for managing OCaml monorepos with opam and git repository integration") 9 9 (authors "Anil Madhavapeddy") 10 10 (license ISC) 11 11 (depends ··· 14 14 (logs (>= 0.7.0)) 15 15 (fmt (>= 0.9.0)) 16 16 tomlt 17 - (jsont (>= 0.1.0)))) 17 + (jsont (>= 0.2.0)))) 18 18 19 19 (package 20 20 (name unpac-opam)
+14 -15
lib/backend.ml
··· 33 33 val upstream_branch : string -> string 34 34 (** [upstream_branch pkg] returns branch name, e.g. "opam/upstream/astring". *) 35 35 36 - val vendor_branch : string -> string 37 - (** [vendor_branch pkg] returns branch name, e.g. "opam/vendor/astring". *) 38 - 39 36 val patches_branch : string -> string 40 37 (** [patches_branch pkg] returns branch name, e.g. "opam/patches/astring". *) 41 38 ··· 44 41 45 42 (** {2 Worktree Kinds} *) 46 43 47 - val upstream_kind : string -> Worktree.kind 48 - val vendor_kind : string -> Worktree.kind 49 44 val patches_kind : string -> Worktree.kind 50 45 51 46 (** {2 Package Operations} *) ··· 57 52 add_result 58 53 (** [add_package ~proc_mgr ~root info] vendors a single package. 59 54 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 *) 55 + 1. Creates/updates upstream branch from URL 56 + 2. Creates patches branch from upstream *) 63 57 64 58 val update_package : 65 59 proc_mgr:Git.proc_mgr -> ··· 68 62 update_result 69 63 (** [update_package ~proc_mgr ~root name] updates a package from upstream. 70 64 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. *) 65 + 1. Fetches latest into upstream branch 66 + 2. Rebases patches branch onto new upstream *) 74 67 75 68 val list_packages : 76 69 proc_mgr:Git.proc_mgr -> ··· 83 76 84 77 (** These operations are backend-agnostic and work on any patches branch. *) 85 78 86 - let merge_to_project ~proc_mgr ~root ~project ~patches_branch = 79 + let merge_to_project ~proc_mgr ~root ~project ~patches_branch ~prefix = 87 80 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) 81 + (* Check if this is the first merge (prefix doesn't exist) or an update *) 82 + let prefix_path = Eio.Path.(project_wt / prefix) in 83 + let exists = Eio.Path.is_directory prefix_path in 84 + if exists then 85 + (* Update existing subtree *) 86 + Git.subtree_pull ~proc_mgr ~cwd:project_wt ~prefix ~branch:patches_branch ~squash:true 87 + else 88 + (* First time merge *) 89 + Git.subtree_add ~proc_mgr ~cwd:project_wt ~prefix ~branch:patches_branch ~squash:true 91 90 92 91 let rebase_patches ~proc_mgr ~root ~patches_kind ~onto = 93 92 Worktree.ensure ~proc_mgr root patches_kind;
+8 -14
lib/claude/tools.ml
··· 34 34 err (Printf.sprintf "Failed to list git repos: %s" (Printexc.to_string exn)) 35 35 36 36 (* Git add tool *) 37 - let git_add ~proc_mgr ~fs ~root ~url ?name ?branch ?subdir () = 37 + let git_add ~proc_mgr ~fs ~root ~url ?name ?branch () = 38 38 try 39 39 let repo_name = match name with 40 40 | Some n -> n ··· 49 49 name = repo_name; 50 50 url; 51 51 branch; 52 - subdir; 53 52 } in 54 53 55 54 let config_path = Filename.concat (snd (Unpac.Worktree.path root Unpac.Worktree.Main)) ··· 100 99 | None -> ()); 101 100 102 101 let upstream = Unpac.Git_backend.upstream_branch name in 103 - let vendor = Unpac.Git_backend.vendor_branch name in 104 102 let patches = Unpac.Git_backend.patches_branch name in 105 103 106 104 (match Unpac.Git.rev_parse ~proc_mgr ~cwd:git upstream with 107 105 | Some sha -> add (Printf.sprintf "Upstream: %s\n" (String.sub sha 0 7)) 108 - | None -> ()); 109 - (match Unpac.Git.rev_parse ~proc_mgr ~cwd:git vendor with 110 - | Some sha -> add (Printf.sprintf "Vendor: %s\n" (String.sub sha 0 7)) 111 106 | None -> ()); 112 107 (match Unpac.Git.rev_parse ~proc_mgr ~cwd:git patches with 113 108 | Some sha -> add (Printf.sprintf "Patches: %s\n" (String.sub sha 0 7)) 114 109 | None -> ()); 115 110 111 + (* Count local commits: patches ahead of upstream *) 116 112 let log_output = Unpac.Git.run_exn ~proc_mgr ~cwd:git 117 - ["log"; "--oneline"; vendor ^ ".." ^ patches] in 113 + ["log"; "--oneline"; upstream ^ ".." ^ patches] in 118 114 let commits = List.length (String.split_on_char '\n' log_output |> 119 115 List.filter (fun s -> String.trim s <> "")) in 120 116 add (Printf.sprintf "Local commits: %d\n" commits); ··· 132 128 if not (List.mem name repos) then 133 129 err (Printf.sprintf "Repository '%s' is not vendored" name) 134 130 else begin 135 - let vendor = Unpac.Git_backend.vendor_branch name in 131 + let upstream = Unpac.Git_backend.upstream_branch name in 136 132 let patches = Unpac.Git_backend.patches_branch name in 137 - let diff = Unpac.Git.run_exn ~proc_mgr ~cwd:git ["diff"; vendor; patches] in 133 + let diff = Unpac.Git.run_exn ~proc_mgr ~cwd:git ["diff"; upstream; patches] in 138 134 if String.trim diff = "" then 139 135 ok (Printf.sprintf "No local changes in '%s'." name) 140 136 else ··· 547 543 548 544 create 549 545 ~name:"unpac_git_add" 550 - ~description:"Vendor a new git repository. Clones the repo and creates the three-tier \ 551 - branch structure for conflict-free vendoring with full history preservation." 546 + ~description:"Vendor a new git repository. Clones the repo and creates the two-tier \ 547 + branch structure (upstream/patches) for conflict-free vendoring." 552 548 ~input_schema:(schema_object [ 553 549 ("url", schema_string); 554 550 ("name", schema_string); 555 551 ("branch", schema_string); 556 - ("subdir", schema_string); 557 552 ] ~required:["url"]) 558 553 ~handler:(fun args -> 559 554 match Claude.Tool_input.get_string args "url" with ··· 561 556 | Some url -> 562 557 let name = Claude.Tool_input.get_string args "name" in 563 558 let branch = Claude.Tool_input.get_string args "branch" in 564 - let subdir = Claude.Tool_input.get_string args "subdir" in 565 - git_add ~proc_mgr ~fs ~root ~url ?name ?branch ?subdir ()); 559 + git_add ~proc_mgr ~fs ~root ~url ?name ?branch ()); 566 560 567 561 create 568 562 ~name:"unpac_git_info"
+2 -4
lib/config.ml
··· 30 30 git_name : string; (** User-specified name for the repo *) 31 31 git_url : string; (** Git URL to clone from *) 32 32 git_branch : string option; (** Optional branch/tag to track *) 33 - git_subdir : string option; (** Optional subdirectory to extract *) 34 33 } 35 34 36 35 type git_config = { ··· 101 100 let git_repo_config_codec : git_repo_config Tomlt.t = 102 101 let open Tomlt in 103 102 let open Table in 104 - obj (fun git_name git_url git_branch git_subdir : git_repo_config -> 105 - { git_name; git_url; git_branch; git_subdir }) 103 + obj (fun git_name git_url git_branch : git_repo_config -> 104 + { git_name; git_url; git_branch }) 106 105 |> mem "name" string ~enc:(fun (r : git_repo_config) -> r.git_name) 107 106 |> mem "url" string ~enc:(fun (r : git_repo_config) -> r.git_url) 108 107 |> opt_mem "branch" string ~enc:(fun (r : git_repo_config) -> r.git_branch) 109 - |> opt_mem "subdir" string ~enc:(fun (r : git_repo_config) -> r.git_subdir) 110 108 |> finish 111 109 112 110 let git_config_codec : git_config Tomlt.t =
-1
lib/config.mli
··· 30 30 git_name : string; (** User-specified name for the repo *) 31 31 git_url : string; (** Git URL to clone from *) 32 32 git_branch : string option; (** Optional branch/tag to track *) 33 - git_subdir : string option; (** Optional subdirectory to extract *) 34 33 } 35 34 36 35 type git_config = {
+49 -91
lib/git.ml
··· 437 437 Log.debug (fun m -> m "Cleaning untracked files"); 438 438 run_exn ~proc_mgr ~cwd ["clean"; "-fd"] |> ignore 439 439 440 - let filter_repo_to_subdirectory ~proc_mgr ~cwd ~branch ~subdirectory = 441 - Log.info (fun m -> m "Rewriting history of %s into subdirectory %s..." branch subdirectory); 442 - (* Use git-filter-repo with --to-subdirectory-filter to rewrite all paths into subdirectory. 443 - This preserves full history with paths prefixed. Much faster than filter-branch. 444 - 445 - For bare repositories, we need to create a temporary worktree, run filter-repo 446 - there, and then update the branch in the bare repo. *) 447 - 448 - (* Create a unique temporary worktree name using the branch name *) 449 - let safe_branch = String.map (fun c -> if c = '/' then '-' else c) branch in 450 - let temp_wt_name = ".filter-tmp-" ^ safe_branch in 451 - let temp_wt_relpath = "../" ^ temp_wt_name in 452 - 453 - (* Construct the worktree path - cwd is (fs, path_string), so we go up one level *) 454 - let fs = fst cwd in 455 - let git_path = snd cwd in 456 - let parent_path = Filename.dirname git_path in 457 - let temp_wt_path = Filename.concat parent_path temp_wt_name in 458 - let temp_wt : path = (fs, temp_wt_path) in 459 - 460 - (* Remove any existing temp worktree *) 461 - ignore (run ~proc_mgr ~cwd ["worktree"; "remove"; "-f"; temp_wt_relpath]); 462 - 463 - (* Create worktree for the branch *) 464 - run_exn ~proc_mgr ~cwd ["worktree"; "add"; temp_wt_relpath; branch] |> ignore; 465 - 466 - (* Run git-filter-repo in the worktree *) 467 - let result = run ~proc_mgr ~cwd:temp_wt [ 468 - "filter-repo"; 469 - "--to-subdirectory-filter"; subdirectory; 470 - "--force"; 471 - "--refs"; "HEAD" 472 - ] in 473 - 474 - (* Handle result: get the new SHA, cleanup worktree, then update branch *) 475 - (match result with 476 - | Ok _ -> 477 - (* Get the new HEAD SHA from the worktree BEFORE removing it *) 478 - let new_sha = run_exn ~proc_mgr ~cwd:temp_wt ["rev-parse"; "HEAD"] |> string_trim in 479 - (* Cleanup temporary worktree first (must do this before updating branch) *) 480 - ignore (run ~proc_mgr ~cwd ["worktree"; "remove"; "-f"; temp_wt_relpath]); 481 - (* Now update the branch in the bare repo *) 482 - run_exn ~proc_mgr ~cwd ["branch"; "-f"; branch; new_sha] |> ignore 483 - | Error e -> 484 - (* Cleanup and re-raise *) 485 - ignore (run ~proc_mgr ~cwd ["worktree"; "remove"; "-f"; temp_wt_relpath]); 486 - raise (err e)) 440 + (* Git subtree operations *) 487 441 488 - let filter_repo_from_subdirectory ~proc_mgr ~cwd ~branch ~subdirectory = 489 - Log.info (fun m -> m "Extracting %s from subdirectory %s to root..." branch subdirectory); 490 - (* Use git-filter-repo with --subdirectory-filter to extract files from subdirectory 491 - to root. This is the inverse of --to-subdirectory-filter. 492 - Preserves history for files that were in the subdirectory. 493 - 494 - For bare repositories, we need to create a temporary worktree, run filter-repo 495 - there, and then update the branch in the bare repo. *) 496 - 497 - (* Create a unique temporary worktree name using the branch name *) 498 - let safe_branch = String.map (fun c -> if c = '/' then '-' else c) branch in 499 - let temp_wt_name = ".filter-tmp-" ^ safe_branch in 500 - let temp_wt_relpath = "../" ^ temp_wt_name in 501 - 502 - (* Construct the worktree path - cwd is (fs, path_string), so we go up one level *) 503 - let fs = fst cwd in 504 - let git_path = snd cwd in 505 - let parent_path = Filename.dirname git_path in 506 - let temp_wt_path = Filename.concat parent_path temp_wt_name in 507 - let temp_wt : path = (fs, temp_wt_path) in 442 + type subtree_result = 443 + | Subtree_ok 444 + | Subtree_conflict of string list 508 445 509 - (* Remove any existing temp worktree *) 510 - ignore (run ~proc_mgr ~cwd ["worktree"; "remove"; "-f"; temp_wt_relpath]); 446 + let has_conflict_marker stderr = 447 + String.starts_with ~prefix:"CONFLICT" stderr || 448 + String.starts_with ~prefix:"conflict" stderr || 449 + (* Check if "Merge conflict" appears anywhere *) 450 + let rec find_substring s sub i = 451 + if i + String.length sub > String.length s then false 452 + else if String.sub s i (String.length sub) = sub then true 453 + else find_substring s sub (i + 1) 454 + in 455 + find_substring stderr "Merge conflict" 0 511 456 512 - (* Create worktree for the branch *) 513 - run_exn ~proc_mgr ~cwd ["worktree"; "add"; temp_wt_relpath; branch] |> ignore; 457 + let subtree_add ~proc_mgr ~cwd ~prefix ~branch ~squash = 458 + Log.info (fun m -> m "Subtree add: %s from %s (squash=%b)" prefix branch squash); 459 + let args = ["subtree"; "add"; "--prefix"; prefix] @ 460 + (if squash then ["--squash"] else []) @ 461 + [branch] in 462 + match run ~proc_mgr ~cwd args with 463 + | Ok _ -> Subtree_ok 464 + | Error (Command_failed { exit_code = 1; stderr; _ }) when 465 + String.length stderr > 0 && has_conflict_marker stderr -> 466 + (* Parse conflicting files *) 467 + let conflict_output = run_exn ~proc_mgr ~cwd ["diff"; "--name-only"; "--diff-filter=U"] in 468 + let files = lines conflict_output in 469 + Log.warn (fun m -> m "Subtree add conflict: %a" Fmt.(list ~sep:comma string) files); 470 + Subtree_conflict files 471 + | Error e -> 472 + raise (err e) 514 473 515 - (* Run git-filter-repo in the worktree with --subdirectory-filter *) 516 - let result = run ~proc_mgr ~cwd:temp_wt [ 517 - "filter-repo"; 518 - "--subdirectory-filter"; subdirectory; 519 - "--force"; 520 - "--refs"; "HEAD" 521 - ] in 474 + let subtree_pull ~proc_mgr ~cwd ~prefix ~branch ~squash = 475 + Log.info (fun m -> m "Subtree pull: %s from %s (squash=%b)" prefix branch squash); 476 + let args = ["subtree"; "pull"; "--prefix"; prefix] @ 477 + (if squash then ["--squash"] else []) @ 478 + ["." (* local repo *); branch] in 479 + match run ~proc_mgr ~cwd args with 480 + | Ok _ -> Subtree_ok 481 + | Error (Command_failed { exit_code = 1; stderr; _ }) when 482 + String.length stderr > 0 && has_conflict_marker stderr -> 483 + (* Parse conflicting files *) 484 + let conflict_output = run_exn ~proc_mgr ~cwd ["diff"; "--name-only"; "--diff-filter=U"] in 485 + let files = lines conflict_output in 486 + Log.warn (fun m -> m "Subtree pull conflict: %a" Fmt.(list ~sep:comma string) files); 487 + Subtree_conflict files 488 + | Error e -> 489 + raise (err e) 522 490 523 - (* Handle result: get the new SHA, cleanup worktree, then update branch *) 524 - (match result with 525 - | Ok _ -> 526 - (* Get the new HEAD SHA from the worktree BEFORE removing it *) 527 - let new_sha = run_exn ~proc_mgr ~cwd:temp_wt ["rev-parse"; "HEAD"] |> string_trim in 528 - (* Cleanup temporary worktree first (must do this before updating branch) *) 529 - ignore (run ~proc_mgr ~cwd ["worktree"; "remove"; "-f"; temp_wt_relpath]); 530 - (* Now update the branch in the bare repo *) 531 - run_exn ~proc_mgr ~cwd ["branch"; "-f"; branch; new_sha] |> ignore 532 - | Error e -> 533 - (* Cleanup and re-raise *) 534 - ignore (run ~proc_mgr ~cwd ["worktree"; "remove"; "-f"; temp_wt_relpath]); 535 - raise (err e)) 491 + let subtree_split ~proc_mgr ~cwd ~prefix ~branch = 492 + Log.info (fun m -> m "Subtree split: %s to branch %s" prefix branch); 493 + run_exn ~proc_mgr ~cwd ["subtree"; "split"; "--prefix"; prefix; "-b"; branch] |> ignore
+29 -13
lib/git.mli
··· 376 376 unit 377 377 (** [clean_fd] removes untracked files and directories. *) 378 378 379 - val filter_repo_to_subdirectory : 379 + (** {1 Git Subtree Operations} *) 380 + 381 + type subtree_result = 382 + | Subtree_ok 383 + | Subtree_conflict of string list 384 + (** Result of a subtree operation. *) 385 + 386 + val subtree_add : 380 387 proc_mgr:proc_mgr -> 381 388 cwd:path -> 389 + prefix:string -> 382 390 branch:string -> 383 - subdirectory:string -> 384 - unit 385 - (** [filter_repo_to_subdirectory ~proc_mgr ~cwd ~branch ~subdirectory] 386 - rewrites the history of [branch] so all files are moved into [subdirectory]. 387 - Uses git-filter-repo for fast history rewriting. Preserves full commit history. *) 391 + squash:bool -> 392 + subtree_result 393 + (** [subtree_add ~proc_mgr ~cwd ~prefix ~branch ~squash] adds a branch as a subtree. 394 + Use this for the first merge of a package into a project. 395 + Files from [branch] will be placed under [prefix]. 396 + If [squash] is true, the subtree's history is squashed into a single commit. *) 388 397 389 - val filter_repo_from_subdirectory : 398 + val subtree_pull : 399 + proc_mgr:proc_mgr -> 400 + cwd:path -> 401 + prefix:string -> 402 + branch:string -> 403 + squash:bool -> 404 + subtree_result 405 + (** [subtree_pull ~proc_mgr ~cwd ~prefix ~branch ~squash] updates an existing subtree. 406 + Use this to update a package that was previously merged via subtree_add. *) 407 + 408 + val subtree_split : 390 409 proc_mgr:proc_mgr -> 391 410 cwd:path -> 411 + prefix:string -> 392 412 branch:string -> 393 - subdirectory:string -> 394 413 unit 395 - (** [filter_repo_from_subdirectory ~proc_mgr ~cwd ~branch ~subdirectory] 396 - rewrites the history of [branch] extracting only files from [subdirectory] 397 - and placing them at the repository root. This is the inverse of 398 - [filter_repo_to_subdirectory]. Uses git-filter-repo --subdirectory-filter. 399 - Preserves full commit history for files that were in the subdirectory. *) 414 + (** [subtree_split ~proc_mgr ~cwd ~prefix ~branch] extracts a subdirectory's history 415 + into a new branch. This is used for promoting project code to a vendored library. *)
+53 -103
lib/git_backend.ml
··· 1 1 (** Git backend for direct repository vendoring. 2 2 3 - Implements vendoring of arbitrary git repositories using the three-tier branch model: 3 + Implements vendoring of arbitrary git repositories using a two-tier branch model: 4 4 - git/upstream/<name> - pristine upstream code 5 - - git/vendor/<name> - upstream history rewritten with vendor/git/<name>/ prefix 6 - - git/patches/<name> - local modifications *) 5 + - git/patches/<name> - local modifications on top of upstream 6 + 7 + Repositories are merged into projects using git subtree, which places files 8 + under vendor/git/<name>/ without rewriting upstream history. *) 7 9 8 10 (** {1 Branch Naming} *) 9 11 10 12 let upstream_branch name = "git/upstream/" ^ name 11 - let vendor_branch name = "git/vendor/" ^ name 12 13 let patches_branch name = "git/patches/" ^ name 13 14 let vendor_path name = "vendor/git/" ^ name 14 15 15 16 (** {1 Worktree Kinds} *) 16 17 17 - let upstream_kind name = Worktree.Git_upstream name 18 - let vendor_kind name = Worktree.Git_vendor name 19 18 let patches_kind name = Worktree.Git_patches name 20 19 21 20 (** {1 Repository Info} *) ··· 24 23 name : string; 25 24 url : string; 26 25 branch : string option; 27 - subdir : string option; 28 26 } 29 27 30 28 (** {1 Repository Operations} *) ··· 68 66 Git.branch_force ~proc_mgr ~cwd:git 69 67 ~name:(upstream_branch repo_name) ~point:ref_point; 70 68 71 - (* Step 2: Create vendor branch from upstream and rewrite history *) 72 - Git.branch_force ~proc_mgr ~cwd:git 73 - ~name:(vendor_branch repo_name) ~point:(upstream_branch repo_name); 69 + (* Step 2: Create patches branch from upstream (initially identical) *) 70 + (* Patches will be merged into projects via git subtree *) 71 + Git.branch_create ~proc_mgr ~cwd:git 72 + ~name:(patches_branch repo_name) 73 + ~start_point:(upstream_branch repo_name); 74 74 75 - (* If subdir is specified, we first filter to that subdirectory, 76 - then move to vendor path. Otherwise, just move to vendor path. *) 77 - (match info.subdir with 78 - | Some subdir -> 79 - (* First filter to extract only the subdirectory *) 80 - Git.filter_repo_to_subdirectory ~proc_mgr ~cwd:git 81 - ~branch:(vendor_branch repo_name) 82 - ~subdirectory:subdir; 83 - (* Now the subdir is at root, rewrite to vendor path *) 84 - Git.filter_repo_to_subdirectory ~proc_mgr ~cwd:git 85 - ~branch:(vendor_branch repo_name) 86 - ~subdirectory:(vendor_path repo_name) 87 - | None -> 88 - (* Rewrite vendor branch history to move all files into vendor/git/<name>/ *) 89 - Git.filter_repo_to_subdirectory ~proc_mgr ~cwd:git 90 - ~branch:(vendor_branch repo_name) 91 - ~subdirectory:(vendor_path repo_name)); 92 - 93 - (* Get the vendor SHA after rewriting *) 94 - let vendor_sha = match Git.rev_parse ~proc_mgr ~cwd:git (vendor_branch repo_name) with 75 + (* Get the SHA for reporting *) 76 + let sha = match Git.rev_parse ~proc_mgr ~cwd:git (patches_branch repo_name) with 95 77 | Some sha -> sha 96 - | None -> failwith "Vendor branch not found after filter-repo" 78 + | None -> failwith "Patches branch not found" 97 79 in 98 80 99 - (* Step 3: Create patches branch from vendor *) 100 - Git.branch_create ~proc_mgr ~cwd:git 101 - ~name:(patches_branch repo_name) 102 - ~start_point:(vendor_branch repo_name); 103 - 104 - Backend.Added { name = repo_name; sha = vendor_sha } 81 + Backend.Added { name = repo_name; sha } 105 82 end 106 83 with exn -> 107 - (* Cleanup on failure *) 108 - (try Worktree.remove_force ~proc_mgr root (upstream_kind repo_name) with _ -> ()); 109 - (try Worktree.remove_force ~proc_mgr root (vendor_kind repo_name) with _ -> ()); 84 + (* Cleanup on failure - just delete branches, no worktrees to remove *) 85 + (try Git.run_exn ~proc_mgr ~cwd:git ["branch"; "-D"; upstream_branch repo_name] |> ignore with _ -> ()); 86 + (try Git.run_exn ~proc_mgr ~cwd:git ["branch"; "-D"; patches_branch repo_name] |> ignore with _ -> ()); 110 87 Backend.Failed { name = repo_name; error = Printexc.to_string exn } 111 88 112 - let copy_with_prefix ~src_dir ~dst_dir ~prefix = 113 - (* Recursively copy files from src_dir to dst_dir/prefix/ *) 114 - let prefix_dir = Eio.Path.(dst_dir / prefix) in 115 - Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 prefix_dir; 116 - 117 - let rec copy_dir src dst = 118 - Eio.Path.read_dir src |> List.iter (fun name -> 119 - let src_path = Eio.Path.(src / name) in 120 - let dst_path = Eio.Path.(dst / name) in 121 - if Eio.Path.is_directory src_path then begin 122 - Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 dst_path; 123 - copy_dir src_path dst_path 124 - end else begin 125 - let content = Eio.Path.load src_path in 126 - Eio.Path.save ~create:(`Or_truncate 0o644) dst_path content 127 - end 128 - ) 129 - in 130 - 131 - (* Copy everything except .git *) 132 - Eio.Path.read_dir src_dir |> List.iter (fun name -> 133 - if name <> ".git" then begin 134 - let src_path = Eio.Path.(src_dir / name) in 135 - let dst_path = Eio.Path.(prefix_dir / name) in 136 - if Eio.Path.is_directory src_path then begin 137 - Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 dst_path; 138 - copy_dir src_path dst_path 139 - end else begin 140 - let content = Eio.Path.load src_path in 141 - Eio.Path.save ~create:(`Or_truncate 0o644) dst_path content 142 - end 143 - end 144 - ) 145 - 146 89 let update_repo ~proc_mgr ~root ?cache repo_name = 147 90 let git = Worktree.git_dir root in 148 91 ··· 188 131 if old_sha = new_sha then 189 132 Backend.No_changes repo_name 190 133 else begin 191 - (* Create worktrees *) 192 - Worktree.ensure ~proc_mgr root (upstream_kind repo_name); 193 - Worktree.ensure ~proc_mgr root (vendor_kind repo_name); 134 + (* Rebase patches branch onto new upstream *) 135 + (* First check if patches has diverged from upstream *) 136 + let patches_sha = Git.rev_parse_exn ~proc_mgr ~cwd:git (patches_branch repo_name) in 137 + if patches_sha = old_sha then begin 138 + (* No local patches - just fast-forward patches branch *) 139 + Git.branch_force ~proc_mgr ~cwd:git 140 + ~name:(patches_branch repo_name) ~point:(upstream_branch repo_name); 141 + Backend.Updated { name = repo_name; old_sha; new_sha } 142 + end else begin 143 + (* Has local patches - need to rebase *) 144 + (* Create a temporary worktree for rebasing *) 145 + let safe_name = String.map (fun c -> if c = '/' then '-' else c) repo_name in 146 + let temp_wt_name = ".rebase-tmp-" ^ safe_name in 147 + let temp_wt_relpath = "../" ^ temp_wt_name in 194 148 195 - let upstream_wt = Worktree.path root (upstream_kind repo_name) in 196 - let vendor_wt = Worktree.path root (vendor_kind repo_name) in 149 + let fs = fst git in 150 + let git_path = snd git in 151 + let parent_path = Filename.dirname git_path in 152 + let temp_wt_path = Filename.concat parent_path temp_wt_name in 153 + let temp_wt : Git.path = (fs, temp_wt_path) in 197 154 198 - (* Clear vendor content and copy new *) 199 - let vendor_pkg_path = Eio.Path.(vendor_wt / "vendor" / "git" / repo_name) in 200 - (try Eio.Path.rmtree vendor_pkg_path with _ -> ()); 155 + (* Remove any existing temp worktree *) 156 + ignore (Git.run ~proc_mgr ~cwd:git ["worktree"; "remove"; "-f"; temp_wt_relpath]); 201 157 202 - copy_with_prefix 203 - ~src_dir:upstream_wt 204 - ~dst_dir:vendor_wt 205 - ~prefix:(vendor_path repo_name); 158 + (* Create worktree for the patches branch *) 159 + Git.run_exn ~proc_mgr ~cwd:git ["worktree"; "add"; temp_wt_relpath; patches_branch repo_name] |> ignore; 206 160 207 - (* Commit *) 208 - Git.add_all ~proc_mgr ~cwd:vendor_wt; 209 - Git.commit ~proc_mgr ~cwd:vendor_wt 210 - ~message:(Printf.sprintf "Update %s to %s" repo_name (String.sub new_sha 0 7)); 161 + (* Try to rebase *) 162 + let result = Git.rebase ~proc_mgr ~cwd:temp_wt ~onto:(upstream_branch repo_name) in 211 163 212 - (* Cleanup *) 213 - Worktree.remove ~proc_mgr root (upstream_kind repo_name); 214 - Worktree.remove ~proc_mgr root (vendor_kind repo_name); 164 + (* Cleanup temp worktree *) 165 + ignore (Git.run ~proc_mgr ~cwd:git ["worktree"; "remove"; "-f"; temp_wt_relpath]); 215 166 216 - Backend.Updated { name = repo_name; old_sha; new_sha } 167 + match result with 168 + | Ok () -> 169 + Backend.Updated { name = repo_name; old_sha; new_sha } 170 + | Error (`Conflict hint) -> 171 + (* Abort the rebase and report conflict *) 172 + Git.rebase_abort ~proc_mgr ~cwd:git; 173 + Backend.Update_failed { name = repo_name; error = "Rebase conflict: " ^ hint } 174 + end 217 175 end 218 176 end 219 177 with exn -> 220 - (try Worktree.remove_force ~proc_mgr root (upstream_kind repo_name) with _ -> ()); 221 - (try Worktree.remove_force ~proc_mgr root (vendor_kind repo_name) with _ -> ()); 222 178 Backend.Update_failed { name = repo_name; error = Printexc.to_string exn } 223 179 224 180 let list_repos ~proc_mgr ~root = ··· 227 183 let remove_repo ~proc_mgr ~root repo_name = 228 184 let git = Worktree.git_dir root in 229 185 230 - (* Remove worktrees if exist *) 231 - (try Worktree.remove_force ~proc_mgr root (upstream_kind repo_name) with _ -> ()); 232 - (try Worktree.remove_force ~proc_mgr root (vendor_kind repo_name) with _ -> ()); 233 - (try Worktree.remove_force ~proc_mgr root (patches_kind repo_name) with _ -> ()); 234 - 235 - (* Delete branches *) 186 + (* Delete branches - no worktrees in new architecture *) 236 187 (try Git.run_exn ~proc_mgr ~cwd:git ["branch"; "-D"; upstream_branch repo_name] |> ignore with _ -> ()); 237 - (try Git.run_exn ~proc_mgr ~cwd:git ["branch"; "-D"; vendor_branch repo_name] |> ignore with _ -> ()); 238 188 (try Git.run_exn ~proc_mgr ~cwd:git ["branch"; "-D"; patches_branch repo_name] |> ignore with _ -> ()); 239 189 240 190 (* Remove remote *)
+10 -12
lib/git_backend.mli
··· 1 1 (** Git backend for direct repository vendoring. 2 2 3 - Implements vendoring of arbitrary git repositories using the three-tier branch model: 3 + Implements vendoring of arbitrary git repositories using a two-tier branch model: 4 4 - git/upstream/<name> - pristine upstream code 5 - - git/vendor/<name> - upstream history rewritten with vendor/git/<name>/ prefix 6 - - git/patches/<name> - local modifications 5 + - git/patches/<name> - local modifications on top of upstream 6 + 7 + Repositories are merged into projects using git subtree, which places files 8 + under vendor/git/<name>/ without rewriting upstream history. 7 9 8 10 Unlike the opam backend which discovers packages via opam repositories, 9 11 this backend allows cloning any git repository directly. *) ··· 12 14 13 15 val upstream_branch : string -> string 14 16 (** [upstream_branch name] returns the upstream branch name "git/upstream/<name>". *) 15 - 16 - val vendor_branch : string -> string 17 - (** [vendor_branch name] returns the vendor branch name "git/vendor/<name>". *) 18 17 19 18 val patches_branch : string -> string 20 19 (** [patches_branch name] returns the patches branch name "git/patches/<name>". *) ··· 28 27 name : string; (** User-specified name *) 29 28 url : string; (** Git URL to clone from *) 30 29 branch : string option; (** Optional branch/tag to track *) 31 - subdir : string option; (** Optional subdirectory to extract *) 32 30 } 33 31 34 32 (** {1 Repository Operations} *) ··· 41 39 Backend.add_result 42 40 (** [add_repo ~proc_mgr ~root ?cache info] vendors a git repository. 43 41 44 - Creates the three-tier branch structure: 42 + Creates the two-tier branch structure: 45 43 1. Fetches from url into git/upstream/<name> 46 - 2. Rewrites history into git/vendor/<name> with vendor/git/<name>/ prefix 47 - 3. Creates git/patches/<name> for local modifications 44 + 2. Creates git/patches/<name> for local modifications (initially identical to upstream) 48 45 49 - If [subdir] is specified, only that subdirectory is extracted from the repo. *) 46 + Use [git subtree add] to merge into a project. *) 50 47 51 48 val update_repo : 52 49 proc_mgr:Git.proc_mgr -> ··· 54 51 ?cache:Vendor_cache.t -> 55 52 string -> 56 53 Backend.update_result 57 - (** [update_repo ~proc_mgr ~root ?cache name] updates a vendored repository from upstream. *) 54 + (** [update_repo ~proc_mgr ~root ?cache name] updates a vendored repository from upstream. 55 + Rebases the patches branch onto the new upstream. *) 58 56 59 57 val list_repos : 60 58 proc_mgr:Git.proc_mgr ->
+51 -84
lib/opam/opam.ml
··· 1 1 (** Opam backend for unpac. 2 2 3 - Implements vendoring of opam packages using the three-tier branch model: 3 + Implements vendoring of opam packages using a two-tier branch model: 4 4 - opam/upstream/<pkg> - pristine upstream code 5 - - opam/vendor/<pkg> - upstream history rewritten with vendor/opam/<pkg>/ prefix 6 - - opam/patches/<pkg> - local modifications 5 + - opam/patches/<pkg> - local modifications on top of upstream 7 6 8 - The vendor branch preserves full git history from upstream, with all paths 9 - rewritten to be under vendor/opam/<pkg>/. This allows git blame/log to work 10 - correctly on vendored files. *) 7 + Packages are merged into projects using git subtree, which places files 8 + under vendor/opam/<pkg>/ without rewriting upstream history. *) 11 9 12 10 module Worktree = Unpac.Worktree 13 11 module Git = Unpac.Git ··· 20 18 (** {1 Branch Naming} *) 21 19 22 20 let upstream_branch pkg = "opam/upstream/" ^ pkg 23 - let vendor_branch pkg = "opam/vendor/" ^ pkg 24 21 let patches_branch pkg = "opam/patches/" ^ pkg 25 22 let vendor_path pkg = "vendor/opam/" ^ pkg 26 23 27 24 (** {1 Worktree Kinds} *) 28 25 29 26 let upstream_kind pkg = Worktree.Opam_upstream pkg 30 - let vendor_kind pkg = Worktree.Opam_vendor pkg 31 27 let patches_kind pkg = Worktree.Opam_patches pkg 32 28 33 29 (** {1 Package Operations} *) 34 30 35 - let copy_with_prefix ~src_dir ~dst_dir ~prefix = 36 - (* Recursively copy files from src_dir to dst_dir/prefix/ *) 37 - let prefix_dir = Eio.Path.(dst_dir / prefix) in 38 - Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 prefix_dir; 39 - 40 - let rec copy_dir src dst = 41 - Eio.Path.read_dir src |> List.iter (fun name -> 42 - let src_path = Eio.Path.(src / name) in 43 - let dst_path = Eio.Path.(dst / name) in 44 - if Eio.Path.is_directory src_path then begin 45 - Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 dst_path; 46 - copy_dir src_path dst_path 47 - end else begin 48 - let content = Eio.Path.load src_path in 49 - Eio.Path.save ~create:(`Or_truncate 0o644) dst_path content 50 - end 51 - ) 52 - in 53 - 54 - (* Copy everything except .git *) 55 - Eio.Path.read_dir src_dir |> List.iter (fun name -> 56 - if name <> ".git" then begin 57 - let src_path = Eio.Path.(src_dir / name) in 58 - let dst_path = Eio.Path.(prefix_dir / name) in 59 - if Eio.Path.is_directory src_path then begin 60 - Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 dst_path; 61 - copy_dir src_path dst_path 62 - end else begin 63 - let content = Eio.Path.load src_path in 64 - Eio.Path.save ~create:(`Or_truncate 0o644) dst_path content 65 - end 66 - end 67 - ) 68 - 69 31 let add_package ~proc_mgr ~root ?cache (info : Backend.package_info) = 70 32 let pkg = info.name in 71 33 let git = Worktree.git_dir root in ··· 105 67 Git.branch_force ~proc_mgr ~cwd:git 106 68 ~name:(upstream_branch pkg) ~point:ref_point; 107 69 108 - (* Step 2: Create vendor branch from upstream and rewrite history *) 109 - Git.branch_force ~proc_mgr ~cwd:git 110 - ~name:(vendor_branch pkg) ~point:(upstream_branch pkg); 111 - 112 - (* Rewrite vendor branch history to move all files into vendor/opam/<pkg>/ *) 113 - Git.filter_repo_to_subdirectory ~proc_mgr ~cwd:git 114 - ~branch:(vendor_branch pkg) 115 - ~subdirectory:(vendor_path pkg); 70 + (* Step 2: Create patches branch from upstream (initially identical) *) 71 + (* Patches will be merged into projects via git subtree *) 72 + Git.branch_create ~proc_mgr ~cwd:git 73 + ~name:(patches_branch pkg) 74 + ~start_point:(upstream_branch pkg); 116 75 117 - (* Get the vendor SHA after rewriting *) 118 - let vendor_sha = match Git.rev_parse ~proc_mgr ~cwd:git (vendor_branch pkg) with 76 + (* Get the SHA for reporting *) 77 + let sha = match Git.rev_parse ~proc_mgr ~cwd:git (patches_branch pkg) with 119 78 | Some sha -> sha 120 - | None -> failwith "Vendor branch not found after filter-repo" 79 + | None -> failwith "Patches branch not found" 121 80 in 122 81 123 - (* Step 3: Create patches branch from vendor *) 124 - Git.branch_create ~proc_mgr ~cwd:git 125 - ~name:(patches_branch pkg) 126 - ~start_point:(vendor_branch pkg); 127 - 128 - Backend.Added { name = pkg; sha = vendor_sha } 82 + Backend.Added { name = pkg; sha } 129 83 end 130 84 with exn -> 131 - (* Cleanup on failure *) 132 - (try Worktree.remove_force ~proc_mgr root (upstream_kind pkg) with _ -> ()); 133 - (try Worktree.remove_force ~proc_mgr root (vendor_kind pkg) with _ -> ()); 85 + (* Cleanup on failure - just delete branches, no worktrees to remove *) 86 + (try Git.run_exn ~proc_mgr ~cwd:git ["branch"; "-D"; upstream_branch pkg] |> ignore with _ -> ()); 87 + (try Git.run_exn ~proc_mgr ~cwd:git ["branch"; "-D"; patches_branch pkg] |> ignore with _ -> ()); 134 88 Backend.Failed { name = pkg; error = Printexc.to_string exn } 135 89 136 90 let update_package ~proc_mgr ~root ?cache pkg = ··· 183 137 if old_sha = new_sha then 184 138 Backend.No_changes pkg 185 139 else begin 186 - (* Create worktrees *) 187 - Worktree.ensure ~proc_mgr root (upstream_kind pkg); 188 - Worktree.ensure ~proc_mgr root (vendor_kind pkg); 140 + (* Rebase patches branch onto new upstream *) 141 + (* First check if patches has diverged from upstream *) 142 + let patches_sha = Git.rev_parse_exn ~proc_mgr ~cwd:git (patches_branch pkg) in 143 + if patches_sha = old_sha then begin 144 + (* No local patches - just fast-forward patches branch *) 145 + Git.branch_force ~proc_mgr ~cwd:git 146 + ~name:(patches_branch pkg) ~point:(upstream_branch pkg); 147 + Backend.Updated { name = pkg; old_sha; new_sha } 148 + end else begin 149 + (* Has local patches - need to rebase *) 150 + (* Create a temporary worktree for rebasing *) 151 + let safe_name = String.map (fun c -> if c = '/' then '-' else c) pkg in 152 + let temp_wt_name = ".rebase-tmp-" ^ safe_name in 153 + let temp_wt_relpath = "../" ^ temp_wt_name in 189 154 190 - let upstream_wt = Worktree.path root (upstream_kind pkg) in 191 - let vendor_wt = Worktree.path root (vendor_kind pkg) in 155 + let fs = fst git in 156 + let git_path = snd git in 157 + let parent_path = Filename.dirname git_path in 158 + let temp_wt_path = Filename.concat parent_path temp_wt_name in 159 + let temp_wt : Git.path = (fs, temp_wt_path) in 192 160 193 - (* Clear vendor content and copy new *) 194 - let vendor_pkg_path = Eio.Path.(vendor_wt / "vendor" / "opam" / pkg) in 195 - (try Eio.Path.rmtree vendor_pkg_path with _ -> ()); 161 + (* Remove any existing temp worktree *) 162 + ignore (Git.run ~proc_mgr ~cwd:git ["worktree"; "remove"; "-f"; temp_wt_relpath]); 196 163 197 - copy_with_prefix 198 - ~src_dir:upstream_wt 199 - ~dst_dir:vendor_wt 200 - ~prefix:(vendor_path pkg); 164 + (* Create worktree for the patches branch *) 165 + Git.run_exn ~proc_mgr ~cwd:git ["worktree"; "add"; temp_wt_relpath; patches_branch pkg] |> ignore; 201 166 202 - (* Commit *) 203 - Git.add_all ~proc_mgr ~cwd:vendor_wt; 204 - Git.commit ~proc_mgr ~cwd:vendor_wt 205 - ~message:(Printf.sprintf "Update %s to %s" pkg (String.sub new_sha 0 7)); 167 + (* Try to rebase *) 168 + let result = Git.rebase ~proc_mgr ~cwd:temp_wt ~onto:(upstream_branch pkg) in 206 169 207 - (* Cleanup *) 208 - Worktree.remove ~proc_mgr root (upstream_kind pkg); 209 - Worktree.remove ~proc_mgr root (vendor_kind pkg); 170 + (* Cleanup temp worktree *) 171 + ignore (Git.run ~proc_mgr ~cwd:git ["worktree"; "remove"; "-f"; temp_wt_relpath]); 210 172 211 - Backend.Updated { name = pkg; old_sha; new_sha } 173 + match result with 174 + | Ok () -> 175 + Backend.Updated { name = pkg; old_sha; new_sha } 176 + | Error (`Conflict hint) -> 177 + (* Abort the rebase and report conflict *) 178 + Git.rebase_abort ~proc_mgr ~cwd:git; 179 + Backend.Update_failed { name = pkg; error = "Rebase conflict: " ^ hint } 180 + end 212 181 end 213 182 end 214 183 with exn -> 215 - (try Worktree.remove_force ~proc_mgr root (upstream_kind pkg) with _ -> ()); 216 - (try Worktree.remove_force ~proc_mgr root (vendor_kind pkg) with _ -> ()); 217 184 Backend.Update_failed { name = pkg; error = Printexc.to_string exn } 218 185 219 186 let list_packages ~proc_mgr ~root =
+8 -13
lib/opam/opam.mli
··· 1 1 (** Opam backend for unpac. 2 2 3 - Implements vendoring of opam packages using the three-tier branch model: 3 + Implements vendoring of opam packages using a two-tier branch model: 4 4 - opam/upstream/<pkg> - pristine upstream code 5 - - opam/vendor/<pkg> - orphan branch with vendor/opam/<pkg>/ prefix 6 - - opam/patches/<pkg> - local modifications *) 5 + - opam/patches/<pkg> - local modifications on top of upstream 6 + 7 + Packages are merged into projects using git subtree, which places files 8 + under vendor/opam/<pkg>/ without rewriting upstream history. *) 7 9 8 10 val name : string 9 11 (** Backend name: "opam" *) ··· 12 14 13 15 val upstream_branch : string -> string 14 16 (** [upstream_branch pkg] returns "opam/upstream/<pkg>". *) 15 - 16 - val vendor_branch : string -> string 17 - (** [vendor_branch pkg] returns "opam/vendor/<pkg>". *) 18 17 19 18 val patches_branch : string -> string 20 19 (** [patches_branch pkg] returns "opam/patches/<pkg>". *) ··· 25 24 (** {1 Worktree Kinds} *) 26 25 27 26 val upstream_kind : string -> Unpac.Worktree.kind 28 - val vendor_kind : string -> Unpac.Worktree.kind 29 27 val patches_kind : string -> Unpac.Worktree.kind 30 28 31 29 (** {1 Package Operations} *) ··· 39 37 (** [add_package ~proc_mgr ~root ?cache info] vendors a single package. 40 38 41 39 1. Fetches upstream into opam/upstream/<pkg> (via cache if provided) 42 - 2. Creates opam/vendor/<pkg> with vendor/opam/<pkg>/ prefix (preserving history) 43 - 3. Creates opam/patches/<pkg> from vendor 40 + 2. Creates opam/patches/<pkg> from upstream (initially identical) 44 41 45 - Uses git-filter-repo for fast history rewriting. 42 + Use [git subtree add] to merge into a project. 46 43 @param cache Optional vendor cache for shared fetches across projects. *) 47 44 48 45 val update_package : ··· 54 51 (** [update_package ~proc_mgr ~root ?cache name] updates a package from upstream. 55 52 56 53 1. Fetches latest into opam/upstream/<pkg> (via cache if provided) 57 - 2. Updates opam/vendor/<pkg> with new content 58 - 59 - Does NOT rebase patches - call [Backend.rebase_patches] separately. 54 + 2. Rebases opam/patches/<pkg> onto the new upstream 60 55 61 56 @param cache Optional vendor cache for shared fetches across projects. *) 62 57
+54 -119
lib/promote.ml
··· 1 1 (** Project promotion to vendor library. 2 2 3 3 Promotes a locally-developed project to a vendored library by: 4 - 1. Filtering out the vendor/ directory from the project history 5 - 2. Creating vendor branches (upstream/vendor/patches) for the specified backend 6 - 3. Recording the promotion in the audit log 4 + 1. Using git subtree split to extract a subdirectory into a branch 5 + 2. Creating upstream/patches branches for the specified backend 7 6 8 - This allows the project to be merged into other projects as a dependency. *) 7 + This allows the project to be merged into other projects as a dependency 8 + using git subtree. *) 9 9 10 10 let src = Logs.Src.create "unpac.promote" ~doc:"Project promotion" 11 11 module Log = (val Logs.src_log src : Logs.LOG) ··· 29 29 | Opam -> "opam/upstream/" ^ name 30 30 | Git -> "git/upstream/" ^ name 31 31 32 - let vendor_branch backend name = match backend with 33 - | Opam -> "opam/vendor/" ^ name 34 - | Git -> "git/vendor/" ^ name 35 - 36 32 let patches_branch backend name = match backend with 37 33 | Opam -> "opam/patches/" ^ name 38 34 | Git -> "git/patches/" ^ name ··· 46 42 | Promoted of { 47 43 name : string; 48 44 backend : backend; 49 - original_commits : int; 50 - filtered_commits : int; 45 + source_prefix : string; 51 46 } 52 47 | Already_promoted of string 53 48 | Project_not_found of string 54 49 | Failed of { name : string; error : string } 55 50 56 - (** Filter a branch to exclude vendor/ directory. 57 - Uses git-filter-repo to rewrite history. *) 58 - let filter_vendor_directory ~proc_mgr ~cwd ~branch = 59 - Log.info (fun m -> m "Filtering vendor/ directory from branch %s..." branch); 60 - 61 - (* Use git-filter-repo with path filtering to exclude vendor/ *) 62 - let fs = fst cwd in 63 - let git_path = snd cwd in 64 - let parent_path = Filename.dirname git_path in 65 - 66 - (* Create a unique temporary worktree *) 67 - let safe_branch = String.map (fun c -> if c = '/' then '-' else c) branch in 68 - let temp_wt_name = ".filter-vendor-" ^ safe_branch in 69 - let temp_wt_relpath = "../" ^ temp_wt_name in 70 - let temp_wt_path = Filename.concat parent_path temp_wt_name in 71 - let temp_wt : Git.path = (fs, temp_wt_path) in 72 - 73 - (* Remove any existing temp worktree *) 74 - ignore (Git.run ~proc_mgr ~cwd ["worktree"; "remove"; "-f"; temp_wt_relpath]); 51 + (** Promote a project subdirectory to a vendored library using subtree split. 75 52 76 - (* Create worktree for the branch *) 77 - Git.run_exn ~proc_mgr ~cwd ["worktree"; "add"; temp_wt_relpath; branch] |> ignore; 53 + Unlike the old filter-repo approach, this extracts a specific subdirectory 54 + from the project into standalone branches that can be subtree'd elsewhere. 78 55 79 - (* Count commits before filtering *) 80 - let commits_before = 81 - int_of_string (String.trim (Git.run_exn ~proc_mgr ~cwd:temp_wt ["rev-list"; "--count"; "HEAD"])) 82 - in 83 - 84 - (* Run git-filter-repo to exclude vendor/ *) 85 - let result = Git.run ~proc_mgr ~cwd:temp_wt [ 86 - "filter-repo"; 87 - "--invert-paths"; 88 - "--path"; "vendor/"; 89 - "--force"; 90 - "--refs"; "HEAD" 91 - ] in 92 - 93 - match result with 94 - | Ok _ -> 95 - (* Count commits after filtering *) 96 - let commits_after = 97 - int_of_string (String.trim (Git.run_exn ~proc_mgr ~cwd:temp_wt ["rev-list"; "--count"; "HEAD"])) 98 - in 99 - (* Get the new HEAD SHA *) 100 - let new_sha = Git.run_exn ~proc_mgr ~cwd:temp_wt ["rev-parse"; "HEAD"] |> String.trim in 101 - (* Cleanup temporary worktree *) 102 - ignore (Git.run ~proc_mgr ~cwd ["worktree"; "remove"; "-f"; temp_wt_relpath]); 103 - (* Update the branch in the bare repo *) 104 - Git.run_exn ~proc_mgr ~cwd ["branch"; "-f"; branch; new_sha] |> ignore; 105 - Ok (commits_before, commits_after) 106 - | Error e -> 107 - (* Cleanup and return error *) 108 - ignore (Git.run ~proc_mgr ~cwd ["worktree"; "remove"; "-f"; temp_wt_relpath]); 109 - Error (Fmt.str "%a" Git.pp_error e) 110 - 111 - (** Promote a project to a vendored library *) 112 - let promote ~proc_mgr ~root ~project ~backend ~vendor_name = 56 + @param prefix The subdirectory path within the project to extract (e.g., "src/mylib") *) 57 + let promote ~proc_mgr ~root ~project ~backend ~vendor_name ~prefix = 113 58 let git = Worktree.git_dir root in 114 59 let name = Option.value ~default:project vendor_name in 115 60 ··· 123 68 Already_promoted name 124 69 else begin 125 70 try 126 - Log.info (fun m -> m "Promoting project %s as %s vendor %s..." project (backend_to_string backend) name); 71 + Log.info (fun m -> m "Promoting project %s prefix %s as %s vendor %s..." 72 + project prefix (backend_to_string backend) name); 127 73 128 74 let project_branch = Worktree.branch (Worktree.Project project) in 129 75 130 - (* Step 1: Create a temporary branch from the project for filtering *) 131 - let temp_branch = "promote-temp-" ^ name in 132 - Git.run_exn ~proc_mgr ~cwd:git ["branch"; "-f"; temp_branch; project_branch] |> ignore; 76 + (* Create a temporary worktree for the project to run subtree split *) 77 + let safe_name = String.map (fun c -> if c = '/' then '-' else c) project in 78 + let temp_wt_name = ".promote-tmp-" ^ safe_name in 79 + let temp_wt_relpath = "../" ^ temp_wt_name in 133 80 134 - (* Step 2: Filter out vendor/ directory from the temp branch *) 135 - let (commits_before, commits_after) = 136 - match filter_vendor_directory ~proc_mgr ~cwd:git ~branch:temp_branch with 137 - | Ok counts -> counts 138 - | Error msg -> 139 - (* Cleanup temp branch *) 140 - ignore (Git.run ~proc_mgr ~cwd:git ["branch"; "-D"; temp_branch]); 141 - failwith msg 142 - in 81 + let fs = fst git in 82 + let git_path = snd git in 83 + let parent_path = Filename.dirname git_path in 84 + let temp_wt_path = Filename.concat parent_path temp_wt_name in 85 + let temp_wt : Git.path = (fs, temp_wt_path) in 143 86 144 - Log.info (fun m -> m "Filtered %d -> %d commits" commits_before commits_after); 145 - 146 - (* Step 3: Create upstream branch (filtered, files at root) *) 147 - (* For local projects, upstream is the same as filtered temp - no external upstream *) 148 - Git.run_exn ~proc_mgr ~cwd:git ["branch"; "-f"; upstream_branch backend name; temp_branch] |> ignore; 87 + (* Remove any existing temp worktree *) 88 + ignore (Git.run ~proc_mgr ~cwd:git ["worktree"; "remove"; "-f"; temp_wt_relpath]); 149 89 150 - (* Step 4: Create vendor branch from upstream and rewrite to vendor path *) 151 - Git.run_exn ~proc_mgr ~cwd:git ["branch"; "-f"; vendor_branch backend name; upstream_branch backend name] |> ignore; 90 + (* Create worktree for the project branch *) 91 + Git.run_exn ~proc_mgr ~cwd:git ["worktree"; "add"; temp_wt_relpath; project_branch] |> ignore; 152 92 153 - (* Rewrite vendor branch to move files into vendor/<backend>/<name>/ *) 154 - Git.filter_repo_to_subdirectory ~proc_mgr ~cwd:git 155 - ~branch:(vendor_branch backend name) 156 - ~subdirectory:(vendor_path backend name); 93 + (* Use git subtree split to extract the prefix into a new branch *) 94 + let upstream_br = upstream_branch backend name in 95 + let result = Git.run ~proc_mgr ~cwd:temp_wt [ 96 + "subtree"; "split"; "--prefix"; prefix; "-b"; upstream_br 97 + ] in 157 98 158 - (* Step 5: Create patches branch from vendor *) 159 - Git.run_exn ~proc_mgr ~cwd:git ["branch"; patches_branch backend name; vendor_branch backend name] |> ignore; 99 + (* Cleanup temp worktree *) 100 + ignore (Git.run ~proc_mgr ~cwd:git ["worktree"; "remove"; "-f"; temp_wt_relpath]); 160 101 161 - (* Step 6: Cleanup temp branch *) 162 - ignore (Git.run ~proc_mgr ~cwd:git ["branch"; "-D"; temp_branch]); 102 + (match result with 103 + | Error e -> 104 + raise (Eio.Exn.create (Git.E e)) 105 + | Ok _ -> 106 + (* Create patches branch from upstream (initially identical) *) 107 + Git.branch_create ~proc_mgr ~cwd:git 108 + ~name:(patches_branch backend name) 109 + ~start_point:upstream_br; 163 110 164 - Promoted { 165 - name; 166 - backend; 167 - original_commits = commits_before; 168 - filtered_commits = commits_after 169 - } 111 + Promoted { 112 + name; 113 + backend; 114 + source_prefix = prefix; 115 + }) 170 116 with exn -> 171 117 (* Cleanup on failure *) 172 - let temp_branch = "promote-temp-" ^ name in 173 - ignore (Git.run ~proc_mgr ~cwd:git ["branch"; "-D"; temp_branch]); 174 118 ignore (Git.run ~proc_mgr ~cwd:git ["branch"; "-D"; upstream_branch backend name]); 175 - ignore (Git.run ~proc_mgr ~cwd:git ["branch"; "-D"; vendor_branch backend name]); 176 119 Failed { name = project; error = Printexc.to_string exn } 177 120 end 178 121 end ··· 301 244 | Already_exported of string 302 245 | Export_failed of { name : string; error : string } 303 246 304 - (** Export a vendored package back to root-level files. 305 - This is the inverse of vendoring - takes a vendor branch and creates 306 - an export branch with files moved from vendor/<backend>/<name>/ to root. 247 + (** Export a vendored package to an export branch for pushing. 307 248 308 - Can export from either vendor/* or patches/* branch. *) 309 - let export ~proc_mgr ~root ~name ~backend ~from_patches = 249 + In the new subtree architecture, upstream and patches branches already have 250 + files at root, so export is just creating a copy of the patches branch. *) 251 + let export ~proc_mgr ~root ~name ~backend = 310 252 let git = Worktree.git_dir root in 311 253 312 - (* Determine source branch *) 313 - let source_br = if from_patches then patches_branch backend name 314 - else vendor_branch backend name in 254 + (* Source is always the patches branch (which has files at root) *) 255 + let source_br = patches_branch backend name in 315 256 let export_br = export_branch backend name in 316 - let subdir = vendor_path backend name in 317 257 318 258 (* Check if source branch exists *) 319 259 if not (Git.branch_exists ~proc_mgr ~cwd:git source_br) then ··· 324 264 try 325 265 Log.info (fun m -> m "Exporting %s from %s to %s..." name source_br export_br); 326 266 327 - (* Step 1: Create export branch from source *) 328 - Git.run_exn ~proc_mgr ~cwd:git ["branch"; "-f"; export_br; source_br] |> ignore; 267 + (* Create export branch as a copy of patches branch *) 268 + Git.run_exn ~proc_mgr ~cwd:git ["branch"; export_br; source_br] |> ignore; 329 269 330 - (* Step 2: Count commits before transformation *) 270 + (* Count commits *) 331 271 let commits = 332 272 int_of_string (String.trim ( 333 273 Git.run_exn ~proc_mgr ~cwd:git ["rev-list"; "--count"; export_br])) 334 274 in 335 - 336 - (* Step 3: Rewrite export branch to move files from subdirectory to root *) 337 - Git.filter_repo_from_subdirectory ~proc_mgr ~cwd:git 338 - ~branch:export_br 339 - ~subdirectory:subdir; 340 275 341 276 Exported { 342 277 name;
+24 -32
lib/promote.mli
··· 1 1 (** Project promotion to vendor library. 2 2 3 3 Promotes a locally-developed project to a vendored library by: 4 - 1. Filtering out the vendor/ directory from the project history 5 - 2. Creating vendor branches (upstream/vendor/patches) for the specified backend 6 - 3. Recording the promotion in the audit log 4 + 1. Using git subtree split to extract a subdirectory into a branch 5 + 2. Creating upstream/patches branches for the specified backend 7 6 8 - This allows the project to be merged into other projects as a dependency. *) 7 + This allows the project to be merged into other projects as a dependency 8 + using git subtree. *) 9 9 10 10 (** {1 Backend Types} *) 11 11 ··· 26 26 (** [upstream_branch backend name] returns the upstream branch name, 27 27 e.g., "opam/upstream/brotli" or "git/upstream/brotli" *) 28 28 29 - val vendor_branch : backend -> string -> string 30 - (** [vendor_branch backend name] returns the vendor branch name *) 31 - 32 29 val patches_branch : backend -> string -> string 33 30 (** [patches_branch backend name] returns the patches branch name *) 34 31 ··· 43 40 | Promoted of { 44 41 name : string; (** Vendor library name *) 45 42 backend : backend; (** Backend used *) 46 - original_commits : int; (** Commits in project before filtering *) 47 - filtered_commits : int; (** Commits after removing vendor/ *) 43 + source_prefix : string; (** Prefix extracted from project *) 48 44 } 49 45 | Already_promoted of string 50 46 (** Library already exists with this name *) ··· 59 55 project:string -> 60 56 backend:backend -> 61 57 vendor_name:string option -> 58 + prefix:string -> 62 59 promote_result 63 - (** [promote ~proc_mgr ~root ~project ~backend ~vendor_name] promotes 64 - a local project to a vendored library. 60 + (** [promote ~proc_mgr ~root ~project ~backend ~vendor_name ~prefix] promotes 61 + a project subdirectory to a vendored library using subtree split. 65 62 66 63 The operation: 67 64 1. Checks that the project exists and hasn't been promoted yet 68 - 2. Creates a filtered copy of project history (excluding vendor/) 69 - 3. Creates upstream/vendor/patches branches for the backend 65 + 2. Uses [git subtree split] to extract the prefix into a branch 66 + 3. Creates upstream/patches branches for the backend 70 67 4. The original project branch is preserved unchanged 71 68 72 - @param project Name of the project to promote (e.g., "brotli") 69 + @param project Name of the project to promote (e.g., "myapp") 73 70 @param backend Backend type (Opam or Git) 74 71 @param vendor_name Optional override for the vendor library name 72 + @param prefix The subdirectory path within the project to extract (e.g., "src/mylib") 75 73 76 74 After promotion, the library can be merged into other projects using: 77 75 - [unpac opam merge <name> <project>] for Opam backend ··· 142 140 (** [get_info ~proc_mgr ~root ~project] returns information about a project, 143 141 or None if the project doesn't exist. *) 144 142 145 - (** {1 Export (Unvendor)} 146 - 147 - Export reverses the vendoring process, creating a branch with files 148 - at the repository root suitable for pushing to an external git repo. 143 + (** {1 Export} 149 144 150 - This is the inverse of vendoring: 151 - - Vendoring: files at root โ†’ files in vendor/<backend>/<name>/ 152 - - Exporting: files in vendor/<backend>/<name>/ โ†’ files at root *) 145 + Export creates a branch suitable for pushing to an external git repo. 146 + In the subtree architecture, patches branches already have files at root, 147 + so export is simply a copy of the patches branch. *) 153 148 154 149 val export_branch : backend -> string -> string 155 150 (** [export_branch backend name] returns the export branch name, ··· 160 155 | Exported of { 161 156 name : string; (** Package name *) 162 157 backend : backend; (** Backend used *) 163 - source_branch : string; (** Branch exported from (vendor or patches) *) 158 + source_branch : string; (** Branch exported from (patches) *) 164 159 export_branch : string; (** Created export branch *) 165 160 commits : int; (** Number of commits in export *) 166 161 } 167 162 | Not_vendored of string 168 - (** No vendor branch exists for this package *) 163 + (** No patches branch exists for this package *) 169 164 | Already_exported of string 170 165 (** Export branch already exists *) 171 166 | Export_failed of { name : string; error : string } ··· 176 171 root:Worktree.root -> 177 172 name:string -> 178 173 backend:backend -> 179 - from_patches:bool -> 180 174 export_result 181 - (** [export ~proc_mgr ~root ~name ~backend ~from_patches] exports a vendored 182 - package back to root-level files. 175 + (** [export ~proc_mgr ~root ~name ~backend] exports a vendored 176 + package to an export branch for pushing. 183 177 184 - Creates an export branch where files are moved from [vendor/<backend>/<name>/] 185 - to the repository root. This branch can then be pushed to an upstream repo. 178 + Creates an export branch as a copy of the patches branch. 179 + This branch can then be pushed to an upstream repo. 186 180 187 181 @param name The vendored package name 188 182 @param backend The backend (Opam or Git) 189 - @param from_patches If true, exports from patches/* branch (includes local mods); 190 - if false, exports from vendor/* branch (pristine upstream) 191 183 192 184 The export branch is named [<backend>/export/<name>], e.g., "git/export/brotli". 193 185 194 186 Example workflow: 195 187 {[ 196 - (* Export with local patches *) 197 - export ~from_patches:true ... 188 + (* Export *) 189 + export ... 198 190 199 191 (* Set remote and push *) 200 192 set_export_remote ~url:"git@github.com:me/brotli.git" ...
+4 -9
lib/worktree.ml
··· 12 12 | Main 13 13 | Project of string 14 14 | Opam_upstream of string 15 - | Opam_vendor of string 16 15 | Opam_patches of string 17 16 | Git_upstream of string 18 - | Git_vendor of string 19 17 | Git_patches of string 20 18 (** Worktree kinds with their associated names. 21 19 Opam_* variants are for opam package vendoring. 22 - Git_* variants are for direct git repository vendoring. *) 20 + Git_* variants are for direct git repository vendoring. 21 + 22 + Note: In the subtree architecture, upstream and patches are branches only 23 + (no worktrees). These variants are kept for branch name computation. *) 23 24 24 25 (** {1 Path and Branch Helpers} *) 25 26 ··· 30 31 | Main -> Eio.Path.(root / "main") 31 32 | Project name -> Eio.Path.(root / "project" / name) 32 33 | Opam_upstream name -> Eio.Path.(root / "opam" / "upstream" / name) 33 - | Opam_vendor name -> Eio.Path.(root / "opam" / "vendor" / name) 34 34 | Opam_patches name -> Eio.Path.(root / "opam" / "patches" / name) 35 35 | Git_upstream name -> Eio.Path.(root / "git-repos" / "upstream" / name) 36 - | Git_vendor name -> Eio.Path.(root / "git-repos" / "vendor" / name) 37 36 | Git_patches name -> Eio.Path.(root / "git-repos" / "patches" / name) 38 37 39 38 let branch = function 40 39 | Main -> "main" 41 40 | Project name -> "project/" ^ name 42 41 | Opam_upstream name -> "opam/upstream/" ^ name 43 - | Opam_vendor name -> "opam/vendor/" ^ name 44 42 | Opam_patches name -> "opam/patches/" ^ name 45 43 | Git_upstream name -> "git/upstream/" ^ name 46 - | Git_vendor name -> "git/vendor/" ^ name 47 44 | Git_patches name -> "git/patches/" ^ name 48 45 49 46 let relative_path = function 50 47 | Main -> "main" 51 48 | Project name -> "project/" ^ name 52 49 | Opam_upstream name -> "opam/upstream/" ^ name 53 - | Opam_vendor name -> "opam/vendor/" ^ name 54 50 | Opam_patches name -> "opam/patches/" ^ name 55 51 | Git_upstream name -> "git-repos/upstream/" ^ name 56 - | Git_vendor name -> "git-repos/vendor/" ^ name 57 52 | Git_patches name -> "git-repos/patches/" ^ name 58 53 59 54 (** {1 Queries} *)
+21 -22
lib/worktree.mli
··· 1 1 (** Git worktree lifecycle management for unpac. 2 2 3 3 Manages creation, cleanup, and paths of worktrees within the unpac 4 - directory structure. All branch operations happen in isolated worktrees. 4 + directory structure. Project branches get isolated worktrees. 5 5 6 6 {2 Directory Structure} 7 7 8 8 An unpac project has this layout: 9 9 {v 10 10 my-project/ 11 - โ”œโ”€โ”€ git/ # Bare repository 11 + โ”œโ”€โ”€ git/ # Bare repository (stores all branches) 12 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 - โ””โ”€โ”€ git-repos/ 23 - โ”œโ”€โ”€ upstream/ 24 - โ”‚ โ””โ”€โ”€ repo/ # Worktree โ†’ git/upstream/repo 25 - โ”œโ”€โ”€ vendor/ 26 - โ”‚ โ””โ”€โ”€ repo/ # Worktree โ†’ git/vendor/repo 27 - โ””โ”€โ”€ patches/ 28 - โ””โ”€โ”€ repo/ # Worktree โ†’ git/patches/repo 29 - v} *) 13 + โ””โ”€โ”€ project/ 14 + โ””โ”€โ”€ myapp/ # Worktree โ†’ project/myapp 15 + v} 16 + 17 + {2 Branch Structure} 18 + 19 + Branches are organized as: 20 + - [main] - main branch with unpac.toml configuration 21 + - [project/<name>] - project branches (have worktrees) 22 + - [opam/upstream/<pkg>] - pristine upstream for opam packages (branch only) 23 + - [opam/patches/<pkg>] - local modifications for opam packages (branch only) 24 + - [git/upstream/<name>] - pristine upstream for git repos (branch only) 25 + - [git/patches/<name>] - local modifications for git repos (branch only) 26 + 27 + Upstream and patches branches are merged into projects using git subtree. *) 30 28 31 29 (** {1 Types} *) 32 30 ··· 37 35 | Main 38 36 | Project of string 39 37 | Opam_upstream of string 40 - | Opam_vendor of string 41 38 | Opam_patches of string 42 39 | Git_upstream of string 43 - | Git_vendor of string 44 40 | Git_patches of string 45 41 (** Worktree kinds with their associated names. 46 42 Opam_* variants are for opam package vendoring. 47 - Git_* variants are for direct git repository vendoring. *) 43 + Git_* variants are for direct git repository vendoring. 44 + 45 + Note: In the subtree architecture, upstream and patches are branches only 46 + (no worktrees). These variants are kept for branch name computation. *) 48 47 49 48 (** {1 Path and Branch Helpers} *) 50 49
+5 -5
test/cram/full-workflow.t
··· 28 28 Initialize unpac project 29 29 30 30 $ unpac init myproj 31 - Initialized unpac project at myproj 31 + Initialized unpac workspace at myproj 32 32 33 33 Next steps: 34 34 cd myproj ··· 53 53 54 54 Create a project 55 55 56 - $ unpac project new myapp 56 + $ unpac project new myapp 2>&1 | grep -v "^\[INFO\]\|unpac: \[INFO\]" 57 57 Created project myapp 58 58 59 59 Next steps: ··· 88 88 $ unpac opam edit testlib 2>&1 | head -1 89 89 Editing testlib 90 90 91 - Make a change in the patches worktree 91 + Make a change in the patches worktree (files are at root, not under vendor/) 92 92 93 - $ echo 'let goodbye () = "Goodbye!"' >> opam/patches/testlib/vendor/opam/testlib/lib.ml 93 + $ echo 'let goodbye () = "Goodbye!"' >> opam/patches/testlib/lib.ml 94 94 $ (cd opam/patches/testlib && git add -A && git commit -q -m "Add goodbye function") 95 95 96 96 Now we have local changes ··· 113 113 Merge into project 114 114 115 115 $ unpac opam merge testlib myapp 2>&1 | grep -E "^(Merged|Merge)" 116 - Merged testlib into project myapp 116 + Merged testlib to vendor/opam/testlib 117 117 118 118 Verify files in project 119 119
+2 -2
test/cram/init.t
··· 1 1 Initialize a new unpac project 2 2 3 3 $ unpac init myproject 4 - Initialized unpac project at myproject 4 + Initialized unpac workspace at myproject 5 5 6 6 Next steps: 7 7 cd myproject ··· 29 29 Check git branches (main is in worktree so shows +) 30 30 31 31 $ git -C myproject/git branch 32 - + main 32 + * main 33 33 34 34 Init should fail if directory exists 35 35
+8 -9
test/cram/opam.t
··· 21 21 Create unpac project 22 22 23 23 $ unpac init myproj 24 - Initialized unpac project at myproj 24 + Initialized unpac workspace at myproj 25 25 26 26 Next steps: 27 27 cd myproj ··· 51 51 $ git -C git branch | grep opam | sort 52 52 opam/patches/testpkg 53 53 opam/upstream/testpkg 54 - opam/vendor/testpkg 55 54 56 - Check vendor branch has prefixed content 55 + Check patches branch has content at root (no vendor/ prefix in branch) 57 56 58 - $ git -C git show opam/vendor/testpkg --name-only | grep "^vendor/" 59 - vendor/opam/testpkg/dune-project 60 - vendor/opam/testpkg/lib.ml 57 + $ git -C git show opam/patches/testpkg --name-only | tail -2 58 + dune-project 59 + lib.ml 61 60 62 61 Create a project and merge 63 62 64 - $ unpac project new myapp 63 + $ unpac project new myapp 2>&1 | grep -v "^\[INFO\]\|unpac: \[INFO\]" 65 64 Created project myapp 66 65 67 66 Next steps: ··· 69 68 unpac opam merge <package> myapp # merge package into project 70 69 71 70 $ unpac opam merge testpkg myapp 2>&1 | grep -E "^(Merged|Merge conflict)" 72 - Merged testpkg into project myapp 71 + Merged testpkg to vendor/opam/testpkg 73 72 74 73 Check files appear in project 75 74 ··· 82 81 83 82 Check git log shows merge 84 83 85 - $ git -C project/myapp log --oneline | wc -l 84 + $ git -C project/myapp log --oneline | wc -l | tr -d ' ' 86 85 3
+4 -3
test/cram/project.t
··· 3 3 Setup: create an unpac project first 4 4 5 5 $ unpac init testproj 6 - Initialized unpac project at testproj 6 + Initialized unpac workspace at testproj 7 7 8 8 Next steps: 9 9 cd testproj ··· 13 13 14 14 Create a new project 15 15 16 - $ unpac project new myapp 16 + $ unpac project new myapp 2>&1 | grep -v "^\[INFO\]\|unpac: \[INFO\]" 17 17 Created project myapp 18 18 19 19 Next steps: ··· 39 39 Check vendor directory structure 40 40 41 41 $ ls project/myapp/vendor 42 + dune 42 43 opam 43 44 44 45 List projects ··· 53 54 54 55 Create another project 55 56 56 - $ unpac project new otherapp 57 + $ unpac project new otherapp 2>&1 | grep -v "^\[INFO\]\|unpac: \[INFO\]" 57 58 Created project otherapp 58 59 59 60 Next steps:
+1 -1
unpac-claude.opam
··· 6 6 authors: ["Anil Madhavapeddy"] 7 7 license: "ISC" 8 8 depends: [ 9 - "dune" {>= "3.20"} 9 + "dune" {>= "3.18"} 10 10 "ocaml" {>= "5.1.0"} 11 11 "unpac" 12 12 "claude"
+1 -1
unpac-opam.opam
··· 5 5 authors: ["Anil Madhavapeddy"] 6 6 license: "ISC" 7 7 depends: [ 8 - "dune" {>= "3.20"} 8 + "dune" {>= "3.18"} 9 9 "ocaml" {>= "5.1.0"} 10 10 "unpac" 11 11 "opam-format"
+3 -3
unpac.opam
··· 2 2 opam-version: "2.0" 3 3 synopsis: "Monorepo management tool" 4 4 description: 5 - "A tool for managing OCaml monorepos with opam repository integration" 5 + "A tool for managing OCaml monorepos with opam and git repository integration" 6 6 authors: ["Anil Madhavapeddy"] 7 7 license: "ISC" 8 8 depends: [ 9 - "dune" {>= "3.20"} 9 + "dune" {>= "3.18"} 10 10 "ocaml" {>= "5.1.0"} 11 11 "eio_main" {>= "1.0"} 12 12 "logs" {>= "0.7.0"} 13 13 "fmt" {>= "0.9.0"} 14 14 "tomlt" 15 - "jsont" {>= "0.1.0"} 15 + "jsont" {>= "0.2.0"} 16 16 "odoc" {with-doc} 17 17 ] 18 18 build: [