A monorepo management tool for the agentic ages

fetch

+197 -29
+1
.gitignore
··· 1 1 *.toml 2 2 _build 3 3 *.sh 4 + .unpac.log
+170 -11
bin/main.ml
··· 86 86 | None -> None 87 87 with _ -> None 88 88 89 + (* Error formatting helper - strip Eio.Io prefix for cleaner output *) 90 + let format_error exn = 91 + let s = Printexc.to_string exn in 92 + if String.length s > 7 && String.sub s 0 7 = "Eio.Io " then 93 + String.sub s 7 (String.length s - 7) 94 + else 95 + s 96 + 97 + (* URL rewriting for known mirrors *) 98 + let rewrite_git_url url = 99 + (* Rewrite erratique.ch repos to GitHub mirrors *) 100 + let erratique_prefix = "https://erratique.ch/repos/" in 101 + if String.length url > String.length erratique_prefix && 102 + String.sub url 0 (String.length erratique_prefix) = erratique_prefix then 103 + let rest = String.sub url (String.length erratique_prefix) 104 + (String.length url - String.length erratique_prefix) in 105 + "https://github.com/dbuenzli/" ^ rest 106 + else 107 + url 108 + 89 109 (* Source kind selection *) 90 110 let source_kind_term = 91 111 let git = ··· 234 254 let cwd_path = (cwd :> Eio.Fs.dir_ty Eio.Path.t) in 235 255 236 256 (* Check we're on a project branch *) 237 - let _project = Unpac.Project.require_project_branch ~proc_mgr ~cwd:cwd_path in 257 + let _project = 258 + try Unpac.Project.require_project_branch ~proc_mgr ~cwd:cwd_path 259 + with Failure msg -> 260 + Format.eprintf "%s@." msg; 261 + exit 1 262 + in 238 263 239 264 (* Check for pending recovery *) 240 265 if Unpac.Recovery.has_recovery ~cwd:cwd_path then begin ··· 298 323 | [] -> "unknown" 299 324 in 300 325 301 - (* Reconstruct full URL for git clone *) 326 + (* Reconstruct full URL for git clone, with rewrites *) 302 327 let url = 303 - let first_pkg = List.hd group.packages in 304 - match first_pkg.source with 305 - | Unpac.Source.GitSource g -> g.url 306 - | _ -> "https://" ^ url_str (* Fallback *) 328 + let raw_url = 329 + let first_pkg = List.hd group.packages in 330 + match first_pkg.source with 331 + | Unpac.Source.GitSource g -> g.url 332 + | _ -> "https://" ^ url_str (* Fallback *) 333 + in 334 + rewrite_git_url raw_url 307 335 in 308 336 309 337 Format.printf " Adding %s (%d packages: %s)@." ··· 322 350 Format.printf " [SKIP] %s already vendored@." name 323 351 | Unpac.Vendor.Failed { step; recovery_hint; error } -> 324 352 Format.eprintf " [FAIL] Failed at step '%s': %s@." step 325 - (Printexc.to_string error); 353 + (format_error error); 326 354 Format.eprintf " %s@." recovery_hint; 327 355 exit 1 328 356 ) grouped; ··· 411 439 Format.printf "[OK] %s is up to date@." name 412 440 | Unpac.Vendor.Update_failed { step; error; recovery_hint } -> 413 441 Format.eprintf "[FAIL] Failed at step '%s': %s@." step 414 - (Printexc.to_string error); 442 + (format_error error); 415 443 Format.eprintf "%s@." recovery_hint; 416 444 exit 1 417 445 in ··· 493 521 Format.printf "[OK] %s already vendored@." name 494 522 | Unpac.Vendor.Failed { step; error; recovery_hint } -> 495 523 Format.eprintf "[FAIL] Failed at step '%s': %s@." step 496 - (Printexc.to_string error); 524 + (format_error error); 497 525 Format.eprintf "%s@." recovery_hint; 498 526 exit 1 499 527 in ··· 753 781 const run $ logging_term $ config_file $ cache_dir_term $ output_format_term $ source_kind_term 754 782 $ resolve_deps_term $ Unpac.Solver.package_specs_term) 755 783 784 + let opam_vendor_fetch_cmd = 785 + let doc = "Fetch package sources into a vendor git repository." in 786 + let man = 787 + [ 788 + `S Manpage.s_description; 789 + `P 790 + "Fetches git sources for the specified packages into a centralized \ 791 + vendor git repository. This repository can then be used as a local \ 792 + cache for subsequent 'unpac add' operations."; 793 + `P 794 + "Each package's upstream is stored as a branch named 'upstream/<pkg>' \ 795 + in the vendor repository."; 796 + `P "Use --deps to include transitive dependencies."; 797 + `S Manpage.s_examples; 798 + `P "Fetch a package and its dependencies:"; 799 + `Pre " unpac opam vendor-fetch --deps lwt --vendor-repo ./vendor-cache"; 800 + `P "Fetch multiple packages:"; 801 + `Pre " unpac opam vendor-fetch eio cmdliner fmt --vendor-repo ~/vendor"; 802 + ] 803 + in 804 + let vendor_repo_arg = 805 + let doc = "Path to the vendor git repository." in 806 + Arg.(required & opt (some string) None & info ["vendor-repo"] ~docv:"DIR" ~doc) 807 + in 808 + let run () config_path cache_dir resolve_deps vendor_repo package_specs = 809 + Eio_main.run @@ fun env -> 810 + let fs = Eio.Stdenv.fs env in 811 + let proc_mgr = Eio.Stdenv.process_mgr env in 812 + let vendor_path = Eio.Path.(fs / vendor_repo) in 813 + 814 + if package_specs = [] then begin 815 + Format.eprintf "Please specify at least one package.@."; 816 + exit 1 817 + end; 818 + 819 + (* Initialize vendor repo if needed *) 820 + if not (Unpac.Git.is_repository vendor_path) then begin 821 + Format.printf "Initializing vendor repository at %s@." vendor_repo; 822 + Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 vendor_path; 823 + Unpac.Git.init ~proc_mgr ~cwd:vendor_path 824 + end; 825 + 826 + (* Load opam index and resolve packages *) 827 + let index = load_index ~fs ~cache_dir config_path in 828 + let compiler = get_compiler_spec config_path in 829 + let selection_result = 830 + if resolve_deps then Unpac.Solver.select_with_deps ?compiler index package_specs 831 + else Unpac.Solver.select_packages index package_specs 832 + in 833 + let packages = match selection_result with 834 + | Error msg -> 835 + Format.eprintf "Error selecting packages: %s@." msg; 836 + exit 1 837 + | Ok selection -> selection.packages 838 + in 839 + 840 + (* Extract sources and group by dev-repo *) 841 + let sources = Unpac.Source.extract_all Unpac.Source.Git packages in 842 + let grouped = Unpac.Source.group_by_dev_repo sources in 843 + 844 + Format.printf "Found %d unique git source(s) to fetch:@." (List.length grouped); 845 + 846 + (* Fetch each unique dev-repo *) 847 + let fetched = ref 0 in 848 + let skipped = ref 0 in 849 + List.iter (fun (group : Unpac.Source.grouped_sources) -> 850 + match group.dev_repo with 851 + | None -> 852 + incr skipped; 853 + let pkg_names = List.map (fun (p : Unpac.Source.package_source) -> p.name) group.packages in 854 + Format.printf " [SKIP] No dev-repo: %s@." (String.concat ", " pkg_names) 855 + | Some _dev_repo -> 856 + let opam_packages = List.map (fun (p : Unpac.Source.package_source) -> p.name) group.packages in 857 + let name = match opam_packages with 858 + | first :: _ -> first 859 + | [] -> "unknown" 860 + in 861 + 862 + (* Get URL with rewrites *) 863 + let url = 864 + let raw_url = 865 + let first_pkg = List.hd group.packages in 866 + match first_pkg.source with 867 + | Unpac.Source.GitSource g -> g.url 868 + | _ -> "" 869 + in 870 + rewrite_git_url raw_url 871 + in 872 + 873 + if url = "" then begin 874 + incr skipped; 875 + Format.printf " [SKIP] No git URL for %s@." name 876 + end else begin 877 + Format.printf " Fetching %s from %s@." name url; 878 + 879 + let remote = "origin-" ^ name in 880 + let upstream_branch = "upstream/" ^ name in 881 + 882 + try 883 + (* Add/update remote *) 884 + ignore (Unpac.Git.ensure_remote ~proc_mgr ~cwd:vendor_path ~name:remote ~url); 885 + 886 + (* Fetch from remote *) 887 + Unpac.Git.fetch ~proc_mgr ~cwd:vendor_path ~remote; 888 + 889 + (* Detect default branch *) 890 + let default_branch = Unpac.Git.ls_remote_default_branch ~proc_mgr ~url in 891 + let ref_point = remote ^ "/" ^ default_branch in 892 + 893 + (* Create/update upstream branch *) 894 + Unpac.Git.branch_force ~proc_mgr ~cwd:vendor_path ~name:upstream_branch ~point:ref_point; 895 + 896 + incr fetched; 897 + Format.printf " [OK] %s -> %s@." name upstream_branch 898 + with exn -> 899 + Format.eprintf " [FAIL] %s: %s@." name (format_error exn) 900 + end 901 + ) grouped; 902 + 903 + Format.printf "Done: %d fetched, %d skipped@." !fetched !skipped 904 + in 905 + let info = Cmd.info "vendor-fetch" ~doc ~man in 906 + Cmd.v info 907 + Term.( 908 + const run $ logging_term $ config_file $ cache_dir_term 909 + $ resolve_deps_term $ vendor_repo_arg $ Unpac.Solver.package_specs_term) 910 + 756 911 (* Opam subcommand group *) 757 912 758 913 let opam_cmd = ··· 766 921 ] 767 922 in 768 923 let info = Cmd.info "opam" ~doc ~man in 769 - Cmd.group info [ opam_list_cmd; opam_info_cmd; opam_related_cmd; opam_sources_cmd ] 924 + Cmd.group info [ opam_list_cmd; opam_info_cmd; opam_related_cmd; opam_sources_cmd; opam_vendor_fetch_cmd ] 770 925 771 926 (* ============================================================================ 772 927 MAIN COMMAND ··· 798 953 let info = Cmd.info "unpac" ~version:"0.1.0" ~doc ~man in 799 954 Cmd.group info [ init_cmd; project_cmd; add_cmd; vendor_cmd; opam_cmd ] 800 955 801 - let () = exit (Cmd.eval main_cmd) 956 + let () = 957 + Unpac.Txn_log.start_session ~args:(List.tl (Array.to_list Sys.argv)); 958 + let exit_code = Cmd.eval main_cmd in 959 + Unpac.Txn_log.end_session ~exit_code; 960 + exit exit_code
+15 -10
lib/git.ml
··· 115 115 let status = Eio.Process.await child in 116 116 let stdout = Buffer.contents stdout_buf in 117 117 let stderr = Buffer.contents stderr_buf in 118 - match status with 119 - | `Exited 0 -> 120 - Log.debug (fun m -> m "Output: %s" (string_trim stdout)); 121 - Ok stdout 122 - | `Exited exit_code -> 123 - Log.debug (fun m -> m "Failed (exit %d): %s" exit_code (string_trim stderr)); 124 - Error (Command_failed { cmd = args; exit_code; stdout; stderr }) 125 - | `Signaled signal -> 126 - Log.debug (fun m -> m "Killed by signal %d" signal); 127 - Error (Command_failed { cmd = args; exit_code = 128 + signal; stdout; stderr }) 118 + let exit_code, result = match status with 119 + | `Exited 0 -> 120 + Log.debug (fun m -> m "Output: %s" (string_trim stdout)); 121 + 0, Ok stdout 122 + | `Exited code -> 123 + Log.debug (fun m -> m "Failed (exit %d): %s" code (string_trim stderr)); 124 + code, Error (Command_failed { cmd = args; exit_code = code; stdout; stderr }) 125 + | `Signaled signal -> 126 + Log.debug (fun m -> m "Killed by signal %d" signal); 127 + let code = 128 + signal in 128 + code, Error (Command_failed { cmd = args; exit_code = code; stdout; stderr }) 129 + in 130 + (* Log to transaction log *) 131 + Txn_log.log_git_command ~args ~exit_code ~stdout ~stderr; 132 + result 128 133 with exn -> 129 134 Log.err (fun m -> m "Exception running git: %a" Fmt.exn exn); 130 135 raise exn
+6
lib/project.ml
··· 226 226 *.o 227 227 *.a 228 228 .unpac/ 229 + .unpac.log 229 230 |} 230 231 in 231 232 Eio.Path.save ~create:(`Or_truncate 0o644) gitignore_path gitignore_content; ··· 275 276 description = "%s" 276 277 277 278 [opam] 279 + # Repositories are listed in priority order (later ones take priority). 280 + # repositories = [ 281 + # { name = "default", path = "/path/to/opam-repository" }, 282 + # { name = "custom", path = "/path/to/custom-repo" }, 283 + # ] 278 284 repositories = [] 279 285 # compiler = "ocaml.5.3.0" 280 286
+3
lib/unpac.ml
··· 13 13 module Recovery = Recovery 14 14 module Vendor = Vendor 15 15 module Project = Project 16 + 17 + (** Logging *) 18 + module Txn_log = Txn_log
+2 -8
lib/vendor.ml
··· 169 169 tm.Unix.tm_min 170 170 tm.Unix.tm_sec 171 171 172 - (* Execute a single step *) 172 + (* Execute a single step - Git module handles its own logging *) 173 173 let execute_step ~proc_mgr ~cwd step = 174 174 let open Recovery in 175 175 match step with 176 176 | Remote_add { remote; url } -> 177 - Log.info (fun m -> m "Adding remote %s -> %s" remote url); 178 177 ignore (Git.ensure_remote ~proc_mgr ~cwd ~name:remote ~url) 179 178 180 179 | Fetch { remote } -> 181 - Log.info (fun m -> m "Fetching from %s..." remote); 182 180 Git.fetch ~proc_mgr ~cwd ~remote 183 181 184 182 | Create_upstream { branch; start_point } -> 185 - Log.info (fun m -> m "Creating pristine branch: %s" branch); 186 183 ignore (Git.ensure_branch ~proc_mgr ~cwd ~name:branch ~start_point) 187 184 188 185 | Create_vendor { name; upstream } -> 189 - Log.info (fun m -> m "Creating vendor branch with path rewrite: %s" (vendor_branch name)); 186 + Log.info (fun m -> m "Creating vendor branch: %s" (vendor_branch name)); 190 187 let current = Git.current_branch ~proc_mgr ~cwd in 191 188 let vbranch = vendor_branch name in 192 189 ··· 218 215 end 219 216 220 217 | Create_patches { branch; vendor } -> 221 - Log.info (fun m -> m "Creating patches branch: %s" branch); 222 218 ignore (Git.ensure_branch ~proc_mgr ~cwd ~name:branch ~start_point:vendor) 223 219 224 220 | Merge_to_project { patches } -> 225 - Log.info (fun m -> m "Merging %s into project branch..." patches); 226 221 let pkg_name = 227 222 if String.starts_with ~prefix:"patches/" patches then 228 223 String.sub patches 8 (String.length patches - 8) ··· 239 234 end 240 235 241 236 | Update_toml { package_name = _ } -> 242 - Log.info (fun m -> m "Updating unpac.toml..."); 243 237 (* TODO: Actually update the TOML file *) 244 238 () 245 239