···11+# This is a basic workflow to help you get started with Actions
22+33+name: CI
44+55+# Controls when the workflow will run
66+on:
77+ # Triggers the workflow on push or pull request events but only for the "main" branch
88+ push:
99+ branches: [ "project/unpac" ]
1010+ pull_request:
1111+ branches: [ "project/unpac" ]
1212+1313+ # Allows you to run this workflow manually from the Actions tab
1414+ workflow_dispatch:
1515+1616+# A workflow run is made up of one or more jobs that can run sequentially or in parallel
1717+jobs:
1818+ # This workflow contains a single job called "build"
1919+ build:
2020+ # The type of runner that the job will run on
2121+ runs-on: ubuntu-latest
2222+2323+ # Steps represent a sequence of tasks that will be executed as part of the job
2424+ steps:
2525+ # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
2626+ - uses: actions/checkout@v4
2727+2828+ - name: Set-up OCaml
2929+ uses: ocaml/setup-ocaml@v3
3030+ with:
3131+ ocaml-compiler: 5
3232+3333+ - name: Install dune
3434+ run: opam install -y dune
3535+3636+ - name: Build unpac
3737+ run: opam exec -- dune build
···2222โ โ โโโ dune
2323โ โโโ feature-x/ # Worktree โ project/feature-x
2424โโโ opam/ # On-demand worktrees for opam backend
2525-โ โโโ upstream/
2626-โ โ โโโ astring/ # Temporary: during add/update
2727-โ โโโ vendor/
2828-โ โ โโโ astring/ # Temporary: during add/update
2925โ โโโ patches/
3026โ โโโ astring/ # On-demand: created for editing
3127โโโ cargo/ # Future: cargo backend worktrees
···4137โ
4238โโโ opam/upstream/astring # Pristine upstream (files at root)
4339โโโ opam/upstream/eio
4444-โโโ opam/vendor/astring # Orphan, files under vendor/opam/astring/
4545-โโโ opam/vendor/eio
4646-โโโ opam/patches/astring # Forked from vendor, local modifications
4040+โโโ opam/patches/astring # Forked from upstream, local modifications
4741โโโ opam/patches/eio
4842โ
4943โโโ cargo/... # Future
···6357โโโ astring.opam
6458```
65596666-### `opam/vendor/<pkg>` (orphan branch)
6767-6868-Files relocated under `vendor/opam/<pkg>/` prefix for conflict-free merging:
6969-7070-```
7171-(root)
7272-โโโ vendor/
7373- โโโ opam/
7474- โโโ astring/
7575- โโโ src/
7676- โ โโโ astring.ml
7777- โโโ dune
7878- โโโ astring.opam
7979-```
8080-8160### `opam/patches/<pkg>`
82618383-Forked from vendor branch. Same structure, may contain local modifications:
6262+Forked from upstream branch. Files at root, may contain local modifications.
6363+When merged into a project, git subtree places files under `vendor/opam/<pkg>/`:
84648565```
8666(root)
8787-โโโ vendor/
8888- โโโ opam/
8989- โโโ astring/
9090- โโโ src/
9191- โ โโโ astring.ml # May be patched
9292- โโโ dune
9393- โโโ astring.opam
6767+โโโ src/
6868+โ โโโ astring.ml # May be patched
6969+โโโ dune
7070+โโโ astring.opam
9471```
95729673### `project/<name>` (orphan branch)
···215192| Vendored packages (global) | List `opam/patches/*` branches |
216193| Packages in a project | List `vendor/opam/*/` directories |
217194| Package versions | Commit metadata on `opam/upstream/*` |
218218-| Patch status | Diff `opam/patches/*` vs `opam/vendor/*` |
195195+| Patch status | Diff `opam/patches/*` vs `opam/upstream/*` |
219196| Merge status | Git merge history |
220197221198## Workflows
···2532302. If cache configured: fetch upstream โ cache โ project
2542313. If no cache: fetch upstream โ project directly
2552324. Create `opam/upstream/astring` branch
256256-5. Create `opam/vendor/astring` orphan branch (with vendor/opam/astring/ prefix)
257257-6. Create `opam/patches/astring` branch (from vendor)
258258-7. Cleanup temporary worktrees
233233+5. Create `opam/patches/astring` branch (from upstream)
259234260235### Add Package (by name)
261236···3002752. Compare old vs new upstream SHA
3012763. If changed:
302277 - Update `opam/upstream/astring` branch
303303- - Update `opam/vendor/astring` with new content
304304-4. Note: patches branch must be rebased separately
278278+ - Rebase `opam/patches/astring` onto new upstream (or report conflicts)
305279306280### Edit Patches
307281···310284```
3112853122861. Create worktree `opam/patches/astring/`
313313-2. User edits files in `vendor/opam/astring/`
287287+2. User edits files (at root of worktree)
3142883. User commits changes
315289316290```bash
···326300unpac opam diff astring
327301```
328302329329-Shows diff between `opam/vendor/astring` and `opam/patches/astring`.
303303+Shows diff between `opam/upstream/astring` and `opam/patches/astring`.
330304331305### Remove Package
332306···335309```
3363103373111. Remove any existing worktrees
338338-2. Delete `opam/upstream/astring`, `opam/vendor/astring`, `opam/patches/astring` branches
312312+2. Delete `opam/upstream/astring`, `opam/patches/astring` branches
3393133. Remove remote `origin-astring`
340314341315## Module Structure
···373347 | Main
374348 | Project of string
375349 | Opam_upstream of string
376376- | Opam_vendor of string
377350 | Opam_patches of string
378351379352val path : root -> kind -> Eio.Fs.dir_ty Eio.Path.t
···481454 Show package information
482455483456unpac opam diff <pkg>
484484- Show local changes (patches vs vendor)
457457+ Show local changes (patches vs upstream)
485458486459unpac opam edit <pkg>
487460 Create patches worktree for editing
···516489517490```
518491cargo/upstream/<crate> # Pristine from crates.io
519519-cargo/vendor/<crate> # Files under vendor/cargo/<crate>/
520520-cargo/patches/<crate> # Local modifications
492492+cargo/patches/<crate> # Local modifications on top of upstream
521493```
522494523495With corresponding worktree paths:
···525497```
526498my-project/
527499โโโ cargo/
528528-โ โโโ upstream/
529529-โ โ โโโ serde/
530530-โ โโโ vendor/
531531-โ โ โโโ serde/
532500โ โโโ patches/
533533-โ โโโ serde/
501501+โ โโโ serde/ # On-demand: created for editing
534502โโโ project/
535503 โโโ myapp/
536504 โโโ vendor/
537505 โโโ opam/
538538- โ โโโ eio/
506506+ โ โโโ eio/ # Merged via git subtree
539507 โโโ cargo/
540540- โโโ serde/
508508+ โโโ serde/ # Merged via git subtree
541509```
+5-6
CLI.md
···121121unpac opam add https://github.com/dbuenzli/cmdliner.git --name cmdliner
122122```
123123124124-This creates three branches:
124124+This creates two branches:
125125- `opam/upstream/<pkg>` - pristine upstream code
126126-- `opam/vendor/<pkg>` - code with `vendor/opam/<pkg>/` path prefix
127127-- `opam/patches/<pkg>` - your local modifications (initially same as vendor)
126126+- `opam/patches/<pkg>` - your local modifications (initially same as upstream)
128127129128#### `unpac opam list`
130129···169168170169This:
1711701. Fetches latest from upstream
172172-2. Updates `opam/upstream/<pkg>` and `opam/vendor/<pkg>`
173173-3. Prints instructions for rebasing patches if needed
171171+2. Updates `opam/upstream/<pkg>`
172172+3. Rebases `opam/patches/<pkg>` onto the new upstream (or prints conflict instructions)
174173175174#### `unpac opam rebase <package>`
176175177177-Rebase your patches onto the updated vendor branch.
176176+Rebase your patches onto the updated upstream branch.
178177179178```bash
180179unpac opam rebase cmdliner
+120-160
bin/main.ml
···145145 git/ # Git repository worktrees
146146 project/ # Project worktrees";
147147 `P "The workspace uses git worktrees to maintain isolated views of \
148148- vendored dependencies. Each vendored item has three branches:";
148148+ vendored dependencies. Each vendored item has two branches:";
149149 `I ("upstream/*", "Tracks original repository state");
150150- `I ("vendor/*", "Clean snapshot for merging");
151151- `I ("patches/*", "Local modifications");
150150+ `I ("patches/*", "Local modifications on top of upstream");
152151 `S Manpage.s_examples;
153152 `P "Create a new workspace:";
154153 `Pre " unpac init my-project
···262261 let doc = "Override the vendor library name (defaults to project name)." in
263262 Arg.(value & opt (some string) None & info ["name"; "n"] ~docv:"NAME" ~doc)
264263 in
265265- let run () project backend_str vendor_name =
264264+ let prefix_arg =
265265+ let doc = "Subdirectory path within the project to extract (e.g., 'src/mylib')." in
266266+ Arg.(required & opt (some string) None & info ["prefix"; "p"] ~docv:"PATH" ~doc)
267267+ in
268268+ let run () project backend_str vendor_name prefix =
266269 with_root @@ fun ~env:_ ~fs:_ ~proc_mgr ~root ->
267270 (* Parse backend *)
268271 let backend = match Unpac.Promote.backend_of_string backend_str with
···273276 in
274277 with_audit ~proc_mgr ~root ~operation_type:Unpac.Audit.Project_promote
275278 ~args:(
276276- [project; "--backend"; backend_str] @
279279+ [project; "--backend"; backend_str; "--prefix"; prefix] @
277280 (match vendor_name with Some n -> ["--name"; n] | None -> [])
278281 ) @@ fun _ctx ->
279279- match Unpac.Promote.promote ~proc_mgr ~root ~project ~backend ~vendor_name with
280280- | Unpac.Promote.Promoted { name; backend; original_commits; filtered_commits } ->
282282+ match Unpac.Promote.promote ~proc_mgr ~root ~project ~backend ~vendor_name ~prefix with
283283+ | Unpac.Promote.Promoted { name; backend; source_prefix } ->
281284 Format.printf "Promoted %s as %s vendor@." project (Unpac.Promote.backend_to_string backend);
282282- Format.printf "@.Filtered history: %d โ %d commits (removed vendor/ directory)@."
283283- original_commits filtered_commits;
285285+ Format.printf "@.Extracted from prefix: %s@." source_prefix;
284286 Format.printf "@.Created branches:@.";
285287 Format.printf " %s@." (Unpac.Promote.upstream_branch backend name);
286286- Format.printf " %s@." (Unpac.Promote.vendor_branch backend name);
287288 Format.printf " %s@." (Unpac.Promote.patches_branch backend name);
288289 Format.printf "@.%s can now be merged into other projects:@." name;
289290 (match backend with
···302303 exit 1
303304 in
304305 let info = Cmd.info "promote" ~doc ~man in
305305- Cmd.v info Term.(const run $ logging_term $ name_arg $ backend_arg $ vendor_name_arg)
306306+ Cmd.v info Term.(const run $ logging_term $ name_arg $ backend_arg $ vendor_name_arg $ prefix_arg)
306307307308(* Project set-remote command *)
308309let project_set_remote_cmd =
···465466 let doc = "Vendor backend type: opam or git." in
466467 Arg.(required & opt (some string) None & info ["backend"; "b"] ~docv:"BACKEND" ~doc)
467468 in
468468- let from_patches_arg =
469469- let doc = "Export from patches/* branch (includes local modifications) \
470470- instead of vendor/* branch (pristine upstream)." in
471471- Arg.(value & flag & info ["from-patches"; "p"] ~doc)
472472- in
473473- let run () name backend_str from_patches =
469469+ let run () name backend_str =
474470 with_root @@ fun ~env:_ ~fs:_ ~proc_mgr ~root ->
475471 let backend = match Unpac.Promote.backend_of_string backend_str with
476472 | Some b -> b
···478474 Format.eprintf "Error: Unknown backend '%s'. Use 'opam' or 'git'.@." backend_str;
479475 exit 1
480476 in
481481- match Unpac.Promote.export ~proc_mgr ~root ~name ~backend ~from_patches with
477477+ match Unpac.Promote.export ~proc_mgr ~root ~name ~backend with
482478 | Unpac.Promote.Exported { name; backend; source_branch; export_branch; commits } ->
483479 Format.printf "Exported %s (%s backend)@." name (Unpac.Promote.backend_to_string backend);
484480 Format.printf " Source: %s@." source_branch;
485481 Format.printf " Export: %s (%d commits)@." export_branch commits;
486486- Format.printf "@.Files moved from vendor/%s/%s/ to repository root.@."
487487- (Unpac.Promote.backend_to_string backend) name;
488482 Format.printf "@.Next steps:@.";
489483 Format.printf " unpac export-set-remote %s <url>@." name;
490484 Format.printf " unpac export-push %s --backend %s@." name backend_str
491485 | Unpac.Promote.Not_vendored name ->
492492- Format.eprintf "Error: No vendor branch found for '%s'.@." name;
486486+ Format.eprintf "Error: No patches branch found for '%s'.@." name;
493487 Format.eprintf "Check available packages with: unpac opam list / unpac git list@.";
494488 exit 1
495489 | Unpac.Promote.Already_exported name ->
···502496 exit 1
503497 in
504498 let info = Cmd.info "export" ~doc ~man in
505505- Cmd.v info Term.(const run $ logging_term $ name_arg $ backend_arg $ from_patches_arg)
499499+ Cmd.v info Term.(const run $ logging_term $ name_arg $ backend_arg)
506500507501(* Export set-remote command *)
508502let export_set_remote_cmd =
···980974981975(* Opam edit command *)
982976let opam_edit_cmd =
983983- let doc = "Open a package's patches worktree for editing. \
984984- Also creates a vendor worktree for reference." in
977977+ let doc = "Open a package's patches worktree for editing." in
985978 let pkg_arg =
986979 let doc = "Package name to edit." in
987980 Arg.(required & pos 0 (some string) None & info [] ~docv:"PACKAGE" ~doc)
···994987 Format.eprintf "Package '%s' is not vendored@." pkg;
995988 exit 1
996989 end;
997997- (* Ensure both patches and vendor worktrees exist *)
990990+ (* Create patches worktree for editing *)
998991 Unpac.Worktree.ensure ~proc_mgr root (Unpac.Worktree.Opam_patches pkg);
999999- Unpac.Worktree.ensure ~proc_mgr root (Unpac.Worktree.Opam_vendor pkg);
1000992 let patches_path = snd (Unpac.Worktree.path root (Unpac.Worktree.Opam_patches pkg)) in
10011001- let vendor_path = snd (Unpac.Worktree.path root (Unpac.Worktree.Opam_vendor pkg)) in
993993+ let upstream_branch = Unpac_opam.Opam.upstream_branch pkg in
1002994 Format.printf "Editing %s@." pkg;
1003995 Format.printf "@.";
10041004- Format.printf "Worktrees created:@.";
10051005- Format.printf " patches: %s (make changes here)@." patches_path;
10061006- Format.printf " vendor: %s (original for reference)@." vendor_path;
996996+ Format.printf "Worktree created:@.";
997997+ Format.printf " patches: %s@." patches_path;
998998+ Format.printf "@.";
999999+ Format.printf "To compare with upstream:@.";
10001000+ Format.printf " git diff %s..HEAD (from patches worktree)@." upstream_branch;
10071001 Format.printf "@.";
10081008- Format.printf "Make your changes in the patches worktree, then:@.";
10021002+ Format.printf "Make your changes, then:@.";
10091003 Format.printf " cd %s@." patches_path;
10101004 Format.printf " git add -A && git commit -m 'your message'@.";
10111005 Format.printf "@.";
···1016101010171011(* Opam done command *)
10181012let opam_done_cmd =
10191019- let doc = "Close a package's patches and vendor worktrees after editing." in
10131013+ let doc = "Close a package's patches worktree after editing." in
10201014 let pkg_arg =
10211015 let doc = "Package name." in
10221016 Arg.(required & pos 0 (some string) None & info [] ~docv:"PACKAGE" ~doc)
···10241018 let run () pkg =
10251019 with_root @@ fun ~env:_ ~fs:_ ~proc_mgr ~root ->
10261020 let patches_kind = Unpac.Worktree.Opam_patches pkg in
10271027- let vendor_kind = Unpac.Worktree.Opam_vendor pkg in
10281021 if not (Unpac.Worktree.exists root patches_kind) then begin
10291022 Format.eprintf "No editing session for '%s'@." pkg;
10301023 exit 1
···10371030 Format.eprintf "Commit or discard them before closing.@.";
10381031 exit 1
10391032 end;
10401040- (* Remove both worktrees *)
10331033+ (* Remove patches worktree *)
10411034 Unpac.Worktree.remove ~proc_mgr root patches_kind;
10421042- if Unpac.Worktree.exists root vendor_kind then
10431043- Unpac.Worktree.remove ~proc_mgr root vendor_kind;
10441035 Format.printf "Closed editing session for %s@." pkg;
10451036 Format.printf "@.Next steps:@.";
10461037 Format.printf " unpac opam diff %s # view your changes@." pkg;
···1137112811381129 let merge_one ~project pkg =
11391130 let patches_branch = Unpac_opam.Opam.patches_branch pkg in
11401140- match Unpac.Backend.merge_to_project ~proc_mgr ~root ~project ~patches_branch with
11411141- | Ok () ->
11421142- Format.printf "Merged %s@." pkg;
11311131+ let prefix = Unpac_opam.Opam.vendor_path pkg in
11321132+ match Unpac.Backend.merge_to_project ~proc_mgr ~root ~project ~patches_branch ~prefix with
11331133+ | Unpac.Git.Subtree_ok ->
11341134+ Format.printf "Merged %s to %s@." pkg prefix;
11431135 true
11441144- | Error (`Conflict files) ->
11361136+ | Unpac.Git.Subtree_conflict files ->
11451137 Format.eprintf "Merge conflict in %s:@." pkg;
11461138 List.iter (Format.eprintf " %s@.") files;
11471139 false
···12641256 | None -> ());
12651257 (* Get branch SHAs *)
12661258 let upstream = Unpac_opam.Opam.upstream_branch pkg in
12671267- let vendor = Unpac_opam.Opam.vendor_branch pkg in
12681259 let patches = Unpac_opam.Opam.patches_branch pkg in
12691260 (match Unpac.Git.rev_parse ~proc_mgr ~cwd:git upstream with
12701261 | Some sha -> Format.printf "Upstream: %s@." (String.sub sha 0 7)
12711271- | None -> ());
12721272- (match Unpac.Git.rev_parse ~proc_mgr ~cwd:git vendor with
12731273- | Some sha -> Format.printf "Vendor: %s@." (String.sub sha 0 7)
12741262 | None -> ());
12751263 (match Unpac.Git.rev_parse ~proc_mgr ~cwd:git patches with
12761264 | Some sha -> Format.printf "Patches: %s@." (String.sub sha 0 7)
12771265 | None -> ());
12781278- (* Count commits ahead *)
12661266+ (* Count commits ahead of upstream *)
12791267 let log_output = Unpac.Git.run_exn ~proc_mgr ~cwd:git
12801280- ["log"; "--oneline"; vendor ^ ".." ^ patches] in
12681268+ ["log"; "--oneline"; upstream ^ ".." ^ patches] in
12811269 let commits = List.length (String.split_on_char '\n' log_output |>
12821270 List.filter (fun s -> String.trim s <> "")) in
12831271 Format.printf "Local commits: %d@." commits;
···1291127912921280(* Opam diff command *)
12931281let opam_diff_cmd =
12941294- let doc = "Show diff between vendor and patches branches." in
12821282+ let doc = "Show diff between upstream and patches branches." in
12951283 let pkg_arg =
12961284 let doc = "Package name." in
12971285 Arg.(required & pos 0 (some string) None & info [] ~docv:"PACKAGE" ~doc)
···13051293 Format.eprintf "Package '%s' is not vendored@." pkg;
13061294 exit 1
13071295 end;
13081308- let vendor = Unpac_opam.Opam.vendor_branch pkg in
12961296+ let upstream = Unpac_opam.Opam.upstream_branch pkg in
13091297 let patches = Unpac_opam.Opam.patches_branch pkg in
13101298 let diff = Unpac.Git.run_exn ~proc_mgr ~cwd:git
13111311- ["diff"; vendor; patches] in
12991299+ ["diff"; upstream; patches] in
13121300 if String.trim diff = "" then begin
13131301 Format.printf "No local changes@.";
13141302 Format.printf "@.Hint: unpac opam edit %s # to make changes@." pkg
···13361324 Format.eprintf "Package '%s' is not vendored@." pkg;
13371325 exit 1
13381326 end;
13391339- (* Remove worktrees if exist *)
13401340- (try Unpac.Worktree.remove_force ~proc_mgr root (Unpac.Worktree.Opam_upstream pkg) with _ -> ());
13411341- (try Unpac.Worktree.remove_force ~proc_mgr root (Unpac.Worktree.Opam_vendor pkg) with _ -> ());
13271327+ (* Remove patches worktree if exists (from editing) *)
13421328 (try Unpac.Worktree.remove_force ~proc_mgr root (Unpac.Worktree.Opam_patches pkg) with _ -> ());
13431329 (* Delete branches *)
13441330 let upstream = Unpac_opam.Opam.upstream_branch pkg in
13451345- let vendor = Unpac_opam.Opam.vendor_branch pkg in
13461331 let patches = Unpac_opam.Opam.patches_branch pkg in
13471332 (try Unpac.Git.run_exn ~proc_mgr ~cwd:git ["branch"; "-D"; upstream] |> ignore with _ -> ());
13481348- (try Unpac.Git.run_exn ~proc_mgr ~cwd:git ["branch"; "-D"; vendor] |> ignore with _ -> ());
13491333 (try Unpac.Git.run_exn ~proc_mgr ~cwd:git ["branch"; "-D"; patches] |> ignore with _ -> ());
13501334 (* Remove remote *)
13511335 let remote = "origin-" ^ pkg in
···13671351 `I ("Internal packages", "Creating packages that will never be published");
13681352 `I ("Agent-created packages", "AI agents can create new dependencies on-demand");
13691353 `P "The package is created with a minimal scaffold including dune-project \
13701370- and a .opam file. It uses the standard three-tier branch model but \
13541354+ and a .opam file. It uses the standard two-tier branch model but \
13711355 with no upstream branch (url='local' in config).";
13721356 `S "PACKAGE STRUCTURE";
13731357 `P "The created package will have:";
···14251409 exit 1
14261410 end;
1427141114281428- (* Create an orphan branch for vendor *)
14291429- let vendor_branch = Unpac_opam.Opam.vendor_branch name in
14301430- let patches_branch = Unpac_opam.Opam.patches_branch name in
14311431- let vendor_path = "vendor/opam/" ^ name in
14121412+ (* Create an orphan branch for upstream *)
14131413+ let upstream_branch = Unpac_opam.Opam.upstream_branch name in
14141414+ let ml_name = String.map (fun c -> if c = '-' then '_' else c) name in
1432141514331416 (* Create orphan branch with initial content *)
14341434- Unpac.Git.checkout_orphan ~proc_mgr ~cwd:git vendor_branch;
14171417+ Unpac.Git.checkout_orphan ~proc_mgr ~cwd:git upstream_branch;
1435141814361419 (* Remove any existing index content *)
14371420 Unpac.Git.rm_cached_rf ~proc_mgr ~cwd:git;
1438142114391439- (* Create scaffold files in a temporary worktree *)
14401440- let wt_path = Unpac.Worktree.path root (Unpac.Worktree.Opam_vendor name) in
14411441- Unpac.Worktree.ensure ~proc_mgr root (Unpac.Worktree.Opam_vendor name);
14221422+ (* Create a temporary worktree for the patches branch to add files *)
14231423+ let patches_kind = Unpac.Worktree.Opam_patches name in
14241424+ Unpac.Worktree.ensure ~proc_mgr root patches_kind;
14251425+ let wt_path = Unpac.Worktree.path root patches_kind in
1442142614431443- (* Create directory structure *)
14441444- let pkg_dir = Eio.Path.(wt_path / vendor_path) in
14451445- let lib_dir = Eio.Path.(pkg_dir / "lib") in
14271427+ (* Create directory structure - files at root, not under vendor/ *)
14281428+ let lib_dir = Eio.Path.(wt_path / "lib") in
14461429 Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 lib_dir;
1447143014481431 (* Create dune-project *)
···14591442 (ocaml (>= 4.14))))
14601443|} name name synopsis in
14611444 Eio.Path.save ~create:(`Or_truncate 0o644)
14621462- Eio.Path.(pkg_dir / "dune-project") dune_project;
14451445+ Eio.Path.(wt_path / "dune-project") dune_project;
1463144614641447 (* Create lib/dune *)
14651448 let lib_dune = Printf.sprintf {|(library
14661449 (name %s)
14671450 (public_name %s))
14681468-|} (String.map (fun c -> if c = '-' then '_' else c) name) name in
14511451+|} ml_name name in
14691452 Eio.Path.save ~create:(`Or_truncate 0o644)
14701453 Eio.Path.(lib_dir / "dune") lib_dune;
14711454···14751458(** This module was created by [unpac opam init].
14761459 Add your implementation here. *)
14771460|} name in
14781478- let ml_name = String.map (fun c -> if c = '-' then '_' else c) name in
14791461 Eio.Path.save ~create:(`Or_truncate 0o644)
14801462 Eio.Path.(lib_dir / (ml_name ^ ".ml")) ml_file;
14811463···14961478 (* Get the commit SHA *)
14971479 let sha = Unpac.Git.current_head ~proc_mgr ~cwd:wt_path in
1498148014991499- (* Create patches branch from vendor *)
15001500- Unpac.Git.branch_create ~proc_mgr ~cwd:git
15011501- ~name:patches_branch ~start_point:vendor_branch;
14811481+ (* Now set up upstream branch pointing to same commit *)
14821482+ Unpac.Git.run_exn ~proc_mgr ~cwd:git
14831483+ ["update-ref"; "refs/heads/" ^ upstream_branch; sha] |> ignore;
1502148415031485 (* Cleanup worktree *)
15041504- Unpac.Worktree.remove ~proc_mgr root (Unpac.Worktree.Opam_vendor name);
14861486+ Unpac.Worktree.remove ~proc_mgr root patches_kind;
1505148715061488 (* Switch back to main *)
15071489 Unpac.Git.checkout ~proc_mgr ~cwd:git "main";
···15141496 save_config ~proc_mgr root config (Printf.sprintf "Add local package %s" name);
1515149715161498 Format.printf "Created local package %s (%s)@." name (String.sub sha 0 7);
15171517- Format.printf "@.Package structure:@.";
15181518- Format.printf " %s/@." vendor_path;
15191519- Format.printf " dune-project@.";
15201520- Format.printf " lib/dune@.";
15211521- Format.printf " lib/%s.ml@." ml_name;
15221522- Format.printf " lib/%s.mli@." ml_name;
14991499+ Format.printf "@.Package structure (in patches branch at root):@.";
15001500+ Format.printf " dune-project@.";
15011501+ Format.printf " lib/dune@.";
15021502+ Format.printf " lib/%s.ml@." ml_name;
15031503+ Format.printf " lib/%s.mli@." ml_name;
15041504+ Format.printf "@.When merged to a project, files will be at vendor/opam/%s/@." name;
15231505 Format.printf "@.Next steps:@.";
15241506 Format.printf " unpac opam edit %s # add code to the package@." name;
15251507 Format.printf " unpac opam merge %s <project> # use in a project@." name
···15381520 become a shared library");
15391521 `I ("Needs reuse", "A project that other projects want to depend on");
15401522 `I ("Agent refactoring", "AI agents can extract common code into libraries");
15411541- `P "The project's content is copied to create opam/vendor/<name> and \
15231523+ `P "The project's content is copied to create opam/upstream/<name> and \
15421524 opam/patches/<name> branches. The original project remains unchanged \
15431525 and can be deleted if no longer needed.";
15441526 `S "REQUIREMENTS";
···15901572 exit 1
15911573 end;
1592157415931593- let vendor_branch = Unpac_opam.Opam.vendor_branch pkg_name in
15941594- let patches_branch = Unpac_opam.Opam.patches_branch pkg_name in
15951595- let vendor_path = "vendor/opam/" ^ pkg_name in
15751575+ let upstream_branch = Unpac_opam.Opam.upstream_branch pkg_name in
1596157615971597- (* Create orphan branch for vendor *)
15981598- Unpac.Git.checkout_orphan ~proc_mgr ~cwd:git vendor_branch;
15771577+ (* Create orphan branch for upstream *)
15781578+ Unpac.Git.checkout_orphan ~proc_mgr ~cwd:git upstream_branch;
15991579 Unpac.Git.rm_cached_rf ~proc_mgr ~cwd:git;
1600158016011601- (* Create vendor worktree *)
16021602- Unpac.Worktree.ensure ~proc_mgr root (Unpac.Worktree.Opam_vendor pkg_name);
16031603- let vendor_wt = Unpac.Worktree.path root (Unpac.Worktree.Opam_vendor pkg_name) in
15811581+ (* Create patches worktree *)
15821582+ let patches_kind = Unpac.Worktree.Opam_patches pkg_name in
15831583+ Unpac.Worktree.ensure ~proc_mgr root patches_kind;
15841584+ let patches_wt = Unpac.Worktree.path root patches_kind in
1604158516051586 (* Get project worktree or create temporary one *)
16061587 let project_wt = Unpac.Worktree.path root (Unpac.Worktree.Project project) in
···16081589 if created_project_wt then
16091590 Unpac.Worktree.ensure ~proc_mgr root (Unpac.Worktree.Project project);
1610159116111611- (* Create target directory *)
16121612- let pkg_dir = Eio.Path.(vendor_wt / vendor_path) in
16131613- Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 pkg_dir;
16141614-16151615- (* Copy project content to vendor path *)
15921592+ (* Copy project content to patches worktree (at root, not under vendor/) *)
16161593 let rec copy_dir src dst =
16171594 Eio.Path.read_dir src |> List.iter (fun name ->
16181595 if name <> ".git" then begin
···16281605 end
16291606 )
16301607 in
16311631- copy_dir project_wt pkg_dir;
16081608+ copy_dir project_wt patches_wt;
1632160916331610 (* Commit *)
16341634- Unpac.Git.add_all ~proc_mgr ~cwd:vendor_wt;
16351635- Unpac.Git.commit ~proc_mgr ~cwd:vendor_wt
16111611+ Unpac.Git.add_all ~proc_mgr ~cwd:patches_wt;
16121612+ Unpac.Git.commit ~proc_mgr ~cwd:patches_wt
16361613 ~message:(Printf.sprintf "Promote project %s to package %s" project pkg_name);
1637161416381615 (* Get SHA *)
16391639- let sha = Unpac.Git.current_head ~proc_mgr ~cwd:vendor_wt in
16161616+ let sha = Unpac.Git.current_head ~proc_mgr ~cwd:patches_wt in
1640161716411641- (* Create patches branch from vendor *)
16421642- Unpac.Git.branch_create ~proc_mgr ~cwd:git
16431643- ~name:patches_branch ~start_point:vendor_branch;
16181618+ (* Set upstream branch to same commit *)
16191619+ Unpac.Git.run_exn ~proc_mgr ~cwd:git
16201620+ ["update-ref"; "refs/heads/" ^ upstream_branch; sha] |> ignore;
1644162116451622 (* Cleanup *)
16461646- Unpac.Worktree.remove ~proc_mgr root (Unpac.Worktree.Opam_vendor pkg_name);
16231623+ Unpac.Worktree.remove ~proc_mgr root patches_kind;
16471624 if created_project_wt then
16481625 Unpac.Worktree.remove ~proc_mgr root (Unpac.Worktree.Project project);
16491626···1659163616601637 Format.printf "Promoted project %s to package %s (%s)@." project pkg_name (String.sub sha 0 7);
16611638 Format.printf "@.The package is now available as a vendored dependency.@.";
16391639+ Format.printf "@.When merged to a project, files will be at vendor/opam/%s/@." pkg_name;
16621640 Format.printf "@.Next steps:@.";
16631641 Format.printf " unpac opam merge %s <other-project> # use in another project@." pkg_name;
16641642 Format.printf " unpac opam edit %s # make changes@." pkg_name;
···16741652 let man = [
16751653 `S Manpage.s_description;
16761654 `P "Vendor OCaml packages from opam repositories or create new local packages. \
16771677- Uses a three-tier branch model for conflict-free vendoring:";
16551655+ Uses a two-tier branch model for vendoring:";
16781656 `I ("opam/upstream/<pkg>", "Tracks the original repository state (empty for local packages)");
16791679- `I ("opam/vendor/<pkg>", "Clean snapshot used as merge base");
16801680- `I ("opam/patches/<pkg>", "Local modifications on top of vendor");
16571657+ `I ("opam/patches/<pkg>", "Local modifications on top of upstream");
16811658 `S "PACKAGE SOURCES";
16821659 `P "Packages can come from three sources:";
16831660 `I ("External (unpac opam add)", "Vendor from opam repository or git URL. \
···17781755 let doc = "Git branch or tag to vendor (default: remote default)." in
17791756 Arg.(value & opt (some string) None & info ["b"; "branch"] ~docv:"REF" ~doc)
17801757 in
17811781- let subdir_arg =
17821782- let doc = "Extract only this subdirectory from the repository." in
17831783- Arg.(value & opt (some string) None & info ["subdir"] ~docv:"PATH" ~doc)
17841784- in
17851758 let cache_arg =
17861759 let doc = "Path to vendor cache." in
17871760 Arg.(value & opt (some string) None & info ["cache"] ~docv:"PATH" ~doc)
17881761 in
17891789- let run () url name_opt branch_opt subdir_opt cli_cache =
17621762+ let run () url name_opt branch_opt cli_cache =
17901763 with_root @@ fun ~env:_ ~fs ~proc_mgr ~root ->
17911764 let config = load_config root in
17921765 let cache = resolve_cache ~proc_mgr ~fs ~config ~cli_cache in
···18031776 in
1804177718051778 let info : Unpac.Git_backend.repo_info = {
18061806- name; url; branch = branch_opt; subdir = subdir_opt;
17791779+ name; url; branch = branch_opt;
18071780 } in
1808178118091782 match Unpac.Git_backend.add_repo ~proc_mgr ~root ?cache info with
···18111784 Format.printf "Added %s (%s)@." repo_name (String.sub sha 0 7);
18121785 let repo_config : Unpac.Config.git_repo_config = {
18131786 git_name = name; git_url = url;
18141814- git_branch = branch_opt; git_subdir = subdir_opt;
17871787+ git_branch = branch_opt;
18151788 } in
18161789 let config' = Unpac.Config.add_git_repo config repo_config in
18171790 save_config ~proc_mgr root config' (Printf.sprintf "Add git repo %s" name);
···18251798 exit 1
18261799 in
18271800 let info = Cmd.info "add" ~doc in
18281828- Cmd.v info Term.(const run $ logging_term $ url_arg $ name_arg $ branch_arg $ subdir_arg $ cache_arg)
18011801+ Cmd.v info Term.(const run $ logging_term $ url_arg $ name_arg $ branch_arg $ cache_arg)
1829180218301803(* Git list command *)
18311804let git_list_cmd =
···18801853 with_root @@ fun ~env:_ ~fs:_ ~proc_mgr ~root ->
18811854 with_audit ~proc_mgr ~root ~operation_type:Unpac.Audit.Git_merge ~args:[name; project] @@ fun _ctx ->
18821855 let patches_branch = Unpac.Git_backend.patches_branch name in
18831883- match Unpac.Backend.merge_to_project ~proc_mgr ~root ~project ~patches_branch with
18841884- | Ok () ->
18851885- Format.printf "Merged %s into %s@." name project;
18561856+ let prefix = Unpac.Git_backend.vendor_path name in
18571857+ match Unpac.Backend.merge_to_project ~proc_mgr ~root ~project ~patches_branch ~prefix with
18581858+ | Unpac.Git.Subtree_ok ->
18591859+ Format.printf "Merged %s into %s at %s@." name project prefix;
18861860 Format.printf "@.Next: Build your project in project/%s@." project
18871887- | Error (`Conflict files) ->
18611861+ | Unpac.Git.Subtree_conflict files ->
18881862 Format.eprintf "Merge conflict in %s:@." name;
18891863 List.iter (Format.eprintf " %s@.") files;
18901864 Format.eprintf "Resolve conflicts in project/%s and commit.@." project;
···19131887 Format.printf "Repository: %s@." name;
19141888 (match url with Some u -> Format.printf "URL: %s@." u | None -> ());
19151889 let upstream = Unpac.Git_backend.upstream_branch name in
19161916- let vendor = Unpac.Git_backend.vendor_branch name in
19171890 let patches = Unpac.Git_backend.patches_branch name in
19181891 (match Unpac.Git.rev_parse ~proc_mgr ~cwd:git upstream with
19191892 | Some sha -> Format.printf "Upstream: %s@." (String.sub sha 0 7) | None -> ());
19201920- (match Unpac.Git.rev_parse ~proc_mgr ~cwd:git vendor with
19211921- | Some sha -> Format.printf "Vendor: %s@." (String.sub sha 0 7) | None -> ());
19221893 (match Unpac.Git.rev_parse ~proc_mgr ~cwd:git patches with
19231894 | Some sha -> Format.printf "Patches: %s@." (String.sub sha 0 7) | None -> ());
19241895 let log_output = Unpac.Git.run_exn ~proc_mgr ~cwd:git
19251925- ["log"; "--oneline"; vendor ^ ".." ^ patches] in
18961896+ ["log"; "--oneline"; upstream ^ ".." ^ patches] in
19261897 let commits = List.length (String.split_on_char '\n' log_output |>
19271898 List.filter (fun s -> String.trim s <> "")) in
19281899 Format.printf "Local commits: %d@." commits
···1932190319331904(* Git diff command *)
19341905let git_diff_cmd =
19351935- let doc = "Show diff between vendor and patches branches." in
19061906+ let doc = "Show diff between upstream and patches branches." in
19361907 let name_arg =
19371908 let doc = "Repository name." in
19381909 Arg.(required & pos 0 (some string) None & info [] ~docv:"NAME" ~doc)
···19451916 Format.eprintf "Repository '%s' is not vendored@." name;
19461917 exit 1
19471918 end;
19481948- let vendor = Unpac.Git_backend.vendor_branch name in
19191919+ let upstream = Unpac.Git_backend.upstream_branch name in
19491920 let patches = Unpac.Git_backend.patches_branch name in
19501950- let diff = Unpac.Git.run_exn ~proc_mgr ~cwd:git ["diff"; vendor; patches] in
19211921+ let diff = Unpac.Git.run_exn ~proc_mgr ~cwd:git ["diff"; upstream; patches] in
19511922 if String.trim diff = "" then
19521923 Format.printf "No local changes@."
19531924 else
···19711942 exit 1
19721943 end;
19731944 Unpac.Worktree.ensure ~proc_mgr root (Unpac.Worktree.Git_patches name);
19741974- Unpac.Worktree.ensure ~proc_mgr root (Unpac.Worktree.Git_vendor name);
19751945 let patches_path = snd (Unpac.Worktree.path root (Unpac.Worktree.Git_patches name)) in
19761976- let vendor_path = snd (Unpac.Worktree.path root (Unpac.Worktree.Git_vendor name)) in
19461946+ let upstream_branch = Unpac.Git_backend.upstream_branch name in
19771947 Format.printf "Editing %s@.@." name;
19781978- Format.printf "Worktrees created:@.";
19791979- Format.printf " patches: %s (make changes here)@." patches_path;
19801980- Format.printf " vendor: %s (original for reference)@." vendor_path;
19481948+ Format.printf "Worktree created:@.";
19491949+ Format.printf " patches: %s@." patches_path;
19501950+ Format.printf "@.To compare with upstream:@.";
19511951+ Format.printf " git diff %s..HEAD (from patches worktree)@." upstream_branch;
19811952 Format.printf "@.When done: unpac git done %s@." name
19821953 in
19831954 let info = Cmd.info "edit" ~doc in
···1985195619861957(* Git done command *)
19871958let git_done_cmd =
19881988- let doc = "Close a repository's patches and vendor worktrees." in
19591959+ let doc = "Close a repository's patches worktree." in
19891960 let name_arg =
19901961 let doc = "Repository name." in
19911962 Arg.(required & pos 0 (some string) None & info [] ~docv:"NAME" ~doc)
···19931964 let run () name =
19941965 with_root @@ fun ~env:_ ~fs:_ ~proc_mgr ~root ->
19951966 let patches_kind = Unpac.Worktree.Git_patches name in
19961996- let vendor_kind = Unpac.Worktree.Git_vendor name in
19971967 if not (Unpac.Worktree.exists root patches_kind) then begin
19981968 Format.eprintf "No editing session for '%s'@." name;
19991969 exit 1
···20061976 exit 1
20071977 end;
20081978 Unpac.Worktree.remove ~proc_mgr root patches_kind;
20092009- if Unpac.Worktree.exists root vendor_kind then
20102010- Unpac.Worktree.remove ~proc_mgr root vendor_kind;
20111979 Format.printf "Closed editing session for %s@." name
20121980 in
20131981 let info = Cmd.info "done" ~doc in
···20412009 let doc = "Git repository vendoring commands." in
20422010 let man = [
20432011 `S Manpage.s_description;
20442044- `P "Vendor arbitrary git repositories with full history preservation. \
20452045- Uses the three-tier branch model:";
20122012+ `P "Vendor arbitrary git repositories. Uses a two-tier branch model:";
20462013 `I ("git/upstream/<name>", "Tracks the original repository state");
20472047- `I ("git/vendor/<name>", "Clean snapshot used as merge base");
20482048- `I ("git/patches/<name>", "Local modifications on top of vendor");
20492049- `S "REQUIREMENTS";
20502050- `P "git-filter-repo must be installed and in PATH. Install with:";
20512051- `Pre " curl -o ~/.local/bin/git-filter-repo \\
20522052- https://raw.githubusercontent.com/newren/git-filter-repo/refs/heads/main/git-filter-repo
20532053- chmod +x ~/.local/bin/git-filter-repo";
20142014+ `I ("git/patches/<name>", "Local modifications on top of upstream");
20152015+ `P "Repositories are merged into projects using git subtree, placing files \
20162016+ under vendor/git/<name>/ without polluting project history.";
20542017 `S "TYPICAL WORKFLOW";
20552018 `P "1. Vendor a git repository:";
20562019 `Pre " unpac git add https://github.com/owner/repo.git";
20572057- `P "2. Optionally extract only a subdirectory:";
20582058- `Pre " unpac git add https://github.com/owner/monorepo.git --subdir lib/component";
20592059- `P "3. Create a project and merge:";
20202020+ `P "2. Create a project and merge:";
20602021 `Pre " unpac project new myapp
20612022 unpac git merge repo myapp";
20622023 `S "MAKING LOCAL CHANGES";
20632024 `P "1. Open repository for editing:";
20642025 `Pre " unpac git edit repo";
20652065- `P "2. Make changes in vendor/git/repo-patches/";
20262026+ `P "2. Make changes in the patches worktree";
20662027 `P "3. Close the editing session:";
20672028 `Pre " unpac git done repo";
20682029 `S "COMMANDS";
···2237219822382199 (* For each package, get patch count and merge status *)
22392200 List.iter (fun pkg ->
22402240- let vendor_branch = Unpac_opam.Opam.vendor_branch pkg in
22012201+ let upstream_branch = Unpac_opam.Opam.upstream_branch pkg in
22412202 let patches_branch = Unpac_opam.Opam.patches_branch pkg in
2242220322432243- (* Count commits on patches that aren't on vendor *)
22042204+ (* Count commits on patches that aren't on upstream *)
22442205 let patch_count =
22452206 let output = Unpac.Git.run_exn ~proc_mgr ~cwd:git
22462246- ["rev-list"; "--count"; vendor_branch ^ ".." ^ patches_branch] in
22072207+ ["rev-list"; "--count"; upstream_branch ^ ".." ^ patches_branch] in
22472208 int_of_string (String.trim output)
22482209 in
22492210···24092370 (* Count total patches - parallel *)
24102371 Log.debug (fun m -> m "Counting opam patches (%d packages) in parallel..." (List.length opam_packages));
24112372 let opam_patch_counts = parallel_commit_counts opam_packages
24122412- Unpac_opam.Opam.vendor_branch Unpac_opam.Opam.patches_branch in
23732373+ Unpac_opam.Opam.upstream_branch Unpac_opam.Opam.patches_branch in
24132374 let opam_patches = List.fold_left (fun acc (_, n) -> acc + n) 0 opam_patch_counts in
24142375 Log.debug (fun m -> m "Counting git patches (%d repos) in parallel..." (List.length git_repos));
24152376 let git_patch_counts = parallel_commit_counts git_repos
24162416- Unpac.Git_backend.vendor_branch Unpac.Git_backend.patches_branch in
23772377+ Unpac.Git_backend.upstream_branch Unpac.Git_backend.patches_branch in
24172378 let git_patches = List.fold_left (fun acc (_, n) -> acc + n) 0 git_patch_counts in
24182379 if opam_patches + git_patches > 0 then
24192380 Format.printf "Local patches: %d commits@." (opam_patches + git_patches);
···25072468 else begin
25082469 (* Parallel commit counts *)
25092470 let opam_counts = parallel_commit_counts opam_packages
25102510- Unpac_opam.Opam.vendor_branch Unpac_opam.Opam.patches_branch in
24712471+ Unpac_opam.Opam.upstream_branch Unpac_opam.Opam.patches_branch in
25112472 let opam_count_table = Hashtbl.create (List.length opam_counts) in
25122473 List.iter (fun (pkg, count) -> Hashtbl.add opam_count_table pkg count) opam_counts;
25132474···25602521 else begin
25612522 (* Parallel commit counts *)
25622523 let git_counts = parallel_commit_counts git_repos
25632563- Unpac.Git_backend.vendor_branch Unpac.Git_backend.patches_branch in
25242524+ Unpac.Git_backend.upstream_branch Unpac.Git_backend.patches_branch in
25642525 let git_count_table = Hashtbl.create (List.length git_counts) in
25652526 List.iter (fun (repo, count) -> Hashtbl.add git_count_table repo count) git_counts;
25662527···27622723 else begin
27632724 (* Parallel commit counts *)
27642725 let readme_opam_counts = parallel_commit_counts opam_packages
27652765- Unpac_opam.Opam.vendor_branch Unpac_opam.Opam.patches_branch in
27262726+ Unpac_opam.Opam.upstream_branch Unpac_opam.Opam.patches_branch in
27662727 let readme_opam_count_table = Hashtbl.create (List.length readme_opam_counts) in
27672728 List.iter (fun (pkg, count) -> Hashtbl.add readme_opam_count_table pkg count) readme_opam_counts;
27682729···28142775 else begin
28152776 (* Parallel commit counts *)
28162777 let readme_git_counts = parallel_commit_counts git_repos
28172817- Unpac.Git_backend.vendor_branch Unpac.Git_backend.patches_branch in
27782778+ Unpac.Git_backend.upstream_branch Unpac.Git_backend.patches_branch in
28182779 let readme_git_count_table = Hashtbl.create (List.length readme_git_counts) in
28192780 List.iter (fun (repo, count) -> Hashtbl.add readme_git_count_table repo count) readme_git_counts;
28202781···30863047 `S Manpage.s_description;
30873048 `P "Unpac is a vendoring tool that maintains third-party dependencies \
30883049 as git branches with full history. It uses git worktrees to provide \
30893089- isolated views for editing, and a three-tier branch model \
30903090- (upstream/vendor/patches) for conflict-free updates.";
30503050+ isolated views for editing, and a two-tier branch model \
30513051+ (upstream/patches) for conflict-free updates.";
30913052 `S "VENDORING MODES";
30923053 `I ("unpac opam", "Vendor OCaml packages from opam repositories with \
30933054 dependency solving.");
30943055 `I ("unpac git", "Vendor arbitrary git repositories directly by URL.");
30953095- `S "THREE-TIER BRANCH MODEL";
30963096- `P "Each vendored item has three branches:";
30563056+ `S "TWO-TIER BRANCH MODEL";
30573057+ `P "Each vendored item has two branches:";
30973058 `I ("upstream/*", "Tracks the original repository");
30983098- `I ("vendor/*", "Clean snapshot used as merge base");
30993099- `I ("patches/*", "Your local modifications");
30593059+ `I ("patches/*", "Your local modifications on top of upstream");
31003060 `S "QUICK START";
31013061 `Pre " unpac init myproject && cd myproject
31023062 unpac opam repo add default /path/to/opam-repository
···3333 val upstream_branch : string -> string
3434 (** [upstream_branch pkg] returns branch name, e.g. "opam/upstream/astring". *)
35353636- val vendor_branch : string -> string
3737- (** [vendor_branch pkg] returns branch name, e.g. "opam/vendor/astring". *)
3838-3936 val patches_branch : string -> string
4037 (** [patches_branch pkg] returns branch name, e.g. "opam/patches/astring". *)
4138···44414542 (** {2 Worktree Kinds} *)
46434747- val upstream_kind : string -> Worktree.kind
4848- val vendor_kind : string -> Worktree.kind
4944 val patches_kind : string -> Worktree.kind
50455146 (** {2 Package Operations} *)
···5752 add_result
5853 (** [add_package ~proc_mgr ~root info] vendors a single package.
59546060- 1. Creates/updates opam/upstream/<pkg> from URL
6161- 2. Creates opam/vendor/<pkg> orphan with vendor/ prefix
6262- 3. Creates opam/patches/<pkg> from vendor *)
5555+ 1. Creates/updates upstream branch from URL
5656+ 2. Creates patches branch from upstream *)
63576458 val update_package :
6559 proc_mgr:Git.proc_mgr ->
···6862 update_result
6963 (** [update_package ~proc_mgr ~root name] updates a package from upstream.
70647171- 1. Fetches latest into opam/upstream/<pkg>
7272- 2. Updates opam/vendor/<pkg> with new content
7373- Does NOT rebase patches - that's a separate operation. *)
6565+ 1. Fetches latest into upstream branch
6666+ 2. Rebases patches branch onto new upstream *)
74677568 val list_packages :
7669 proc_mgr:Git.proc_mgr ->
···83768477(** These operations are backend-agnostic and work on any patches branch. *)
85788686-let merge_to_project ~proc_mgr ~root ~project ~patches_branch =
7979+let merge_to_project ~proc_mgr ~root ~project ~patches_branch ~prefix =
8780 let project_wt = Worktree.path root (Worktree.Project project) in
8888- Git.merge_allow_unrelated ~proc_mgr ~cwd:project_wt
8989- ~branch:patches_branch
9090- ~message:(Printf.sprintf "Merge %s" patches_branch)
8181+ (* Check if this is the first merge (prefix doesn't exist) or an update *)
8282+ let prefix_path = Eio.Path.(project_wt / prefix) in
8383+ let exists = Eio.Path.is_directory prefix_path in
8484+ if exists then
8585+ (* Update existing subtree *)
8686+ Git.subtree_pull ~proc_mgr ~cwd:project_wt ~prefix ~branch:patches_branch ~squash:true
8787+ else
8888+ (* First time merge *)
8989+ Git.subtree_add ~proc_mgr ~cwd:project_wt ~prefix ~branch:patches_branch ~squash:true
91909291let rebase_patches ~proc_mgr ~root ~patches_kind ~onto =
9392 Worktree.ensure ~proc_mgr root patches_kind;
+8-14
lib/claude/tools.ml
···3434 err (Printf.sprintf "Failed to list git repos: %s" (Printexc.to_string exn))
35353636(* Git add tool *)
3737-let git_add ~proc_mgr ~fs ~root ~url ?name ?branch ?subdir () =
3737+let git_add ~proc_mgr ~fs ~root ~url ?name ?branch () =
3838 try
3939 let repo_name = match name with
4040 | Some n -> n
···4949 name = repo_name;
5050 url;
5151 branch;
5252- subdir;
5352 } in
54535554 let config_path = Filename.concat (snd (Unpac.Worktree.path root Unpac.Worktree.Main))
···10099 | None -> ());
101100102101 let upstream = Unpac.Git_backend.upstream_branch name in
103103- let vendor = Unpac.Git_backend.vendor_branch name in
104102 let patches = Unpac.Git_backend.patches_branch name in
105103106104 (match Unpac.Git.rev_parse ~proc_mgr ~cwd:git upstream with
107105 | Some sha -> add (Printf.sprintf "Upstream: %s\n" (String.sub sha 0 7))
108108- | None -> ());
109109- (match Unpac.Git.rev_parse ~proc_mgr ~cwd:git vendor with
110110- | Some sha -> add (Printf.sprintf "Vendor: %s\n" (String.sub sha 0 7))
111106 | None -> ());
112107 (match Unpac.Git.rev_parse ~proc_mgr ~cwd:git patches with
113108 | Some sha -> add (Printf.sprintf "Patches: %s\n" (String.sub sha 0 7))
114109 | None -> ());
115110111111+ (* Count local commits: patches ahead of upstream *)
116112 let log_output = Unpac.Git.run_exn ~proc_mgr ~cwd:git
117117- ["log"; "--oneline"; vendor ^ ".." ^ patches] in
113113+ ["log"; "--oneline"; upstream ^ ".." ^ patches] in
118114 let commits = List.length (String.split_on_char '\n' log_output |>
119115 List.filter (fun s -> String.trim s <> "")) in
120116 add (Printf.sprintf "Local commits: %d\n" commits);
···132128 if not (List.mem name repos) then
133129 err (Printf.sprintf "Repository '%s' is not vendored" name)
134130 else begin
135135- let vendor = Unpac.Git_backend.vendor_branch name in
131131+ let upstream = Unpac.Git_backend.upstream_branch name in
136132 let patches = Unpac.Git_backend.patches_branch name in
137137- let diff = Unpac.Git.run_exn ~proc_mgr ~cwd:git ["diff"; vendor; patches] in
133133+ let diff = Unpac.Git.run_exn ~proc_mgr ~cwd:git ["diff"; upstream; patches] in
138134 if String.trim diff = "" then
139135 ok (Printf.sprintf "No local changes in '%s'." name)
140136 else
···547543548544 create
549545 ~name:"unpac_git_add"
550550- ~description:"Vendor a new git repository. Clones the repo and creates the three-tier \
551551- branch structure for conflict-free vendoring with full history preservation."
546546+ ~description:"Vendor a new git repository. Clones the repo and creates the two-tier \
547547+ branch structure (upstream/patches) for conflict-free vendoring."
552548 ~input_schema:(schema_object [
553549 ("url", schema_string);
554550 ("name", schema_string);
555551 ("branch", schema_string);
556556- ("subdir", schema_string);
557552 ] ~required:["url"])
558553 ~handler:(fun args ->
559554 match Claude.Tool_input.get_string args "url" with
···561556 | Some url ->
562557 let name = Claude.Tool_input.get_string args "name" in
563558 let branch = Claude.Tool_input.get_string args "branch" in
564564- let subdir = Claude.Tool_input.get_string args "subdir" in
565565- git_add ~proc_mgr ~fs ~root ~url ?name ?branch ?subdir ());
559559+ git_add ~proc_mgr ~fs ~root ~url ?name ?branch ());
566560567561 create
568562 ~name:"unpac_git_info"
+2-4
lib/config.ml
···3030 git_name : string; (** User-specified name for the repo *)
3131 git_url : string; (** Git URL to clone from *)
3232 git_branch : string option; (** Optional branch/tag to track *)
3333- git_subdir : string option; (** Optional subdirectory to extract *)
3433}
35343635type git_config = {
···101100let git_repo_config_codec : git_repo_config Tomlt.t =
102101 let open Tomlt in
103102 let open Table in
104104- obj (fun git_name git_url git_branch git_subdir : git_repo_config ->
105105- { git_name; git_url; git_branch; git_subdir })
103103+ obj (fun git_name git_url git_branch : git_repo_config ->
104104+ { git_name; git_url; git_branch })
106105 |> mem "name" string ~enc:(fun (r : git_repo_config) -> r.git_name)
107106 |> mem "url" string ~enc:(fun (r : git_repo_config) -> r.git_url)
108107 |> opt_mem "branch" string ~enc:(fun (r : git_repo_config) -> r.git_branch)
109109- |> opt_mem "subdir" string ~enc:(fun (r : git_repo_config) -> r.git_subdir)
110108 |> finish
111109112110let git_config_codec : git_config Tomlt.t =
-1
lib/config.mli
···3030 git_name : string; (** User-specified name for the repo *)
3131 git_url : string; (** Git URL to clone from *)
3232 git_branch : string option; (** Optional branch/tag to track *)
3333- git_subdir : string option; (** Optional subdirectory to extract *)
3433}
35343635type git_config = {
+49-91
lib/git.ml
···437437 Log.debug (fun m -> m "Cleaning untracked files");
438438 run_exn ~proc_mgr ~cwd ["clean"; "-fd"] |> ignore
439439440440-let filter_repo_to_subdirectory ~proc_mgr ~cwd ~branch ~subdirectory =
441441- Log.info (fun m -> m "Rewriting history of %s into subdirectory %s..." branch subdirectory);
442442- (* Use git-filter-repo with --to-subdirectory-filter to rewrite all paths into subdirectory.
443443- This preserves full history with paths prefixed. Much faster than filter-branch.
444444-445445- For bare repositories, we need to create a temporary worktree, run filter-repo
446446- there, and then update the branch in the bare repo. *)
447447-448448- (* Create a unique temporary worktree name using the branch name *)
449449- let safe_branch = String.map (fun c -> if c = '/' then '-' else c) branch in
450450- let temp_wt_name = ".filter-tmp-" ^ safe_branch in
451451- let temp_wt_relpath = "../" ^ temp_wt_name in
452452-453453- (* Construct the worktree path - cwd is (fs, path_string), so we go up one level *)
454454- let fs = fst cwd in
455455- let git_path = snd cwd in
456456- let parent_path = Filename.dirname git_path in
457457- let temp_wt_path = Filename.concat parent_path temp_wt_name in
458458- let temp_wt : path = (fs, temp_wt_path) in
459459-460460- (* Remove any existing temp worktree *)
461461- ignore (run ~proc_mgr ~cwd ["worktree"; "remove"; "-f"; temp_wt_relpath]);
462462-463463- (* Create worktree for the branch *)
464464- run_exn ~proc_mgr ~cwd ["worktree"; "add"; temp_wt_relpath; branch] |> ignore;
465465-466466- (* Run git-filter-repo in the worktree *)
467467- let result = run ~proc_mgr ~cwd:temp_wt [
468468- "filter-repo";
469469- "--to-subdirectory-filter"; subdirectory;
470470- "--force";
471471- "--refs"; "HEAD"
472472- ] in
473473-474474- (* Handle result: get the new SHA, cleanup worktree, then update branch *)
475475- (match result with
476476- | Ok _ ->
477477- (* Get the new HEAD SHA from the worktree BEFORE removing it *)
478478- let new_sha = run_exn ~proc_mgr ~cwd:temp_wt ["rev-parse"; "HEAD"] |> string_trim in
479479- (* Cleanup temporary worktree first (must do this before updating branch) *)
480480- ignore (run ~proc_mgr ~cwd ["worktree"; "remove"; "-f"; temp_wt_relpath]);
481481- (* Now update the branch in the bare repo *)
482482- run_exn ~proc_mgr ~cwd ["branch"; "-f"; branch; new_sha] |> ignore
483483- | Error e ->
484484- (* Cleanup and re-raise *)
485485- ignore (run ~proc_mgr ~cwd ["worktree"; "remove"; "-f"; temp_wt_relpath]);
486486- raise (err e))
440440+(* Git subtree operations *)
487441488488-let filter_repo_from_subdirectory ~proc_mgr ~cwd ~branch ~subdirectory =
489489- Log.info (fun m -> m "Extracting %s from subdirectory %s to root..." branch subdirectory);
490490- (* Use git-filter-repo with --subdirectory-filter to extract files from subdirectory
491491- to root. This is the inverse of --to-subdirectory-filter.
492492- Preserves history for files that were in the subdirectory.
493493-494494- For bare repositories, we need to create a temporary worktree, run filter-repo
495495- there, and then update the branch in the bare repo. *)
496496-497497- (* Create a unique temporary worktree name using the branch name *)
498498- let safe_branch = String.map (fun c -> if c = '/' then '-' else c) branch in
499499- let temp_wt_name = ".filter-tmp-" ^ safe_branch in
500500- let temp_wt_relpath = "../" ^ temp_wt_name in
501501-502502- (* Construct the worktree path - cwd is (fs, path_string), so we go up one level *)
503503- let fs = fst cwd in
504504- let git_path = snd cwd in
505505- let parent_path = Filename.dirname git_path in
506506- let temp_wt_path = Filename.concat parent_path temp_wt_name in
507507- let temp_wt : path = (fs, temp_wt_path) in
442442+type subtree_result =
443443+ | Subtree_ok
444444+ | Subtree_conflict of string list
508445509509- (* Remove any existing temp worktree *)
510510- ignore (run ~proc_mgr ~cwd ["worktree"; "remove"; "-f"; temp_wt_relpath]);
446446+let has_conflict_marker stderr =
447447+ String.starts_with ~prefix:"CONFLICT" stderr ||
448448+ String.starts_with ~prefix:"conflict" stderr ||
449449+ (* Check if "Merge conflict" appears anywhere *)
450450+ let rec find_substring s sub i =
451451+ if i + String.length sub > String.length s then false
452452+ else if String.sub s i (String.length sub) = sub then true
453453+ else find_substring s sub (i + 1)
454454+ in
455455+ find_substring stderr "Merge conflict" 0
511456512512- (* Create worktree for the branch *)
513513- run_exn ~proc_mgr ~cwd ["worktree"; "add"; temp_wt_relpath; branch] |> ignore;
457457+let subtree_add ~proc_mgr ~cwd ~prefix ~branch ~squash =
458458+ Log.info (fun m -> m "Subtree add: %s from %s (squash=%b)" prefix branch squash);
459459+ let args = ["subtree"; "add"; "--prefix"; prefix] @
460460+ (if squash then ["--squash"] else []) @
461461+ [branch] in
462462+ match run ~proc_mgr ~cwd args with
463463+ | Ok _ -> Subtree_ok
464464+ | Error (Command_failed { exit_code = 1; stderr; _ }) when
465465+ String.length stderr > 0 && has_conflict_marker stderr ->
466466+ (* Parse conflicting files *)
467467+ let conflict_output = run_exn ~proc_mgr ~cwd ["diff"; "--name-only"; "--diff-filter=U"] in
468468+ let files = lines conflict_output in
469469+ Log.warn (fun m -> m "Subtree add conflict: %a" Fmt.(list ~sep:comma string) files);
470470+ Subtree_conflict files
471471+ | Error e ->
472472+ raise (err e)
514473515515- (* Run git-filter-repo in the worktree with --subdirectory-filter *)
516516- let result = run ~proc_mgr ~cwd:temp_wt [
517517- "filter-repo";
518518- "--subdirectory-filter"; subdirectory;
519519- "--force";
520520- "--refs"; "HEAD"
521521- ] in
474474+let subtree_pull ~proc_mgr ~cwd ~prefix ~branch ~squash =
475475+ Log.info (fun m -> m "Subtree pull: %s from %s (squash=%b)" prefix branch squash);
476476+ let args = ["subtree"; "pull"; "--prefix"; prefix] @
477477+ (if squash then ["--squash"] else []) @
478478+ ["." (* local repo *); branch] in
479479+ match run ~proc_mgr ~cwd args with
480480+ | Ok _ -> Subtree_ok
481481+ | Error (Command_failed { exit_code = 1; stderr; _ }) when
482482+ String.length stderr > 0 && has_conflict_marker stderr ->
483483+ (* Parse conflicting files *)
484484+ let conflict_output = run_exn ~proc_mgr ~cwd ["diff"; "--name-only"; "--diff-filter=U"] in
485485+ let files = lines conflict_output in
486486+ Log.warn (fun m -> m "Subtree pull conflict: %a" Fmt.(list ~sep:comma string) files);
487487+ Subtree_conflict files
488488+ | Error e ->
489489+ raise (err e)
522490523523- (* Handle result: get the new SHA, cleanup worktree, then update branch *)
524524- (match result with
525525- | Ok _ ->
526526- (* Get the new HEAD SHA from the worktree BEFORE removing it *)
527527- let new_sha = run_exn ~proc_mgr ~cwd:temp_wt ["rev-parse"; "HEAD"] |> string_trim in
528528- (* Cleanup temporary worktree first (must do this before updating branch) *)
529529- ignore (run ~proc_mgr ~cwd ["worktree"; "remove"; "-f"; temp_wt_relpath]);
530530- (* Now update the branch in the bare repo *)
531531- run_exn ~proc_mgr ~cwd ["branch"; "-f"; branch; new_sha] |> ignore
532532- | Error e ->
533533- (* Cleanup and re-raise *)
534534- ignore (run ~proc_mgr ~cwd ["worktree"; "remove"; "-f"; temp_wt_relpath]);
535535- raise (err e))
491491+let subtree_split ~proc_mgr ~cwd ~prefix ~branch =
492492+ Log.info (fun m -> m "Subtree split: %s to branch %s" prefix branch);
493493+ run_exn ~proc_mgr ~cwd ["subtree"; "split"; "--prefix"; prefix; "-b"; branch] |> ignore
+29-13
lib/git.mli
···376376 unit
377377(** [clean_fd] removes untracked files and directories. *)
378378379379-val filter_repo_to_subdirectory :
379379+(** {1 Git Subtree Operations} *)
380380+381381+type subtree_result =
382382+ | Subtree_ok
383383+ | Subtree_conflict of string list
384384+(** Result of a subtree operation. *)
385385+386386+val subtree_add :
380387 proc_mgr:proc_mgr ->
381388 cwd:path ->
389389+ prefix:string ->
382390 branch:string ->
383383- subdirectory:string ->
384384- unit
385385-(** [filter_repo_to_subdirectory ~proc_mgr ~cwd ~branch ~subdirectory]
386386- rewrites the history of [branch] so all files are moved into [subdirectory].
387387- Uses git-filter-repo for fast history rewriting. Preserves full commit history. *)
391391+ squash:bool ->
392392+ subtree_result
393393+(** [subtree_add ~proc_mgr ~cwd ~prefix ~branch ~squash] adds a branch as a subtree.
394394+ Use this for the first merge of a package into a project.
395395+ Files from [branch] will be placed under [prefix].
396396+ If [squash] is true, the subtree's history is squashed into a single commit. *)
388397389389-val filter_repo_from_subdirectory :
398398+val subtree_pull :
399399+ proc_mgr:proc_mgr ->
400400+ cwd:path ->
401401+ prefix:string ->
402402+ branch:string ->
403403+ squash:bool ->
404404+ subtree_result
405405+(** [subtree_pull ~proc_mgr ~cwd ~prefix ~branch ~squash] updates an existing subtree.
406406+ Use this to update a package that was previously merged via subtree_add. *)
407407+408408+val subtree_split :
390409 proc_mgr:proc_mgr ->
391410 cwd:path ->
411411+ prefix:string ->
392412 branch:string ->
393393- subdirectory:string ->
394413 unit
395395-(** [filter_repo_from_subdirectory ~proc_mgr ~cwd ~branch ~subdirectory]
396396- rewrites the history of [branch] extracting only files from [subdirectory]
397397- and placing them at the repository root. This is the inverse of
398398- [filter_repo_to_subdirectory]. Uses git-filter-repo --subdirectory-filter.
399399- Preserves full commit history for files that were in the subdirectory. *)
414414+(** [subtree_split ~proc_mgr ~cwd ~prefix ~branch] extracts a subdirectory's history
415415+ into a new branch. This is used for promoting project code to a vendored library. *)
+53-103
lib/git_backend.ml
···11(** Git backend for direct repository vendoring.
2233- Implements vendoring of arbitrary git repositories using the three-tier branch model:
33+ Implements vendoring of arbitrary git repositories using a two-tier branch model:
44 - git/upstream/<name> - pristine upstream code
55- - git/vendor/<name> - upstream history rewritten with vendor/git/<name>/ prefix
66- - git/patches/<name> - local modifications *)
55+ - git/patches/<name> - local modifications on top of upstream
66+77+ Repositories are merged into projects using git subtree, which places files
88+ under vendor/git/<name>/ without rewriting upstream history. *)
79810(** {1 Branch Naming} *)
9111012let upstream_branch name = "git/upstream/" ^ name
1111-let vendor_branch name = "git/vendor/" ^ name
1213let patches_branch name = "git/patches/" ^ name
1314let vendor_path name = "vendor/git/" ^ name
14151516(** {1 Worktree Kinds} *)
16171717-let upstream_kind name = Worktree.Git_upstream name
1818-let vendor_kind name = Worktree.Git_vendor name
1918let patches_kind name = Worktree.Git_patches name
20192120(** {1 Repository Info} *)
···2423 name : string;
2524 url : string;
2625 branch : string option;
2727- subdir : string option;
2826}
29273028(** {1 Repository Operations} *)
···6866 Git.branch_force ~proc_mgr ~cwd:git
6967 ~name:(upstream_branch repo_name) ~point:ref_point;
70687171- (* Step 2: Create vendor branch from upstream and rewrite history *)
7272- Git.branch_force ~proc_mgr ~cwd:git
7373- ~name:(vendor_branch repo_name) ~point:(upstream_branch repo_name);
6969+ (* Step 2: Create patches branch from upstream (initially identical) *)
7070+ (* Patches will be merged into projects via git subtree *)
7171+ Git.branch_create ~proc_mgr ~cwd:git
7272+ ~name:(patches_branch repo_name)
7373+ ~start_point:(upstream_branch repo_name);
74747575- (* If subdir is specified, we first filter to that subdirectory,
7676- then move to vendor path. Otherwise, just move to vendor path. *)
7777- (match info.subdir with
7878- | Some subdir ->
7979- (* First filter to extract only the subdirectory *)
8080- Git.filter_repo_to_subdirectory ~proc_mgr ~cwd:git
8181- ~branch:(vendor_branch repo_name)
8282- ~subdirectory:subdir;
8383- (* Now the subdir is at root, rewrite to vendor path *)
8484- Git.filter_repo_to_subdirectory ~proc_mgr ~cwd:git
8585- ~branch:(vendor_branch repo_name)
8686- ~subdirectory:(vendor_path repo_name)
8787- | None ->
8888- (* Rewrite vendor branch history to move all files into vendor/git/<name>/ *)
8989- Git.filter_repo_to_subdirectory ~proc_mgr ~cwd:git
9090- ~branch:(vendor_branch repo_name)
9191- ~subdirectory:(vendor_path repo_name));
9292-9393- (* Get the vendor SHA after rewriting *)
9494- let vendor_sha = match Git.rev_parse ~proc_mgr ~cwd:git (vendor_branch repo_name) with
7575+ (* Get the SHA for reporting *)
7676+ let sha = match Git.rev_parse ~proc_mgr ~cwd:git (patches_branch repo_name) with
9577 | Some sha -> sha
9696- | None -> failwith "Vendor branch not found after filter-repo"
7878+ | None -> failwith "Patches branch not found"
9779 in
98809999- (* Step 3: Create patches branch from vendor *)
100100- Git.branch_create ~proc_mgr ~cwd:git
101101- ~name:(patches_branch repo_name)
102102- ~start_point:(vendor_branch repo_name);
103103-104104- Backend.Added { name = repo_name; sha = vendor_sha }
8181+ Backend.Added { name = repo_name; sha }
10582 end
10683 with exn ->
107107- (* Cleanup on failure *)
108108- (try Worktree.remove_force ~proc_mgr root (upstream_kind repo_name) with _ -> ());
109109- (try Worktree.remove_force ~proc_mgr root (vendor_kind repo_name) with _ -> ());
8484+ (* Cleanup on failure - just delete branches, no worktrees to remove *)
8585+ (try Git.run_exn ~proc_mgr ~cwd:git ["branch"; "-D"; upstream_branch repo_name] |> ignore with _ -> ());
8686+ (try Git.run_exn ~proc_mgr ~cwd:git ["branch"; "-D"; patches_branch repo_name] |> ignore with _ -> ());
11087 Backend.Failed { name = repo_name; error = Printexc.to_string exn }
11188112112-let copy_with_prefix ~src_dir ~dst_dir ~prefix =
113113- (* Recursively copy files from src_dir to dst_dir/prefix/ *)
114114- let prefix_dir = Eio.Path.(dst_dir / prefix) in
115115- Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 prefix_dir;
116116-117117- let rec copy_dir src dst =
118118- Eio.Path.read_dir src |> List.iter (fun name ->
119119- let src_path = Eio.Path.(src / name) in
120120- let dst_path = Eio.Path.(dst / name) in
121121- if Eio.Path.is_directory src_path then begin
122122- Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 dst_path;
123123- copy_dir src_path dst_path
124124- end else begin
125125- let content = Eio.Path.load src_path in
126126- Eio.Path.save ~create:(`Or_truncate 0o644) dst_path content
127127- end
128128- )
129129- in
130130-131131- (* Copy everything except .git *)
132132- Eio.Path.read_dir src_dir |> List.iter (fun name ->
133133- if name <> ".git" then begin
134134- let src_path = Eio.Path.(src_dir / name) in
135135- let dst_path = Eio.Path.(prefix_dir / name) in
136136- if Eio.Path.is_directory src_path then begin
137137- Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 dst_path;
138138- copy_dir src_path dst_path
139139- end else begin
140140- let content = Eio.Path.load src_path in
141141- Eio.Path.save ~create:(`Or_truncate 0o644) dst_path content
142142- end
143143- end
144144- )
145145-14689let update_repo ~proc_mgr ~root ?cache repo_name =
14790 let git = Worktree.git_dir root in
14891···188131 if old_sha = new_sha then
189132 Backend.No_changes repo_name
190133 else begin
191191- (* Create worktrees *)
192192- Worktree.ensure ~proc_mgr root (upstream_kind repo_name);
193193- Worktree.ensure ~proc_mgr root (vendor_kind repo_name);
134134+ (* Rebase patches branch onto new upstream *)
135135+ (* First check if patches has diverged from upstream *)
136136+ let patches_sha = Git.rev_parse_exn ~proc_mgr ~cwd:git (patches_branch repo_name) in
137137+ if patches_sha = old_sha then begin
138138+ (* No local patches - just fast-forward patches branch *)
139139+ Git.branch_force ~proc_mgr ~cwd:git
140140+ ~name:(patches_branch repo_name) ~point:(upstream_branch repo_name);
141141+ Backend.Updated { name = repo_name; old_sha; new_sha }
142142+ end else begin
143143+ (* Has local patches - need to rebase *)
144144+ (* Create a temporary worktree for rebasing *)
145145+ let safe_name = String.map (fun c -> if c = '/' then '-' else c) repo_name in
146146+ let temp_wt_name = ".rebase-tmp-" ^ safe_name in
147147+ let temp_wt_relpath = "../" ^ temp_wt_name in
194148195195- let upstream_wt = Worktree.path root (upstream_kind repo_name) in
196196- let vendor_wt = Worktree.path root (vendor_kind repo_name) in
149149+ let fs = fst git in
150150+ let git_path = snd git in
151151+ let parent_path = Filename.dirname git_path in
152152+ let temp_wt_path = Filename.concat parent_path temp_wt_name in
153153+ let temp_wt : Git.path = (fs, temp_wt_path) in
197154198198- (* Clear vendor content and copy new *)
199199- let vendor_pkg_path = Eio.Path.(vendor_wt / "vendor" / "git" / repo_name) in
200200- (try Eio.Path.rmtree vendor_pkg_path with _ -> ());
155155+ (* Remove any existing temp worktree *)
156156+ ignore (Git.run ~proc_mgr ~cwd:git ["worktree"; "remove"; "-f"; temp_wt_relpath]);
201157202202- copy_with_prefix
203203- ~src_dir:upstream_wt
204204- ~dst_dir:vendor_wt
205205- ~prefix:(vendor_path repo_name);
158158+ (* Create worktree for the patches branch *)
159159+ Git.run_exn ~proc_mgr ~cwd:git ["worktree"; "add"; temp_wt_relpath; patches_branch repo_name] |> ignore;
206160207207- (* Commit *)
208208- Git.add_all ~proc_mgr ~cwd:vendor_wt;
209209- Git.commit ~proc_mgr ~cwd:vendor_wt
210210- ~message:(Printf.sprintf "Update %s to %s" repo_name (String.sub new_sha 0 7));
161161+ (* Try to rebase *)
162162+ let result = Git.rebase ~proc_mgr ~cwd:temp_wt ~onto:(upstream_branch repo_name) in
211163212212- (* Cleanup *)
213213- Worktree.remove ~proc_mgr root (upstream_kind repo_name);
214214- Worktree.remove ~proc_mgr root (vendor_kind repo_name);
164164+ (* Cleanup temp worktree *)
165165+ ignore (Git.run ~proc_mgr ~cwd:git ["worktree"; "remove"; "-f"; temp_wt_relpath]);
215166216216- Backend.Updated { name = repo_name; old_sha; new_sha }
167167+ match result with
168168+ | Ok () ->
169169+ Backend.Updated { name = repo_name; old_sha; new_sha }
170170+ | Error (`Conflict hint) ->
171171+ (* Abort the rebase and report conflict *)
172172+ Git.rebase_abort ~proc_mgr ~cwd:git;
173173+ Backend.Update_failed { name = repo_name; error = "Rebase conflict: " ^ hint }
174174+ end
217175 end
218176 end
219177 with exn ->
220220- (try Worktree.remove_force ~proc_mgr root (upstream_kind repo_name) with _ -> ());
221221- (try Worktree.remove_force ~proc_mgr root (vendor_kind repo_name) with _ -> ());
222178 Backend.Update_failed { name = repo_name; error = Printexc.to_string exn }
223179224180let list_repos ~proc_mgr ~root =
···227183let remove_repo ~proc_mgr ~root repo_name =
228184 let git = Worktree.git_dir root in
229185230230- (* Remove worktrees if exist *)
231231- (try Worktree.remove_force ~proc_mgr root (upstream_kind repo_name) with _ -> ());
232232- (try Worktree.remove_force ~proc_mgr root (vendor_kind repo_name) with _ -> ());
233233- (try Worktree.remove_force ~proc_mgr root (patches_kind repo_name) with _ -> ());
234234-235235- (* Delete branches *)
186186+ (* Delete branches - no worktrees in new architecture *)
236187 (try Git.run_exn ~proc_mgr ~cwd:git ["branch"; "-D"; upstream_branch repo_name] |> ignore with _ -> ());
237237- (try Git.run_exn ~proc_mgr ~cwd:git ["branch"; "-D"; vendor_branch repo_name] |> ignore with _ -> ());
238188 (try Git.run_exn ~proc_mgr ~cwd:git ["branch"; "-D"; patches_branch repo_name] |> ignore with _ -> ());
239189240190 (* Remove remote *)
+10-12
lib/git_backend.mli
···11(** Git backend for direct repository vendoring.
2233- Implements vendoring of arbitrary git repositories using the three-tier branch model:
33+ Implements vendoring of arbitrary git repositories using a two-tier branch model:
44 - git/upstream/<name> - pristine upstream code
55- - git/vendor/<name> - upstream history rewritten with vendor/git/<name>/ prefix
66- - git/patches/<name> - local modifications
55+ - git/patches/<name> - local modifications on top of upstream
66+77+ Repositories are merged into projects using git subtree, which places files
88+ under vendor/git/<name>/ without rewriting upstream history.
79810 Unlike the opam backend which discovers packages via opam repositories,
911 this backend allows cloning any git repository directly. *)
···12141315val upstream_branch : string -> string
1416(** [upstream_branch name] returns the upstream branch name "git/upstream/<name>". *)
1515-1616-val vendor_branch : string -> string
1717-(** [vendor_branch name] returns the vendor branch name "git/vendor/<name>". *)
18171918val patches_branch : string -> string
2019(** [patches_branch name] returns the patches branch name "git/patches/<name>". *)
···2827 name : string; (** User-specified name *)
2928 url : string; (** Git URL to clone from *)
3029 branch : string option; (** Optional branch/tag to track *)
3131- subdir : string option; (** Optional subdirectory to extract *)
3230}
33313432(** {1 Repository Operations} *)
···4139 Backend.add_result
4240(** [add_repo ~proc_mgr ~root ?cache info] vendors a git repository.
43414444- Creates the three-tier branch structure:
4242+ Creates the two-tier branch structure:
4543 1. Fetches from url into git/upstream/<name>
4646- 2. Rewrites history into git/vendor/<name> with vendor/git/<name>/ prefix
4747- 3. Creates git/patches/<name> for local modifications
4444+ 2. Creates git/patches/<name> for local modifications (initially identical to upstream)
48454949- If [subdir] is specified, only that subdirectory is extracted from the repo. *)
4646+ Use [git subtree add] to merge into a project. *)
50475148val update_repo :
5249 proc_mgr:Git.proc_mgr ->
···5451 ?cache:Vendor_cache.t ->
5552 string ->
5653 Backend.update_result
5757-(** [update_repo ~proc_mgr ~root ?cache name] updates a vendored repository from upstream. *)
5454+(** [update_repo ~proc_mgr ~root ?cache name] updates a vendored repository from upstream.
5555+ Rebases the patches branch onto the new upstream. *)
58565957val list_repos :
6058 proc_mgr:Git.proc_mgr ->
+51-84
lib/opam/opam.ml
···11(** Opam backend for unpac.
2233- Implements vendoring of opam packages using the three-tier branch model:
33+ Implements vendoring of opam packages using a two-tier branch model:
44 - opam/upstream/<pkg> - pristine upstream code
55- - opam/vendor/<pkg> - upstream history rewritten with vendor/opam/<pkg>/ prefix
66- - opam/patches/<pkg> - local modifications
55+ - opam/patches/<pkg> - local modifications on top of upstream
7688- The vendor branch preserves full git history from upstream, with all paths
99- rewritten to be under vendor/opam/<pkg>/. This allows git blame/log to work
1010- correctly on vendored files. *)
77+ Packages are merged into projects using git subtree, which places files
88+ under vendor/opam/<pkg>/ without rewriting upstream history. *)
1191210module Worktree = Unpac.Worktree
1311module Git = Unpac.Git
···2018(** {1 Branch Naming} *)
21192220let upstream_branch pkg = "opam/upstream/" ^ pkg
2323-let vendor_branch pkg = "opam/vendor/" ^ pkg
2421let patches_branch pkg = "opam/patches/" ^ pkg
2522let vendor_path pkg = "vendor/opam/" ^ pkg
26232724(** {1 Worktree Kinds} *)
28252926let upstream_kind pkg = Worktree.Opam_upstream pkg
3030-let vendor_kind pkg = Worktree.Opam_vendor pkg
3127let patches_kind pkg = Worktree.Opam_patches pkg
32283329(** {1 Package Operations} *)
34303535-let copy_with_prefix ~src_dir ~dst_dir ~prefix =
3636- (* Recursively copy files from src_dir to dst_dir/prefix/ *)
3737- let prefix_dir = Eio.Path.(dst_dir / prefix) in
3838- Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 prefix_dir;
3939-4040- let rec copy_dir src dst =
4141- Eio.Path.read_dir src |> List.iter (fun name ->
4242- let src_path = Eio.Path.(src / name) in
4343- let dst_path = Eio.Path.(dst / name) in
4444- if Eio.Path.is_directory src_path then begin
4545- Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 dst_path;
4646- copy_dir src_path dst_path
4747- end else begin
4848- let content = Eio.Path.load src_path in
4949- Eio.Path.save ~create:(`Or_truncate 0o644) dst_path content
5050- end
5151- )
5252- in
5353-5454- (* Copy everything except .git *)
5555- Eio.Path.read_dir src_dir |> List.iter (fun name ->
5656- if name <> ".git" then begin
5757- let src_path = Eio.Path.(src_dir / name) in
5858- let dst_path = Eio.Path.(prefix_dir / name) in
5959- if Eio.Path.is_directory src_path then begin
6060- Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 dst_path;
6161- copy_dir src_path dst_path
6262- end else begin
6363- let content = Eio.Path.load src_path in
6464- Eio.Path.save ~create:(`Or_truncate 0o644) dst_path content
6565- end
6666- end
6767- )
6868-6931let add_package ~proc_mgr ~root ?cache (info : Backend.package_info) =
7032 let pkg = info.name in
7133 let git = Worktree.git_dir root in
···10567 Git.branch_force ~proc_mgr ~cwd:git
10668 ~name:(upstream_branch pkg) ~point:ref_point;
10769108108- (* Step 2: Create vendor branch from upstream and rewrite history *)
109109- Git.branch_force ~proc_mgr ~cwd:git
110110- ~name:(vendor_branch pkg) ~point:(upstream_branch pkg);
111111-112112- (* Rewrite vendor branch history to move all files into vendor/opam/<pkg>/ *)
113113- Git.filter_repo_to_subdirectory ~proc_mgr ~cwd:git
114114- ~branch:(vendor_branch pkg)
115115- ~subdirectory:(vendor_path pkg);
7070+ (* Step 2: Create patches branch from upstream (initially identical) *)
7171+ (* Patches will be merged into projects via git subtree *)
7272+ Git.branch_create ~proc_mgr ~cwd:git
7373+ ~name:(patches_branch pkg)
7474+ ~start_point:(upstream_branch pkg);
11675117117- (* Get the vendor SHA after rewriting *)
118118- let vendor_sha = match Git.rev_parse ~proc_mgr ~cwd:git (vendor_branch pkg) with
7676+ (* Get the SHA for reporting *)
7777+ let sha = match Git.rev_parse ~proc_mgr ~cwd:git (patches_branch pkg) with
11978 | Some sha -> sha
120120- | None -> failwith "Vendor branch not found after filter-repo"
7979+ | None -> failwith "Patches branch not found"
12180 in
12281123123- (* Step 3: Create patches branch from vendor *)
124124- Git.branch_create ~proc_mgr ~cwd:git
125125- ~name:(patches_branch pkg)
126126- ~start_point:(vendor_branch pkg);
127127-128128- Backend.Added { name = pkg; sha = vendor_sha }
8282+ Backend.Added { name = pkg; sha }
12983 end
13084 with exn ->
131131- (* Cleanup on failure *)
132132- (try Worktree.remove_force ~proc_mgr root (upstream_kind pkg) with _ -> ());
133133- (try Worktree.remove_force ~proc_mgr root (vendor_kind pkg) with _ -> ());
8585+ (* Cleanup on failure - just delete branches, no worktrees to remove *)
8686+ (try Git.run_exn ~proc_mgr ~cwd:git ["branch"; "-D"; upstream_branch pkg] |> ignore with _ -> ());
8787+ (try Git.run_exn ~proc_mgr ~cwd:git ["branch"; "-D"; patches_branch pkg] |> ignore with _ -> ());
13488 Backend.Failed { name = pkg; error = Printexc.to_string exn }
1358913690let update_package ~proc_mgr ~root ?cache pkg =
···183137 if old_sha = new_sha then
184138 Backend.No_changes pkg
185139 else begin
186186- (* Create worktrees *)
187187- Worktree.ensure ~proc_mgr root (upstream_kind pkg);
188188- Worktree.ensure ~proc_mgr root (vendor_kind pkg);
140140+ (* Rebase patches branch onto new upstream *)
141141+ (* First check if patches has diverged from upstream *)
142142+ let patches_sha = Git.rev_parse_exn ~proc_mgr ~cwd:git (patches_branch pkg) in
143143+ if patches_sha = old_sha then begin
144144+ (* No local patches - just fast-forward patches branch *)
145145+ Git.branch_force ~proc_mgr ~cwd:git
146146+ ~name:(patches_branch pkg) ~point:(upstream_branch pkg);
147147+ Backend.Updated { name = pkg; old_sha; new_sha }
148148+ end else begin
149149+ (* Has local patches - need to rebase *)
150150+ (* Create a temporary worktree for rebasing *)
151151+ let safe_name = String.map (fun c -> if c = '/' then '-' else c) pkg in
152152+ let temp_wt_name = ".rebase-tmp-" ^ safe_name in
153153+ let temp_wt_relpath = "../" ^ temp_wt_name in
189154190190- let upstream_wt = Worktree.path root (upstream_kind pkg) in
191191- let vendor_wt = Worktree.path root (vendor_kind pkg) in
155155+ let fs = fst git in
156156+ let git_path = snd git in
157157+ let parent_path = Filename.dirname git_path in
158158+ let temp_wt_path = Filename.concat parent_path temp_wt_name in
159159+ let temp_wt : Git.path = (fs, temp_wt_path) in
192160193193- (* Clear vendor content and copy new *)
194194- let vendor_pkg_path = Eio.Path.(vendor_wt / "vendor" / "opam" / pkg) in
195195- (try Eio.Path.rmtree vendor_pkg_path with _ -> ());
161161+ (* Remove any existing temp worktree *)
162162+ ignore (Git.run ~proc_mgr ~cwd:git ["worktree"; "remove"; "-f"; temp_wt_relpath]);
196163197197- copy_with_prefix
198198- ~src_dir:upstream_wt
199199- ~dst_dir:vendor_wt
200200- ~prefix:(vendor_path pkg);
164164+ (* Create worktree for the patches branch *)
165165+ Git.run_exn ~proc_mgr ~cwd:git ["worktree"; "add"; temp_wt_relpath; patches_branch pkg] |> ignore;
201166202202- (* Commit *)
203203- Git.add_all ~proc_mgr ~cwd:vendor_wt;
204204- Git.commit ~proc_mgr ~cwd:vendor_wt
205205- ~message:(Printf.sprintf "Update %s to %s" pkg (String.sub new_sha 0 7));
167167+ (* Try to rebase *)
168168+ let result = Git.rebase ~proc_mgr ~cwd:temp_wt ~onto:(upstream_branch pkg) in
206169207207- (* Cleanup *)
208208- Worktree.remove ~proc_mgr root (upstream_kind pkg);
209209- Worktree.remove ~proc_mgr root (vendor_kind pkg);
170170+ (* Cleanup temp worktree *)
171171+ ignore (Git.run ~proc_mgr ~cwd:git ["worktree"; "remove"; "-f"; temp_wt_relpath]);
210172211211- Backend.Updated { name = pkg; old_sha; new_sha }
173173+ match result with
174174+ | Ok () ->
175175+ Backend.Updated { name = pkg; old_sha; new_sha }
176176+ | Error (`Conflict hint) ->
177177+ (* Abort the rebase and report conflict *)
178178+ Git.rebase_abort ~proc_mgr ~cwd:git;
179179+ Backend.Update_failed { name = pkg; error = "Rebase conflict: " ^ hint }
180180+ end
212181 end
213182 end
214183 with exn ->
215215- (try Worktree.remove_force ~proc_mgr root (upstream_kind pkg) with _ -> ());
216216- (try Worktree.remove_force ~proc_mgr root (vendor_kind pkg) with _ -> ());
217184 Backend.Update_failed { name = pkg; error = Printexc.to_string exn }
218185219186let list_packages ~proc_mgr ~root =
+8-13
lib/opam/opam.mli
···11(** Opam backend for unpac.
2233- Implements vendoring of opam packages using the three-tier branch model:
33+ Implements vendoring of opam packages using a two-tier branch model:
44 - opam/upstream/<pkg> - pristine upstream code
55- - opam/vendor/<pkg> - orphan branch with vendor/opam/<pkg>/ prefix
66- - opam/patches/<pkg> - local modifications *)
55+ - opam/patches/<pkg> - local modifications on top of upstream
66+77+ Packages are merged into projects using git subtree, which places files
88+ under vendor/opam/<pkg>/ without rewriting upstream history. *)
79810val name : string
911(** Backend name: "opam" *)
···12141315val upstream_branch : string -> string
1416(** [upstream_branch pkg] returns "opam/upstream/<pkg>". *)
1515-1616-val vendor_branch : string -> string
1717-(** [vendor_branch pkg] returns "opam/vendor/<pkg>". *)
18171918val patches_branch : string -> string
2019(** [patches_branch pkg] returns "opam/patches/<pkg>". *)
···2524(** {1 Worktree Kinds} *)
26252726val upstream_kind : string -> Unpac.Worktree.kind
2828-val vendor_kind : string -> Unpac.Worktree.kind
2927val patches_kind : string -> Unpac.Worktree.kind
30283129(** {1 Package Operations} *)
···3937(** [add_package ~proc_mgr ~root ?cache info] vendors a single package.
40384139 1. Fetches upstream into opam/upstream/<pkg> (via cache if provided)
4242- 2. Creates opam/vendor/<pkg> with vendor/opam/<pkg>/ prefix (preserving history)
4343- 3. Creates opam/patches/<pkg> from vendor
4040+ 2. Creates opam/patches/<pkg> from upstream (initially identical)
44414545- Uses git-filter-repo for fast history rewriting.
4242+ Use [git subtree add] to merge into a project.
4643 @param cache Optional vendor cache for shared fetches across projects. *)
47444845val update_package :
···5451(** [update_package ~proc_mgr ~root ?cache name] updates a package from upstream.
55525653 1. Fetches latest into opam/upstream/<pkg> (via cache if provided)
5757- 2. Updates opam/vendor/<pkg> with new content
5858-5959- Does NOT rebase patches - call [Backend.rebase_patches] separately.
5454+ 2. Rebases opam/patches/<pkg> onto the new upstream
60556156 @param cache Optional vendor cache for shared fetches across projects. *)
6257
+54-119
lib/promote.ml
···11(** Project promotion to vendor library.
2233 Promotes a locally-developed project to a vendored library by:
44- 1. Filtering out the vendor/ directory from the project history
55- 2. Creating vendor branches (upstream/vendor/patches) for the specified backend
66- 3. Recording the promotion in the audit log
44+ 1. Using git subtree split to extract a subdirectory into a branch
55+ 2. Creating upstream/patches branches for the specified backend
7688- This allows the project to be merged into other projects as a dependency. *)
77+ This allows the project to be merged into other projects as a dependency
88+ using git subtree. *)
991010let src = Logs.Src.create "unpac.promote" ~doc:"Project promotion"
1111module Log = (val Logs.src_log src : Logs.LOG)
···2929 | Opam -> "opam/upstream/" ^ name
3030 | Git -> "git/upstream/" ^ name
31313232-let vendor_branch backend name = match backend with
3333- | Opam -> "opam/vendor/" ^ name
3434- | Git -> "git/vendor/" ^ name
3535-3632let patches_branch backend name = match backend with
3733 | Opam -> "opam/patches/" ^ name
3834 | Git -> "git/patches/" ^ name
···4642 | Promoted of {
4743 name : string;
4844 backend : backend;
4949- original_commits : int;
5050- filtered_commits : int;
4545+ source_prefix : string;
5146 }
5247 | Already_promoted of string
5348 | Project_not_found of string
5449 | Failed of { name : string; error : string }
55505656-(** Filter a branch to exclude vendor/ directory.
5757- Uses git-filter-repo to rewrite history. *)
5858-let filter_vendor_directory ~proc_mgr ~cwd ~branch =
5959- Log.info (fun m -> m "Filtering vendor/ directory from branch %s..." branch);
6060-6161- (* Use git-filter-repo with path filtering to exclude vendor/ *)
6262- let fs = fst cwd in
6363- let git_path = snd cwd in
6464- let parent_path = Filename.dirname git_path in
6565-6666- (* Create a unique temporary worktree *)
6767- let safe_branch = String.map (fun c -> if c = '/' then '-' else c) branch in
6868- let temp_wt_name = ".filter-vendor-" ^ safe_branch in
6969- let temp_wt_relpath = "../" ^ temp_wt_name in
7070- let temp_wt_path = Filename.concat parent_path temp_wt_name in
7171- let temp_wt : Git.path = (fs, temp_wt_path) in
7272-7373- (* Remove any existing temp worktree *)
7474- ignore (Git.run ~proc_mgr ~cwd ["worktree"; "remove"; "-f"; temp_wt_relpath]);
5151+(** Promote a project subdirectory to a vendored library using subtree split.
75527676- (* Create worktree for the branch *)
7777- Git.run_exn ~proc_mgr ~cwd ["worktree"; "add"; temp_wt_relpath; branch] |> ignore;
5353+ Unlike the old filter-repo approach, this extracts a specific subdirectory
5454+ from the project into standalone branches that can be subtree'd elsewhere.
78557979- (* Count commits before filtering *)
8080- let commits_before =
8181- int_of_string (String.trim (Git.run_exn ~proc_mgr ~cwd:temp_wt ["rev-list"; "--count"; "HEAD"]))
8282- in
8383-8484- (* Run git-filter-repo to exclude vendor/ *)
8585- let result = Git.run ~proc_mgr ~cwd:temp_wt [
8686- "filter-repo";
8787- "--invert-paths";
8888- "--path"; "vendor/";
8989- "--force";
9090- "--refs"; "HEAD"
9191- ] in
9292-9393- match result with
9494- | Ok _ ->
9595- (* Count commits after filtering *)
9696- let commits_after =
9797- int_of_string (String.trim (Git.run_exn ~proc_mgr ~cwd:temp_wt ["rev-list"; "--count"; "HEAD"]))
9898- in
9999- (* Get the new HEAD SHA *)
100100- let new_sha = Git.run_exn ~proc_mgr ~cwd:temp_wt ["rev-parse"; "HEAD"] |> String.trim in
101101- (* Cleanup temporary worktree *)
102102- ignore (Git.run ~proc_mgr ~cwd ["worktree"; "remove"; "-f"; temp_wt_relpath]);
103103- (* Update the branch in the bare repo *)
104104- Git.run_exn ~proc_mgr ~cwd ["branch"; "-f"; branch; new_sha] |> ignore;
105105- Ok (commits_before, commits_after)
106106- | Error e ->
107107- (* Cleanup and return error *)
108108- ignore (Git.run ~proc_mgr ~cwd ["worktree"; "remove"; "-f"; temp_wt_relpath]);
109109- Error (Fmt.str "%a" Git.pp_error e)
110110-111111-(** Promote a project to a vendored library *)
112112-let promote ~proc_mgr ~root ~project ~backend ~vendor_name =
5656+ @param prefix The subdirectory path within the project to extract (e.g., "src/mylib") *)
5757+let promote ~proc_mgr ~root ~project ~backend ~vendor_name ~prefix =
11358 let git = Worktree.git_dir root in
11459 let name = Option.value ~default:project vendor_name in
11560···12368 Already_promoted name
12469 else begin
12570 try
126126- Log.info (fun m -> m "Promoting project %s as %s vendor %s..." project (backend_to_string backend) name);
7171+ Log.info (fun m -> m "Promoting project %s prefix %s as %s vendor %s..."
7272+ project prefix (backend_to_string backend) name);
1277312874 let project_branch = Worktree.branch (Worktree.Project project) in
12975130130- (* Step 1: Create a temporary branch from the project for filtering *)
131131- let temp_branch = "promote-temp-" ^ name in
132132- Git.run_exn ~proc_mgr ~cwd:git ["branch"; "-f"; temp_branch; project_branch] |> ignore;
7676+ (* Create a temporary worktree for the project to run subtree split *)
7777+ let safe_name = String.map (fun c -> if c = '/' then '-' else c) project in
7878+ let temp_wt_name = ".promote-tmp-" ^ safe_name in
7979+ let temp_wt_relpath = "../" ^ temp_wt_name in
13380134134- (* Step 2: Filter out vendor/ directory from the temp branch *)
135135- let (commits_before, commits_after) =
136136- match filter_vendor_directory ~proc_mgr ~cwd:git ~branch:temp_branch with
137137- | Ok counts -> counts
138138- | Error msg ->
139139- (* Cleanup temp branch *)
140140- ignore (Git.run ~proc_mgr ~cwd:git ["branch"; "-D"; temp_branch]);
141141- failwith msg
142142- in
8181+ let fs = fst git in
8282+ let git_path = snd git in
8383+ let parent_path = Filename.dirname git_path in
8484+ let temp_wt_path = Filename.concat parent_path temp_wt_name in
8585+ let temp_wt : Git.path = (fs, temp_wt_path) in
14386144144- Log.info (fun m -> m "Filtered %d -> %d commits" commits_before commits_after);
145145-146146- (* Step 3: Create upstream branch (filtered, files at root) *)
147147- (* For local projects, upstream is the same as filtered temp - no external upstream *)
148148- Git.run_exn ~proc_mgr ~cwd:git ["branch"; "-f"; upstream_branch backend name; temp_branch] |> ignore;
8787+ (* Remove any existing temp worktree *)
8888+ ignore (Git.run ~proc_mgr ~cwd:git ["worktree"; "remove"; "-f"; temp_wt_relpath]);
14989150150- (* Step 4: Create vendor branch from upstream and rewrite to vendor path *)
151151- Git.run_exn ~proc_mgr ~cwd:git ["branch"; "-f"; vendor_branch backend name; upstream_branch backend name] |> ignore;
9090+ (* Create worktree for the project branch *)
9191+ Git.run_exn ~proc_mgr ~cwd:git ["worktree"; "add"; temp_wt_relpath; project_branch] |> ignore;
15292153153- (* Rewrite vendor branch to move files into vendor/<backend>/<name>/ *)
154154- Git.filter_repo_to_subdirectory ~proc_mgr ~cwd:git
155155- ~branch:(vendor_branch backend name)
156156- ~subdirectory:(vendor_path backend name);
9393+ (* Use git subtree split to extract the prefix into a new branch *)
9494+ let upstream_br = upstream_branch backend name in
9595+ let result = Git.run ~proc_mgr ~cwd:temp_wt [
9696+ "subtree"; "split"; "--prefix"; prefix; "-b"; upstream_br
9797+ ] in
15798158158- (* Step 5: Create patches branch from vendor *)
159159- Git.run_exn ~proc_mgr ~cwd:git ["branch"; patches_branch backend name; vendor_branch backend name] |> ignore;
9999+ (* Cleanup temp worktree *)
100100+ ignore (Git.run ~proc_mgr ~cwd:git ["worktree"; "remove"; "-f"; temp_wt_relpath]);
160101161161- (* Step 6: Cleanup temp branch *)
162162- ignore (Git.run ~proc_mgr ~cwd:git ["branch"; "-D"; temp_branch]);
102102+ (match result with
103103+ | Error e ->
104104+ raise (Eio.Exn.create (Git.E e))
105105+ | Ok _ ->
106106+ (* Create patches branch from upstream (initially identical) *)
107107+ Git.branch_create ~proc_mgr ~cwd:git
108108+ ~name:(patches_branch backend name)
109109+ ~start_point:upstream_br;
163110164164- Promoted {
165165- name;
166166- backend;
167167- original_commits = commits_before;
168168- filtered_commits = commits_after
169169- }
111111+ Promoted {
112112+ name;
113113+ backend;
114114+ source_prefix = prefix;
115115+ })
170116 with exn ->
171117 (* Cleanup on failure *)
172172- let temp_branch = "promote-temp-" ^ name in
173173- ignore (Git.run ~proc_mgr ~cwd:git ["branch"; "-D"; temp_branch]);
174118 ignore (Git.run ~proc_mgr ~cwd:git ["branch"; "-D"; upstream_branch backend name]);
175175- ignore (Git.run ~proc_mgr ~cwd:git ["branch"; "-D"; vendor_branch backend name]);
176119 Failed { name = project; error = Printexc.to_string exn }
177120 end
178121 end
···301244 | Already_exported of string
302245 | Export_failed of { name : string; error : string }
303246304304-(** Export a vendored package back to root-level files.
305305- This is the inverse of vendoring - takes a vendor branch and creates
306306- an export branch with files moved from vendor/<backend>/<name>/ to root.
247247+(** Export a vendored package to an export branch for pushing.
307248308308- Can export from either vendor/* or patches/* branch. *)
309309-let export ~proc_mgr ~root ~name ~backend ~from_patches =
249249+ In the new subtree architecture, upstream and patches branches already have
250250+ files at root, so export is just creating a copy of the patches branch. *)
251251+let export ~proc_mgr ~root ~name ~backend =
310252 let git = Worktree.git_dir root in
311253312312- (* Determine source branch *)
313313- let source_br = if from_patches then patches_branch backend name
314314- else vendor_branch backend name in
254254+ (* Source is always the patches branch (which has files at root) *)
255255+ let source_br = patches_branch backend name in
315256 let export_br = export_branch backend name in
316316- let subdir = vendor_path backend name in
317257318258 (* Check if source branch exists *)
319259 if not (Git.branch_exists ~proc_mgr ~cwd:git source_br) then
···324264 try
325265 Log.info (fun m -> m "Exporting %s from %s to %s..." name source_br export_br);
326266327327- (* Step 1: Create export branch from source *)
328328- Git.run_exn ~proc_mgr ~cwd:git ["branch"; "-f"; export_br; source_br] |> ignore;
267267+ (* Create export branch as a copy of patches branch *)
268268+ Git.run_exn ~proc_mgr ~cwd:git ["branch"; export_br; source_br] |> ignore;
329269330330- (* Step 2: Count commits before transformation *)
270270+ (* Count commits *)
331271 let commits =
332272 int_of_string (String.trim (
333273 Git.run_exn ~proc_mgr ~cwd:git ["rev-list"; "--count"; export_br]))
334274 in
335335-336336- (* Step 3: Rewrite export branch to move files from subdirectory to root *)
337337- Git.filter_repo_from_subdirectory ~proc_mgr ~cwd:git
338338- ~branch:export_br
339339- ~subdirectory:subdir;
340275341276 Exported {
342277 name;
+24-32
lib/promote.mli
···11(** Project promotion to vendor library.
2233 Promotes a locally-developed project to a vendored library by:
44- 1. Filtering out the vendor/ directory from the project history
55- 2. Creating vendor branches (upstream/vendor/patches) for the specified backend
66- 3. Recording the promotion in the audit log
44+ 1. Using git subtree split to extract a subdirectory into a branch
55+ 2. Creating upstream/patches branches for the specified backend
7688- This allows the project to be merged into other projects as a dependency. *)
77+ This allows the project to be merged into other projects as a dependency
88+ using git subtree. *)
991010(** {1 Backend Types} *)
1111···2626(** [upstream_branch backend name] returns the upstream branch name,
2727 e.g., "opam/upstream/brotli" or "git/upstream/brotli" *)
28282929-val vendor_branch : backend -> string -> string
3030-(** [vendor_branch backend name] returns the vendor branch name *)
3131-3229val patches_branch : backend -> string -> string
3330(** [patches_branch backend name] returns the patches branch name *)
3431···4340 | Promoted of {
4441 name : string; (** Vendor library name *)
4542 backend : backend; (** Backend used *)
4646- original_commits : int; (** Commits in project before filtering *)
4747- filtered_commits : int; (** Commits after removing vendor/ *)
4343+ source_prefix : string; (** Prefix extracted from project *)
4844 }
4945 | Already_promoted of string
5046 (** Library already exists with this name *)
···5955 project:string ->
6056 backend:backend ->
6157 vendor_name:string option ->
5858+ prefix:string ->
6259 promote_result
6363-(** [promote ~proc_mgr ~root ~project ~backend ~vendor_name] promotes
6464- a local project to a vendored library.
6060+(** [promote ~proc_mgr ~root ~project ~backend ~vendor_name ~prefix] promotes
6161+ a project subdirectory to a vendored library using subtree split.
65626663 The operation:
6764 1. Checks that the project exists and hasn't been promoted yet
6868- 2. Creates a filtered copy of project history (excluding vendor/)
6969- 3. Creates upstream/vendor/patches branches for the backend
6565+ 2. Uses [git subtree split] to extract the prefix into a branch
6666+ 3. Creates upstream/patches branches for the backend
7067 4. The original project branch is preserved unchanged
71687272- @param project Name of the project to promote (e.g., "brotli")
6969+ @param project Name of the project to promote (e.g., "myapp")
7370 @param backend Backend type (Opam or Git)
7471 @param vendor_name Optional override for the vendor library name
7272+ @param prefix The subdirectory path within the project to extract (e.g., "src/mylib")
75737674 After promotion, the library can be merged into other projects using:
7775 - [unpac opam merge <name> <project>] for Opam backend
···142140(** [get_info ~proc_mgr ~root ~project] returns information about a project,
143141 or None if the project doesn't exist. *)
144142145145-(** {1 Export (Unvendor)}
146146-147147- Export reverses the vendoring process, creating a branch with files
148148- at the repository root suitable for pushing to an external git repo.
143143+(** {1 Export}
149144150150- This is the inverse of vendoring:
151151- - Vendoring: files at root โ files in vendor/<backend>/<name>/
152152- - Exporting: files in vendor/<backend>/<name>/ โ files at root *)
145145+ Export creates a branch suitable for pushing to an external git repo.
146146+ In the subtree architecture, patches branches already have files at root,
147147+ so export is simply a copy of the patches branch. *)
153148154149val export_branch : backend -> string -> string
155150(** [export_branch backend name] returns the export branch name,
···160155 | Exported of {
161156 name : string; (** Package name *)
162157 backend : backend; (** Backend used *)
163163- source_branch : string; (** Branch exported from (vendor or patches) *)
158158+ source_branch : string; (** Branch exported from (patches) *)
164159 export_branch : string; (** Created export branch *)
165160 commits : int; (** Number of commits in export *)
166161 }
167162 | Not_vendored of string
168168- (** No vendor branch exists for this package *)
163163+ (** No patches branch exists for this package *)
169164 | Already_exported of string
170165 (** Export branch already exists *)
171166 | Export_failed of { name : string; error : string }
···176171 root:Worktree.root ->
177172 name:string ->
178173 backend:backend ->
179179- from_patches:bool ->
180174 export_result
181181-(** [export ~proc_mgr ~root ~name ~backend ~from_patches] exports a vendored
182182- package back to root-level files.
175175+(** [export ~proc_mgr ~root ~name ~backend] exports a vendored
176176+ package to an export branch for pushing.
183177184184- Creates an export branch where files are moved from [vendor/<backend>/<name>/]
185185- to the repository root. This branch can then be pushed to an upstream repo.
178178+ Creates an export branch as a copy of the patches branch.
179179+ This branch can then be pushed to an upstream repo.
186180187181 @param name The vendored package name
188182 @param backend The backend (Opam or Git)
189189- @param from_patches If true, exports from patches/* branch (includes local mods);
190190- if false, exports from vendor/* branch (pristine upstream)
191183192184 The export branch is named [<backend>/export/<name>], e.g., "git/export/brotli".
193185194186 Example workflow:
195187 {[
196196- (* Export with local patches *)
197197- export ~from_patches:true ...
188188+ (* Export *)
189189+ export ...
198190199191 (* Set remote and push *)
200192 set_export_remote ~url:"git@github.com:me/brotli.git" ...
+4-9
lib/worktree.ml
···1212 | Main
1313 | Project of string
1414 | Opam_upstream of string
1515- | Opam_vendor of string
1615 | Opam_patches of string
1716 | Git_upstream of string
1818- | Git_vendor of string
1917 | Git_patches of string
2018(** Worktree kinds with their associated names.
2119 Opam_* variants are for opam package vendoring.
2222- Git_* variants are for direct git repository vendoring. *)
2020+ Git_* variants are for direct git repository vendoring.
2121+2222+ Note: In the subtree architecture, upstream and patches are branches only
2323+ (no worktrees). These variants are kept for branch name computation. *)
23242425(** {1 Path and Branch Helpers} *)
2526···3031 | Main -> Eio.Path.(root / "main")
3132 | Project name -> Eio.Path.(root / "project" / name)
3233 | Opam_upstream name -> Eio.Path.(root / "opam" / "upstream" / name)
3333- | Opam_vendor name -> Eio.Path.(root / "opam" / "vendor" / name)
3434 | Opam_patches name -> Eio.Path.(root / "opam" / "patches" / name)
3535 | Git_upstream name -> Eio.Path.(root / "git-repos" / "upstream" / name)
3636- | Git_vendor name -> Eio.Path.(root / "git-repos" / "vendor" / name)
3736 | Git_patches name -> Eio.Path.(root / "git-repos" / "patches" / name)
38373938let branch = function
4039 | Main -> "main"
4140 | Project name -> "project/" ^ name
4241 | Opam_upstream name -> "opam/upstream/" ^ name
4343- | Opam_vendor name -> "opam/vendor/" ^ name
4442 | Opam_patches name -> "opam/patches/" ^ name
4543 | Git_upstream name -> "git/upstream/" ^ name
4646- | Git_vendor name -> "git/vendor/" ^ name
4744 | Git_patches name -> "git/patches/" ^ name
48454946let relative_path = function
5047 | Main -> "main"
5148 | Project name -> "project/" ^ name
5249 | Opam_upstream name -> "opam/upstream/" ^ name
5353- | Opam_vendor name -> "opam/vendor/" ^ name
5450 | Opam_patches name -> "opam/patches/" ^ name
5551 | Git_upstream name -> "git-repos/upstream/" ^ name
5656- | Git_vendor name -> "git-repos/vendor/" ^ name
5752 | Git_patches name -> "git-repos/patches/" ^ name
58535954(** {1 Queries} *)
+21-22
lib/worktree.mli
···11(** Git worktree lifecycle management for unpac.
2233 Manages creation, cleanup, and paths of worktrees within the unpac
44- directory structure. All branch operations happen in isolated worktrees.
44+ directory structure. Project branches get isolated worktrees.
5566 {2 Directory Structure}
7788 An unpac project has this layout:
99 {v
1010 my-project/
1111- โโโ git/ # Bare repository
1111+ โโโ git/ # Bare repository (stores all branches)
1212 โโโ main/ # Worktree โ main branch
1313- โโโ project/
1414- โ โโโ myapp/ # Worktree โ project/myapp
1515- โโโ opam/
1616- โ โโโ upstream/
1717- โ โ โโโ pkg/ # Worktree โ opam/upstream/pkg
1818- โ โโโ vendor/
1919- โ โ โโโ pkg/ # Worktree โ opam/vendor/pkg
2020- โ โโโ patches/
2121- โ โโโ pkg/ # Worktree โ opam/patches/pkg
2222- โโโ git-repos/
2323- โโโ upstream/
2424- โ โโโ repo/ # Worktree โ git/upstream/repo
2525- โโโ vendor/
2626- โ โโโ repo/ # Worktree โ git/vendor/repo
2727- โโโ patches/
2828- โโโ repo/ # Worktree โ git/patches/repo
2929- v} *)
1313+ โโโ project/
1414+ โโโ myapp/ # Worktree โ project/myapp
1515+ v}
1616+1717+ {2 Branch Structure}
1818+1919+ Branches are organized as:
2020+ - [main] - main branch with unpac.toml configuration
2121+ - [project/<name>] - project branches (have worktrees)
2222+ - [opam/upstream/<pkg>] - pristine upstream for opam packages (branch only)
2323+ - [opam/patches/<pkg>] - local modifications for opam packages (branch only)
2424+ - [git/upstream/<name>] - pristine upstream for git repos (branch only)
2525+ - [git/patches/<name>] - local modifications for git repos (branch only)
2626+2727+ Upstream and patches branches are merged into projects using git subtree. *)
30283129(** {1 Types} *)
3230···3735 | Main
3836 | Project of string
3937 | Opam_upstream of string
4040- | Opam_vendor of string
4138 | Opam_patches of string
4239 | Git_upstream of string
4343- | Git_vendor of string
4440 | Git_patches of string
4541(** Worktree kinds with their associated names.
4642 Opam_* variants are for opam package vendoring.
4747- Git_* variants are for direct git repository vendoring. *)
4343+ Git_* variants are for direct git repository vendoring.
4444+4545+ Note: In the subtree architecture, upstream and patches are branches only
4646+ (no worktrees). These variants are kept for branch name computation. *)
48474948(** {1 Path and Branch Helpers} *)
5049
+5-5
test/cram/full-workflow.t
···2828Initialize unpac project
29293030 $ unpac init myproj
3131- Initialized unpac project at myproj
3131+ Initialized unpac workspace at myproj
32323333 Next steps:
3434 cd myproj
···53535454Create a project
55555656- $ unpac project new myapp
5656+ $ unpac project new myapp 2>&1 | grep -v "^\[INFO\]\|unpac: \[INFO\]"
5757 Created project myapp
58585959 Next steps:
···8888 $ unpac opam edit testlib 2>&1 | head -1
8989 Editing testlib
90909191-Make a change in the patches worktree
9191+Make a change in the patches worktree (files are at root, not under vendor/)
92929393- $ echo 'let goodbye () = "Goodbye!"' >> opam/patches/testlib/vendor/opam/testlib/lib.ml
9393+ $ echo 'let goodbye () = "Goodbye!"' >> opam/patches/testlib/lib.ml
9494 $ (cd opam/patches/testlib && git add -A && git commit -q -m "Add goodbye function")
95959696Now we have local changes
···113113Merge into project
114114115115 $ unpac opam merge testlib myapp 2>&1 | grep -E "^(Merged|Merge)"
116116- Merged testlib into project myapp
116116+ Merged testlib to vendor/opam/testlib
117117118118Verify files in project
119119
+2-2
test/cram/init.t
···11Initialize a new unpac project
2233 $ unpac init myproject
44- Initialized unpac project at myproject
44+ Initialized unpac workspace at myproject
5566 Next steps:
77 cd myproject
···2929Check git branches (main is in worktree so shows +)
30303131 $ git -C myproject/git branch
3232- + main
3232+ * main
33333434Init should fail if directory exists
3535
+8-9
test/cram/opam.t
···2121Create unpac project
22222323 $ unpac init myproj
2424- Initialized unpac project at myproj
2424+ Initialized unpac workspace at myproj
25252626 Next steps:
2727 cd myproj
···5151 $ git -C git branch | grep opam | sort
5252 opam/patches/testpkg
5353 opam/upstream/testpkg
5454- opam/vendor/testpkg
55545656-Check vendor branch has prefixed content
5555+Check patches branch has content at root (no vendor/ prefix in branch)
57565858- $ git -C git show opam/vendor/testpkg --name-only | grep "^vendor/"
5959- vendor/opam/testpkg/dune-project
6060- vendor/opam/testpkg/lib.ml
5757+ $ git -C git show opam/patches/testpkg --name-only | tail -2
5858+ dune-project
5959+ lib.ml
61606261Create a project and merge
63626464- $ unpac project new myapp
6363+ $ unpac project new myapp 2>&1 | grep -v "^\[INFO\]\|unpac: \[INFO\]"
6564 Created project myapp
66656766 Next steps:
···6968 unpac opam merge <package> myapp # merge package into project
70697170 $ unpac opam merge testpkg myapp 2>&1 | grep -E "^(Merged|Merge conflict)"
7272- Merged testpkg into project myapp
7171+ Merged testpkg to vendor/opam/testpkg
73727473Check files appear in project
7574···82818382Check git log shows merge
84838585- $ git -C project/myapp log --oneline | wc -l
8484+ $ git -C project/myapp log --oneline | wc -l | tr -d ' '
8685 3
+4-3
test/cram/project.t
···33Setup: create an unpac project first
4455 $ unpac init testproj
66- Initialized unpac project at testproj
66+ Initialized unpac workspace at testproj
7788 Next steps:
99 cd testproj
···13131414Create a new project
15151616- $ unpac project new myapp
1616+ $ unpac project new myapp 2>&1 | grep -v "^\[INFO\]\|unpac: \[INFO\]"
1717 Created project myapp
18181919 Next steps:
···3939Check vendor directory structure
40404141 $ ls project/myapp/vendor
4242+ dune
4243 opam
43444445List projects
···53545455Create another project
55565656- $ unpac project new otherapp
5757+ $ unpac project new otherapp 2>&1 | grep -v "^\[INFO\]\|unpac: \[INFO\]"
5758 Created project otherapp
58595960 Next steps: