···318318 let doc = "Path to vendor cache (overrides config and UNPAC_VENDOR_CACHE env var)." in
319319 Arg.(value & opt (some string) None & info ["cache"] ~docv:"PATH" ~doc)
320320 in
321321- let fast_arg =
322322- let doc = "Fast mode: skip history rewriting (useful for large repos like dune)." in
323323- Arg.(value & flag & info ["fast"] ~doc)
324324- in
325325- let run () pkg name_opt version_opt branch_opt solve cli_cache fast =
326326- let preserve_history = not fast in
321321+ let run () pkg name_opt version_opt branch_opt solve cli_cache =
327322 with_root @@ fun ~env:_ ~fs ~proc_mgr ~root ->
328323 let config = load_config root in
329324 let cache = resolve_cache ~proc_mgr ~fs ~config ~cli_cache in
···388383 url;
389384 branch = None;
390385 } in
391391- match Unpac_opam.Opam.add_package ~preserve_history ~proc_mgr ~root ?cache info with
386386+ match Unpac_opam.Opam.add_package ~proc_mgr ~root ?cache info with
392387 | Unpac.Backend.Added { name = pkg_name; sha } ->
393388 Format.printf "Added %s (%s)@." pkg_name (String.sub sha 0 7);
394389 if List.length g.packages > 1 then
···447442 url;
448443 branch = branch_opt;
449444 } in
450450- match Unpac_opam.Opam.add_package ~preserve_history ~proc_mgr ~root ?cache info with
445445+ match Unpac_opam.Opam.add_package ~proc_mgr ~root ?cache info with
451446 | Unpac.Backend.Added { name = pkg_name; sha } ->
452447 Format.printf "Added %s (%s)@." pkg_name (String.sub sha 0 7);
453448 Format.printf "@.Next steps:@.";
···461456 end
462457 in
463458 let info = Cmd.info "add" ~doc in
464464- Cmd.v info Term.(const run $ logging_term $ pkg_arg $ name_arg $ version_arg $ branch_arg $ solve_arg $ cache_arg $ fast_arg)
459459+ Cmd.v info Term.(const run $ logging_term $ pkg_arg $ name_arg $ version_arg $ branch_arg $ solve_arg $ cache_arg)
465460466461(* Opam list command *)
467462let opam_list_cmd =
···577572(* Opam merge command *)
578573let opam_merge_cmd =
579574 let doc = "Merge vendored opam packages into a project. \
580580- Use --all to merge all vendored packages." in
575575+ Use --solve to merge a package and its dependencies, \
576576+ or --all to merge all vendored packages." in
581577 let args =
582582- let doc = "With --all: PROJECT. Without --all: PACKAGE PROJECT." in
578578+ let doc = "PACKAGE PROJECT (or just PROJECT with --all)." in
583579 Arg.(value & pos_all string [] & info [] ~docv:"ARGS" ~doc)
584580 in
585581 let all_flag =
586582 let doc = "Merge all vendored packages into the project." in
587583 Arg.(value & flag & info ["all"] ~doc)
588584 in
589589- let run () args all =
585585+ let solve_flag =
586586+ let doc = "Solve dependencies for PACKAGE and merge all solved packages into the project." in
587587+ Arg.(value & flag & info ["solve"] ~doc)
588588+ in
589589+ let run () args all solve =
590590 with_root @@ fun ~env:_ ~fs:_ ~proc_mgr ~root ->
591591- (* Parse arguments based on --all flag *)
592592- let pkg, project = if all then
593593- match args with
594594- | [project] -> None, project
595595- | _ ->
596596- Format.eprintf "Usage: unpac opam merge --all PROJECT@.";
597597- exit 1
598598- else
599599- match args with
600600- | [pkg; project] -> Some pkg, project
601601- | _ ->
602602- Format.eprintf "Usage: unpac opam merge PACKAGE PROJECT@.";
603603- exit 1
604604- in
605605- let merge_one pkg =
591591+ let config = load_config root in
592592+593593+ let merge_one ~project pkg =
606594 let patches_branch = Unpac_opam.Opam.patches_branch pkg in
607595 match Unpac.Backend.merge_to_project ~proc_mgr ~root ~project ~patches_branch with
608596 | Ok () ->
609609- Format.printf "Merged %s into project %s@." pkg project;
597597+ Format.printf "Merged %s@." pkg;
610598 true
611599 | Error (`Conflict files) ->
612600 Format.eprintf "Merge conflict in %s:@." pkg;
613601 List.iter (Format.eprintf " %s@.") files;
614602 false
615603 in
616616- if all then begin
617617- (* Merge all vendored packages *)
618618- let packages = Unpac_opam.Opam.list_packages ~proc_mgr ~root in
619619- if packages = [] then begin
620620- Format.eprintf "No vendored packages to merge.@.";
621621- exit 1
622622- end;
604604+605605+ let merge_packages packages project =
623606 Format.printf "Merging %d packages into project %s...@." (List.length packages) project;
624607 let (successes, failures) = List.fold_left (fun (s, f) pkg ->
625625- if merge_one pkg then (s + 1, f) else (s, f + 1)
608608+ if merge_one ~project pkg then (s + 1, f) else (s, f + 1)
626609 ) (0, 0) packages in
627610 Format.printf "@.Done: %d merged" successes;
628611 if failures > 0 then Format.printf ", %d had conflicts" failures;
···632615 exit 1
633616 end else
634617 Format.printf "Next: Build your project in project/%s@." project
635635- end else begin
636636- match pkg with
637637- | Some pkg ->
638638- if merge_one pkg then
639639- Format.printf "@.Next: Build your project in project/%s@." project
640640- else begin
641641- Format.eprintf "Resolve conflicts in project/%s and commit.@." project;
618618+ in
619619+620620+ if solve then begin
621621+ (* Solve dependencies and merge all solved packages that are vendored *)
622622+ let pkg, project = match args with
623623+ | [pkg; project] -> pkg, project
624624+ | _ ->
625625+ Format.eprintf "Usage: unpac opam merge --solve PACKAGE PROJECT@.";
642626 exit 1
643643- end
644644- | None ->
645645- Format.eprintf "Error: Either provide a package name or use --all@.";
627627+ in
628628+ let repos = config.opam.repositories in
629629+ if repos = [] then begin
630630+ Format.eprintf "No repositories configured. Add one with: unpac opam repo add <name> <path>@.";
631631+ exit 1
632632+ end;
633633+ let ocaml_version = match Unpac.Config.get_compiler config with
634634+ | Some v -> v
635635+ | None ->
636636+ Format.eprintf "No compiler version configured.@.";
637637+ Format.eprintf "Set one with: unpac opam config compiler 5.2.0@.";
638638+ exit 1
639639+ in
640640+ let repo_paths = List.map (fun (r : Unpac.Config.repo_config) ->
641641+ match r.source with
642642+ | Unpac.Config.Local p -> p
643643+ | Unpac.Config.Remote u -> u
644644+ ) repos in
645645+ Format.printf "Solving dependencies for %s...@." pkg;
646646+ match Unpac_opam.Solver.solve ~repos:repo_paths ~ocaml_version ~packages:[pkg] with
647647+ | Error msg ->
648648+ Format.eprintf "Dependency solving failed:@.%s@." msg;
646649 exit 1
650650+ | Ok result ->
651651+ (* Group by dev-repo to get canonical names *)
652652+ let groups = group_packages_by_dev_repo ~config result.packages in
653653+ let canonical_names = List.map (fun (g : package_group) -> g.canonical_name) groups in
654654+ (* Filter to only vendored packages *)
655655+ let vendored = Unpac_opam.Opam.list_packages ~proc_mgr ~root in
656656+ let to_merge = List.filter (fun name -> List.mem name vendored) canonical_names in
657657+ if to_merge = [] then begin
658658+ Format.eprintf "No vendored packages match the solved dependencies.@.";
659659+ Format.eprintf "Run 'unpac opam add %s --solve' first to vendor them.@." pkg;
660660+ exit 1
661661+ end;
662662+ Format.printf "Found %d vendored packages to merge.@.@." (List.length to_merge);
663663+ merge_packages to_merge project
664664+ end else if all then begin
665665+ (* Merge all vendored packages *)
666666+ let project = match args with
667667+ | [project] -> project
668668+ | _ ->
669669+ Format.eprintf "Usage: unpac opam merge --all PROJECT@.";
670670+ exit 1
671671+ in
672672+ let packages = Unpac_opam.Opam.list_packages ~proc_mgr ~root in
673673+ if packages = [] then begin
674674+ Format.eprintf "No vendored packages to merge.@.";
675675+ exit 1
676676+ end;
677677+ merge_packages packages project
678678+ end else begin
679679+ (* Single package mode *)
680680+ let pkg, project = match args with
681681+ | [pkg; project] -> pkg, project
682682+ | _ ->
683683+ Format.eprintf "Usage: unpac opam merge PACKAGE PROJECT@.";
684684+ exit 1
685685+ in
686686+ if merge_one ~project pkg then
687687+ Format.printf "@.Next: Build your project in project/%s@." project
688688+ else begin
689689+ Format.eprintf "Resolve conflicts in project/%s and commit.@." project;
690690+ exit 1
691691+ end
647692 end
648693 in
649694 let info = Cmd.info "merge" ~doc in
650650- Cmd.v info Term.(const run $ logging_term $ args $ all_flag)
695695+ Cmd.v info Term.(const run $ logging_term $ args $ all_flag $ solve_flag)
651696652697(* Opam info command *)
653698let opam_info_cmd =
···849894 let info = Cmd.info "push" ~doc in
850895 Cmd.v info Term.(const run $ logging_term $ remote_arg $ force_arg $ dry_run_arg)
851896897897+(* Vendor status command *)
898898+let vendor_status_cmd =
899899+ let doc = "Show status of all vendored packages." in
900900+ let run () =
901901+ with_root @@ fun ~env:_ ~fs:_ ~proc_mgr ~root ->
902902+ let git = Unpac.Worktree.git_dir root in
903903+904904+ (* Get all vendored packages *)
905905+ let packages = Unpac_opam.Opam.list_packages ~proc_mgr ~root in
906906+ if packages = [] then begin
907907+ Format.printf "No vendored packages.@.";
908908+ exit 0
909909+ end;
910910+911911+ (* Get all project branches *)
912912+ let all_branches = Unpac.Git.run_lines ~proc_mgr ~cwd:git
913913+ ["branch"; "--format=%(refname:short)"] in
914914+ let project_branches = List.filter (fun b ->
915915+ String.starts_with ~prefix:"project/" b
916916+ ) all_branches in
917917+ let project_names = List.map (fun b ->
918918+ String.sub b 8 (String.length b - 8) (* Remove "project/" prefix *)
919919+ ) project_branches in
920920+921921+ (* Print header *)
922922+ Format.printf "%-25s %8s %s@." "Package" "Patches" "Merged into";
923923+ Format.printf "%s@." (String.make 70 '-');
924924+925925+ (* For each package, get patch count and merge status *)
926926+ List.iter (fun pkg ->
927927+ let vendor_branch = Unpac_opam.Opam.vendor_branch pkg in
928928+ let patches_branch = Unpac_opam.Opam.patches_branch pkg in
929929+930930+ (* Count commits on patches that aren't on vendor *)
931931+ let patch_count =
932932+ let output = Unpac.Git.run_exn ~proc_mgr ~cwd:git
933933+ ["rev-list"; "--count"; vendor_branch ^ ".." ^ patches_branch] in
934934+ int_of_string (String.trim output)
935935+ in
936936+937937+ (* Check which projects contain this package's patches *)
938938+ let merged_into = List.filter (fun proj_name ->
939939+ let proj_branch = "project/" ^ proj_name in
940940+ (* Check if patches branch is an ancestor of project branch *)
941941+ match Unpac.Git.run ~proc_mgr ~cwd:git
942942+ ["merge-base"; "--is-ancestor"; patches_branch; proj_branch] with
943943+ | Ok _ -> true
944944+ | Error _ -> false
945945+ ) project_names in
946946+947947+ let merged_str = if merged_into = [] then "-"
948948+ else String.concat ", " merged_into in
949949+950950+ Format.printf "%-25s %8d %s@." pkg patch_count merged_str
951951+ ) packages;
952952+953953+ Format.printf "@.Total: %d packages@." (List.length packages)
954954+ in
955955+ let info = Cmd.info "status" ~doc in
956956+ Cmd.v info Term.(const run $ logging_term)
957957+958958+(* Vendor command group *)
959959+let vendor_cmd =
960960+ let doc = "Vendor status and management commands." in
961961+ let info = Cmd.info "vendor" ~doc in
962962+ Cmd.group info [vendor_status_cmd]
963963+852964(* Main command *)
853965let main_cmd =
854966 let doc = "Multi-backend vendoring tool using git worktrees." in
855967 let info = Cmd.info "unpac" ~version:"0.1.0" ~doc in
856856- Cmd.group info [init_cmd; project_cmd; opam_cmd; push_cmd]
968968+ Cmd.group info [init_cmd; project_cmd; opam_cmd; vendor_cmd; push_cmd]
857969858970let () = exit (Cmd.eval main_cmd)
+9-22
lib/git.ml
···396396 Log.debug (fun m -> m "Cleaning untracked files");
397397 run_exn ~proc_mgr ~cwd ["clean"; "-fd"] |> ignore
398398399399-let filter_branch_to_subdirectory ~proc_mgr ~cwd ~branch ~subdirectory =
399399+let filter_repo_to_subdirectory ~proc_mgr ~cwd ~branch ~subdirectory =
400400 Log.info (fun m -> m "Rewriting history of %s into subdirectory %s..." branch subdirectory);
401401- (* Use filter-branch with index-filter to rewrite all paths into subdirectory.
402402- This preserves full history with paths prefixed.
401401+ (* Use git-filter-repo with --to-subdirectory-filter to rewrite all paths into subdirectory.
402402+ This preserves full history with paths prefixed. Much faster than filter-branch.
403403404404- For bare repositories, we need to create a temporary worktree, run filter-branch
404404+ For bare repositories, we need to create a temporary worktree, run filter-repo
405405 there, and then update the branch in the bare repo. *)
406406407407 (* Create a unique temporary worktree name using the branch name *)
···422422 (* Create worktree for the branch *)
423423 run_exn ~proc_mgr ~cwd ["worktree"; "add"; temp_wt_relpath; branch] |> ignore;
424424425425- (* Run filter-branch in the worktree *)
426426- let script = Printf.sprintf
427427- {|git ls-files -s | sed "s,\t,&%s/," | GIT_INDEX_FILE=$GIT_INDEX_FILE.new git update-index --index-info && mv "$GIT_INDEX_FILE.new" "$GIT_INDEX_FILE"|}
428428- subdirectory
429429- in
430430-431431- (* Set environment to suppress the warning *)
432432- let old_env = try Some (Unix.getenv "FILTER_BRANCH_SQUELCH_WARNING") with Not_found -> None in
433433- Unix.putenv "FILTER_BRANCH_SQUELCH_WARNING" "1";
434434-425425+ (* Run git-filter-repo in the worktree *)
435426 let result = run ~proc_mgr ~cwd:temp_wt [
436436- "filter-branch"; "-f";
437437- "--index-filter"; script;
438438- "--"; "HEAD"
427427+ "filter-repo";
428428+ "--to-subdirectory-filter"; subdirectory;
429429+ "--force";
430430+ "--refs"; "HEAD"
439431 ] in
440440-441441- (* Restore environment *)
442442- (match old_env with
443443- | Some v -> Unix.putenv "FILTER_BRANCH_SQUELCH_WARNING" v
444444- | None -> (try Unix.putenv "FILTER_BRANCH_SQUELCH_WARNING" "" with _ -> ()));
445432446433 (* Handle result: get the new SHA, cleanup worktree, then update branch *)
447434 (match result with
+3-3
lib/git.mli
···345345 unit
346346(** [clean_fd] removes untracked files and directories. *)
347347348348-val filter_branch_to_subdirectory :
348348+val filter_repo_to_subdirectory :
349349 proc_mgr:proc_mgr ->
350350 cwd:path ->
351351 branch:string ->
352352 subdirectory:string ->
353353 unit
354354-(** [filter_branch_to_subdirectory ~proc_mgr ~cwd ~branch ~subdirectory]
354354+(** [filter_repo_to_subdirectory ~proc_mgr ~cwd ~branch ~subdirectory]
355355 rewrites the history of [branch] so all files are moved into [subdirectory].
356356- This preserves full commit history with paths prefixed. *)
356356+ Uses git-filter-repo for fast history rewriting. Preserves full commit history. *)
+12-40
lib/opam/opam.ml
···6666 end
6767 )
68686969-let add_package ?(preserve_history=true) ~proc_mgr ~root ?cache (info : Backend.package_info) =
6969+let add_package ~proc_mgr ~root ?cache (info : Backend.package_info) =
7070 let pkg = info.name in
7171 let git = Worktree.git_dir root in
7272···105105 Git.branch_force ~proc_mgr ~cwd:git
106106 ~name:(upstream_branch pkg) ~point:ref_point;
107107108108- let vendor_sha =
109109- if preserve_history then begin
110110- (* Step 2a: Create vendor branch from upstream (preserves history) *)
111111- Git.branch_force ~proc_mgr ~cwd:git
112112- ~name:(vendor_branch pkg) ~point:(upstream_branch pkg);
113113-114114- (* Rewrite vendor branch history to move all files into vendor/opam/<pkg>/ *)
115115- Git.filter_branch_to_subdirectory ~proc_mgr ~cwd:git
116116- ~branch:(vendor_branch pkg)
117117- ~subdirectory:(vendor_path pkg);
118118-119119- (* Get the vendor SHA after rewriting *)
120120- match Git.rev_parse ~proc_mgr ~cwd:git (vendor_branch pkg) with
121121- | Some sha -> sha
122122- | None -> failwith "Vendor branch not found after filter-branch"
123123- end else begin
124124- (* Step 2b: Fast mode - create orphan vendor branch without history *)
125125- Worktree.ensure ~proc_mgr root (upstream_kind pkg);
126126- let upstream_wt = Worktree.path root (upstream_kind pkg) in
127127-128128- Worktree.ensure_orphan ~proc_mgr root (vendor_kind pkg);
129129- let vendor_wt = Worktree.path root (vendor_kind pkg) in
130130-131131- (* Copy files with vendor/opam/<pkg>/ prefix *)
132132- copy_with_prefix
133133- ~src_dir:upstream_wt
134134- ~dst_dir:vendor_wt
135135- ~prefix:(vendor_path pkg);
108108+ (* 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);
136111137137- (* Commit vendor branch *)
138138- Git.add_all ~proc_mgr ~cwd:vendor_wt;
139139- Git.commit ~proc_mgr ~cwd:vendor_wt
140140- ~message:(Printf.sprintf "Vendor %s" pkg);
141141-142142- let sha = Git.current_head ~proc_mgr ~cwd:vendor_wt in
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);
143116144144- (* Cleanup worktrees *)
145145- Worktree.remove ~proc_mgr root (upstream_kind pkg);
146146- Worktree.remove ~proc_mgr root (vendor_kind pkg);
147147- sha
148148- end
117117+ (* Get the vendor SHA after rewriting *)
118118+ let vendor_sha = match Git.rev_parse ~proc_mgr ~cwd:git (vendor_branch pkg) with
119119+ | Some sha -> sha
120120+ | None -> failwith "Vendor branch not found after filter-repo"
149121 in
150122151123 (* Step 3: Create patches branch from vendor *)
+2-5
lib/opam/opam.mli
···3131(** {1 Package Operations} *)
32323333val add_package :
3434- ?preserve_history:bool ->
3534 proc_mgr:Unpac.Git.proc_mgr ->
3635 root:Unpac.Worktree.root ->
3736 ?cache:Unpac.Vendor_cache.t ->
···4039(** [add_package ~proc_mgr ~root ?cache info] vendors a single package.
41404241 1. Fetches upstream into opam/upstream/<pkg> (via cache if provided)
4343- 2. Creates opam/vendor/<pkg> with vendor/opam/<pkg>/ prefix
4242+ 2. Creates opam/vendor/<pkg> with vendor/opam/<pkg>/ prefix (preserving history)
4443 3. Creates opam/patches/<pkg> from vendor
45444646- @param preserve_history If true (default), rewrites git history to preserve
4747- full commit history in the vendor branch. Set to false for faster
4848- vendoring without history (useful for large repositories).
4545+ Uses git-filter-repo for fast history rewriting.
4946 @param cache Optional vendor cache for shared fetches across projects. *)
50475148val update_package :