A monorepo management tool for the agentic ages

Add project promotion, export, and upstream remote support

Promotion:
- Add `unpac project promote <name> --backend <opam|git>` command
- Filters vendor/ directory from project history
- Creates upstream/vendor/patches branches for the backend

Export (inverse of vendoring):
- Add `unpac export <name> --backend <backend>` command
- Moves files from vendor/<backend>/<name>/ back to root
- Add `unpac export-set-remote` and `unpac export-push` commands
- Add `unpac export-list` to show exported packages

Upstream remote for promoted packages:
- Add `unpac opam set-upstream <name> <url>` command
- Allows `unpac opam update` to work on promoted local packages
- Symmetric flow: export-push to publish, set-upstream + update to fetch

Branch model:
- upstream/ - pristine external state (files at root)
- vendor/ - upstream with vendor/<backend>/<name>/ prefix
- patches/ - vendor + local modifications
- export/ - patches transformed back to root (for pushing)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+1841 -170
+817 -59
bin/main.ml
··· 1 1 open Cmdliner 2 2 3 + let src = Logs.Src.create "unpac.main" ~doc:"Main CLI operations" 4 + module Log = (val Logs.src_log src : Logs.LOG) 5 + 3 6 (* Logging setup *) 4 - let setup_logging () = 7 + let setup_logging ?(verbose=false) () = 5 8 Fmt_tty.setup_std_outputs (); 6 - Logs.set_level (Some Logs.Info); 9 + let level = if verbose then Logs.Debug else Logs.Info in 10 + Logs.set_level (Some level); 7 11 Logs.set_reporter (Logs_fmt.reporter ()) 8 12 9 13 let logging_term = 10 - Term.(const setup_logging $ const ()) 14 + Term.(const (setup_logging ~verbose:false) $ const ()) 11 15 12 16 (* Helper to find project root *) 13 17 let with_root f = ··· 200 204 let info = Cmd.info "list" ~doc in 201 205 Cmd.v info Term.(const run $ logging_term) 202 206 207 + (* Project promote command *) 208 + let project_promote_cmd = 209 + let doc = "Promote a local project to a vendored library." in 210 + let man = [ 211 + `S Manpage.s_description; 212 + `P "Converts a locally-developed project into the vendor branch structure \ 213 + used by unpac for dependency management. This allows the project to be:"; 214 + `I ("•", "Merged into other projects as a dependency"); 215 + `I ("•", "Pushed to an independent git repository"); 216 + `I ("•", "Updated independently of the workspace"); 217 + `S "FILTERING"; 218 + `P "The promotion process filters the project history to remove \ 219 + vendored dependencies (the vendor/ directory), producing a clean \ 220 + library that can be independently distributed."; 221 + `P "Specifically, it:"; 222 + `I ("1.", "Extracts project/<name> branch history"); 223 + `I ("2.", "Filters out vendor/ directory (all backends' vendored code)"); 224 + `I ("3.", "Creates clean <backend>/upstream/<name> with filtered history"); 225 + `I ("4.", "Creates <backend>/vendor/<name> with path prefix applied"); 226 + `I ("5.", "Creates <backend>/patches/<name> for local modifications"); 227 + `P "The original project/<name> branch is preserved unchanged."; 228 + `S "BACKENDS"; 229 + `P "The --backend flag determines how the library is structured:"; 230 + `I ("opam", "Use for OCaml libraries built with dune. \ 231 + Creates vendor/opam/<name>/ structure. \ 232 + Merge with: unpac opam merge <name> <project>"); 233 + `I ("git", "Use for reference code, C libraries, or non-OCaml sources. \ 234 + Creates vendor/git/<name>/ structure. \ 235 + Merge with: unpac git merge <name> <project>"); 236 + `S Manpage.s_examples; 237 + `P "Promote a completed OCaml library:"; 238 + `Pre " unpac project promote brotli --backend opam"; 239 + `P "Promote with a different vendor name:"; 240 + `Pre " unpac project promote mybrotli --backend opam --name brotli"; 241 + `P "Promote a reference implementation:"; 242 + `Pre " unpac project promote zstd-reference --backend git"; 243 + `P "Full workflow from development to distribution:"; 244 + `Pre " unpac project new mybrotli 245 + # ... develop the library ... 246 + unpac project promote mybrotli --backend opam 247 + unpac project set-remote mybrotli git@github.com:me/mybrotli.git 248 + unpac opam merge mybrotli other-project"; 249 + `S "SEE ALSO"; 250 + `P "unpac-project-new(1), unpac-opam-merge(1), unpac-git-merge(1)"; 251 + ] in 252 + let name_arg = 253 + let doc = "Name of the project to promote." in 254 + Arg.(required & pos 0 (some string) None & info [] ~docv:"PROJECT" ~doc) 255 + in 256 + let backend_arg = 257 + let doc = "Vendor backend type: opam or git. \ 258 + Determines branch structure and merge semantics." in 259 + Arg.(required & opt (some string) None & info ["backend"; "b"] ~docv:"BACKEND" ~doc) 260 + in 261 + let vendor_name_arg = 262 + let doc = "Override the vendor library name (defaults to project name)." in 263 + Arg.(value & opt (some string) None & info ["name"; "n"] ~docv:"NAME" ~doc) 264 + in 265 + let run () project backend_str vendor_name = 266 + with_root @@ fun ~env:_ ~fs:_ ~proc_mgr ~root -> 267 + (* Parse backend *) 268 + let backend = match Unpac.Promote.backend_of_string backend_str with 269 + | Some b -> b 270 + | None -> 271 + Format.eprintf "Error: Unknown backend '%s'. Use 'opam' or 'git'.@." backend_str; 272 + exit 1 273 + in 274 + with_audit ~proc_mgr ~root ~operation_type:Unpac.Audit.Project_promote 275 + ~args:( 276 + [project; "--backend"; backend_str] @ 277 + (match vendor_name with Some n -> ["--name"; n] | None -> []) 278 + ) @@ fun _ctx -> 279 + match Unpac.Promote.promote ~proc_mgr ~root ~project ~backend ~vendor_name with 280 + | Unpac.Promote.Promoted { name; backend; original_commits; filtered_commits } -> 281 + Format.printf "Promoted %s as %s vendor@." project (Unpac.Promote.backend_to_string backend); 282 + Format.printf "@.Filtered history: %d → %d commits (removed vendor/ directory)@." 283 + original_commits filtered_commits; 284 + Format.printf "@.Created branches:@."; 285 + Format.printf " %s@." (Unpac.Promote.upstream_branch backend name); 286 + Format.printf " %s@." (Unpac.Promote.vendor_branch backend name); 287 + Format.printf " %s@." (Unpac.Promote.patches_branch backend name); 288 + Format.printf "@.%s can now be merged into other projects:@." name; 289 + (match backend with 290 + | Unpac.Promote.Opam -> 291 + Format.printf " unpac opam merge %s <project>@." name 292 + | Unpac.Promote.Git -> 293 + Format.printf " unpac git merge %s <project>@." name) 294 + | Unpac.Promote.Already_promoted name -> 295 + Format.eprintf "Error: %s is already promoted.@." name; 296 + exit 1 297 + | Unpac.Promote.Project_not_found name -> 298 + Format.eprintf "Error: Project '%s' not found.@." name; 299 + exit 1 300 + | Unpac.Promote.Failed { name; error } -> 301 + Format.eprintf "Error promoting %s: %s@." name error; 302 + exit 1 303 + in 304 + let info = Cmd.info "promote" ~doc ~man in 305 + Cmd.v info Term.(const run $ logging_term $ name_arg $ backend_arg $ vendor_name_arg) 306 + 307 + (* Project set-remote command *) 308 + let project_set_remote_cmd = 309 + let doc = "Set the remote URL for a project." in 310 + let man = [ 311 + `S Manpage.s_description; 312 + `P "Configures a git remote for pushing a project to an independent repository. \ 313 + This allows projects developed in the workspace to be published separately."; 314 + `P "The remote is named 'origin-<project>' and is stored in the bare git \ 315 + repository. Use 'unpac project push' to push to this remote."; 316 + `S Manpage.s_examples; 317 + `P "Set remote for a project:"; 318 + `Pre " unpac project set-remote brotli git@github.com:user/ocaml-brotli.git"; 319 + `P "Full workflow:"; 320 + `Pre " unpac project new mylib 321 + # ... develop the library ... 322 + unpac project promote mylib --backend opam 323 + unpac project set-remote mylib git@github.com:me/mylib.git 324 + unpac project push mylib"; 325 + `S "SEE ALSO"; 326 + `P "unpac-project-push(1), unpac-project-promote(1)"; 327 + ] in 328 + let name_arg = 329 + let doc = "Name of the project." in 330 + Arg.(required & pos 0 (some string) None & info [] ~docv:"PROJECT" ~doc) 331 + in 332 + let url_arg = 333 + let doc = "Remote URL (git SSH or HTTPS URL)." in 334 + Arg.(required & pos 1 (some string) None & info [] ~docv:"URL" ~doc) 335 + in 336 + let run () project url = 337 + with_root @@ fun ~env:_ ~fs:_ ~proc_mgr ~root -> 338 + with_audit ~proc_mgr ~root ~operation_type:Unpac.Audit.Project_set_remote 339 + ~args:[project; url] @@ fun _ctx -> 340 + match Unpac.Promote.set_remote ~proc_mgr ~root ~project ~url with 341 + | Unpac.Promote.Remote_set { project; url; created } -> 342 + if created then 343 + Format.printf "Created remote for %s: %s@." project url 344 + else 345 + Format.printf "Updated remote for %s: %s@." project url; 346 + Format.printf "@.Push with: unpac project push %s@." project 347 + | Unpac.Promote.Project_not_found name -> 348 + Format.eprintf "Error: Project '%s' not found.@." name; 349 + exit 1 350 + | Unpac.Promote.Set_remote_failed { project; error } -> 351 + Format.eprintf "Error setting remote for %s: %s@." project error; 352 + exit 1 353 + in 354 + let info = Cmd.info "set-remote" ~doc ~man in 355 + Cmd.v info Term.(const run $ logging_term $ name_arg $ url_arg) 356 + 357 + (* Project push command *) 358 + let project_push_cmd = 359 + let doc = "Push a project to its configured remote." in 360 + let man = [ 361 + `S Manpage.s_description; 362 + `P "Pushes a project branch to the remote configured via 'set-remote'. \ 363 + This allows publishing projects developed in the workspace to \ 364 + independent repositories."; 365 + `S Manpage.s_examples; 366 + `P "Push a project:"; 367 + `Pre " unpac project push brotli"; 368 + `S "SEE ALSO"; 369 + `P "unpac-project-set-remote(1)"; 370 + ] in 371 + let name_arg = 372 + let doc = "Name of the project to push." in 373 + Arg.(required & pos 0 (some string) None & info [] ~docv:"PROJECT" ~doc) 374 + in 375 + let run () project = 376 + with_root @@ fun ~env:_ ~fs:_ ~proc_mgr ~root -> 377 + match Unpac.Promote.push ~proc_mgr ~root ~project with 378 + | Unpac.Promote.Pushed { project; branch; remote } -> 379 + Format.printf "Pushed %s (%s) to %s@." project branch remote 380 + | Unpac.Promote.No_remote project -> 381 + Format.eprintf "Error: No remote configured for %s.@." project; 382 + Format.eprintf "Set one with: unpac project set-remote %s <url>@." project; 383 + exit 1 384 + | Unpac.Promote.Project_not_found name -> 385 + Format.eprintf "Error: Project '%s' not found.@." name; 386 + exit 1 387 + | Unpac.Promote.Push_failed { project; error } -> 388 + Format.eprintf "Error pushing %s: %s@." project error; 389 + exit 1 390 + in 391 + let info = Cmd.info "push" ~doc ~man in 392 + Cmd.v info Term.(const run $ logging_term $ name_arg) 393 + 394 + (* Project info command *) 395 + let project_info_cmd = 396 + let doc = "Show detailed information about a project." in 397 + let man = [ 398 + `S Manpage.s_description; 399 + `P "Displays information about a project including:"; 400 + `I ("Origin", "Whether the project was created locally or vendored"); 401 + `I ("Remote", "Configured push URL (if any)"); 402 + `I ("Promoted", "Whether promoted to vendor library and which backend"); 403 + `S Manpage.s_examples; 404 + `Pre " unpac project info brotli"; 405 + ] in 406 + let name_arg = 407 + let doc = "Name of the project." in 408 + Arg.(required & pos 0 (some string) None & info [] ~docv:"PROJECT" ~doc) 409 + in 410 + let run () project = 411 + with_root @@ fun ~env:_ ~fs:_ ~proc_mgr ~root -> 412 + match Unpac.Promote.get_info ~proc_mgr ~root ~project with 413 + | None -> 414 + Format.eprintf "Error: Project '%s' not found.@." project; 415 + exit 1 416 + | Some info -> 417 + Format.printf "Project: %s@." info.name; 418 + Format.printf "Origin: %s@." 419 + (match info.origin with `Local -> "local" | `Vendored -> "vendored"); 420 + Format.printf "Remote: %s@." 421 + (match info.remote with Some url -> url | None -> "(none)"); 422 + Format.printf "Promoted: %s@." 423 + (match info.promoted_as with 424 + | Some (backend, name) -> 425 + Printf.sprintf "%s vendor (%s)" (Unpac.Promote.backend_to_string backend) name 426 + | None -> "no") 427 + in 428 + let info = Cmd.info "info" ~doc ~man in 429 + Cmd.v info Term.(const run $ logging_term $ name_arg) 430 + 431 + (* Export command - unvendor a package for upstream push *) 432 + let export_cmd = 433 + let doc = "Export a vendored package for pushing to upstream." in 434 + let man = [ 435 + `S Manpage.s_description; 436 + `P "Creates an export branch from a vendored package with files moved \ 437 + from vendor/<backend>/<name>/ back to the repository root. This is \ 438 + the inverse of vendoring, producing a branch suitable for pushing \ 439 + to an upstream git repository."; 440 + `P "Use --from-patches to include local modifications in the export. \ 441 + Without this flag, exports from the vendor/* branch (pristine upstream)."; 442 + `S "WORKFLOW"; 443 + `P "The typical export workflow is:"; 444 + `Pre " # Export with local patches 445 + unpac export brotli --backend opam --from-patches 446 + 447 + # Set upstream remote 448 + unpac export-set-remote brotli git@github.com:me/brotli.git 449 + 450 + # Push to upstream 451 + unpac export-push brotli --backend opam"; 452 + `S Manpage.s_examples; 453 + `P "Export an opam package (pristine upstream):"; 454 + `Pre " unpac export brotli --backend opam"; 455 + `P "Export with local patches included:"; 456 + `Pre " unpac export brotli --backend opam --from-patches"; 457 + `P "Export a git-vendored package:"; 458 + `Pre " unpac export zstd --backend git"; 459 + ] in 460 + let name_arg = 461 + let doc = "Name of the vendored package to export." in 462 + Arg.(required & pos 0 (some string) None & info [] ~docv:"NAME" ~doc) 463 + in 464 + let backend_arg = 465 + let doc = "Vendor backend type: opam or git." in 466 + Arg.(required & opt (some string) None & info ["backend"; "b"] ~docv:"BACKEND" ~doc) 467 + in 468 + let from_patches_arg = 469 + let doc = "Export from patches/* branch (includes local modifications) \ 470 + instead of vendor/* branch (pristine upstream)." in 471 + Arg.(value & flag & info ["from-patches"; "p"] ~doc) 472 + in 473 + let run () name backend_str from_patches = 474 + with_root @@ fun ~env:_ ~fs:_ ~proc_mgr ~root -> 475 + let backend = match Unpac.Promote.backend_of_string backend_str with 476 + | Some b -> b 477 + | None -> 478 + Format.eprintf "Error: Unknown backend '%s'. Use 'opam' or 'git'.@." backend_str; 479 + exit 1 480 + in 481 + match Unpac.Promote.export ~proc_mgr ~root ~name ~backend ~from_patches with 482 + | Unpac.Promote.Exported { name; backend; source_branch; export_branch; commits } -> 483 + Format.printf "Exported %s (%s backend)@." name (Unpac.Promote.backend_to_string backend); 484 + Format.printf " Source: %s@." source_branch; 485 + Format.printf " Export: %s (%d commits)@." export_branch commits; 486 + Format.printf "@.Files moved from vendor/%s/%s/ to repository root.@." 487 + (Unpac.Promote.backend_to_string backend) name; 488 + Format.printf "@.Next steps:@."; 489 + Format.printf " unpac export-set-remote %s <url>@." name; 490 + Format.printf " unpac export-push %s --backend %s@." name backend_str 491 + | Unpac.Promote.Not_vendored name -> 492 + Format.eprintf "Error: No vendor branch found for '%s'.@." name; 493 + Format.eprintf "Check available packages with: unpac opam list / unpac git list@."; 494 + exit 1 495 + | Unpac.Promote.Already_exported name -> 496 + Format.eprintf "Error: Export branch already exists for '%s'.@." name; 497 + Format.eprintf "Delete it first with: git branch -D %s/export/%s@." 498 + backend_str name; 499 + exit 1 500 + | Unpac.Promote.Export_failed { name; error } -> 501 + Format.eprintf "Error exporting %s: %s@." name error; 502 + exit 1 503 + in 504 + let info = Cmd.info "export" ~doc ~man in 505 + Cmd.v info Term.(const run $ logging_term $ name_arg $ backend_arg $ from_patches_arg) 506 + 507 + (* Export set-remote command *) 508 + let export_set_remote_cmd = 509 + let doc = "Set the remote URL for pushing exports." in 510 + let man = [ 511 + `S Manpage.s_description; 512 + `P "Configures a git remote for pushing exported packages to an upstream \ 513 + repository. The remote is named 'export-<name>'."; 514 + `S Manpage.s_examples; 515 + `Pre " unpac export-set-remote brotli git@github.com:me/brotli.git"; 516 + ] in 517 + let name_arg = 518 + let doc = "Name of the package." in 519 + Arg.(required & pos 0 (some string) None & info [] ~docv:"NAME" ~doc) 520 + in 521 + let url_arg = 522 + let doc = "Remote URL (git SSH or HTTPS URL)." in 523 + Arg.(required & pos 1 (some string) None & info [] ~docv:"URL" ~doc) 524 + in 525 + let run () name url = 526 + with_root @@ fun ~env:_ ~fs:_ ~proc_mgr ~root -> 527 + match Unpac.Promote.set_export_remote ~proc_mgr ~root ~name ~url with 528 + | `Created -> 529 + Format.printf "Created export remote for %s: %s@." name url; 530 + Format.printf "@.Push with: unpac export-push %s --backend <backend>@." name 531 + | `Updated -> 532 + Format.printf "Updated export remote for %s: %s@." name url 533 + | `Existed -> 534 + Format.printf "Export remote already set for %s: %s@." name url 535 + in 536 + let info = Cmd.info "export-set-remote" ~doc ~man in 537 + Cmd.v info Term.(const run $ logging_term $ name_arg $ url_arg) 538 + 539 + (* Export push command *) 540 + let export_push_cmd = 541 + let doc = "Push an export branch to its configured remote." in 542 + let man = [ 543 + `S Manpage.s_description; 544 + `P "Pushes an export branch to the remote configured via 'export-set-remote'. \ 545 + The export branch is pushed as 'main' on the remote repository."; 546 + `S Manpage.s_examples; 547 + `Pre " unpac export-push brotli --backend opam"; 548 + ] in 549 + let name_arg = 550 + let doc = "Name of the package to push." in 551 + Arg.(required & pos 0 (some string) None & info [] ~docv:"NAME" ~doc) 552 + in 553 + let backend_arg = 554 + let doc = "Vendor backend type: opam or git." in 555 + Arg.(required & opt (some string) None & info ["backend"; "b"] ~docv:"BACKEND" ~doc) 556 + in 557 + let run () name backend_str = 558 + with_root @@ fun ~env:_ ~fs:_ ~proc_mgr ~root -> 559 + let backend = match Unpac.Promote.backend_of_string backend_str with 560 + | Some b -> b 561 + | None -> 562 + Format.eprintf "Error: Unknown backend '%s'. Use 'opam' or 'git'.@." backend_str; 563 + exit 1 564 + in 565 + match Unpac.Promote.push_export ~proc_mgr ~root ~name ~backend with 566 + | Unpac.Promote.Export_pushed { name = _; backend; remote; branch; commits } -> 567 + Format.printf "Pushed %s (%d commits) to %s@." branch commits remote; 568 + Format.printf "Backend: %s@." (Unpac.Promote.backend_to_string backend); 569 + Format.printf "@.Export pushed as 'main' on remote.@." 570 + | Unpac.Promote.Export_not_found name -> 571 + Format.eprintf "Error: No export branch found for '%s'.@." name; 572 + Format.eprintf "Export first with: unpac export %s --backend %s@." name backend_str; 573 + exit 1 574 + | Unpac.Promote.No_export_remote name -> 575 + Format.eprintf "Error: No export remote configured for '%s'.@." name; 576 + Format.eprintf "Set one with: unpac export-set-remote %s <url>@." name; 577 + exit 1 578 + | Unpac.Promote.Export_push_failed { name; error } -> 579 + Format.eprintf "Error pushing export %s: %s@." name error; 580 + exit 1 581 + in 582 + let info = Cmd.info "export-push" ~doc ~man in 583 + Cmd.v info Term.(const run $ logging_term $ name_arg $ backend_arg) 584 + 585 + (* Export list command *) 586 + let export_list_cmd = 587 + let doc = "List all exported packages." in 588 + let run () = 589 + with_root @@ fun ~env:_ ~fs:_ ~proc_mgr ~root -> 590 + let exports = Unpac.Promote.list_exports ~proc_mgr ~root in 591 + if exports = [] then 592 + Format.printf "No exported packages.@." 593 + else begin 594 + Format.printf "Exported packages:@."; 595 + List.iter (fun (backend, name) -> 596 + let remote = Unpac.Promote.get_export_remote ~proc_mgr ~root ~name in 597 + Format.printf " %s (%s)%s@." name 598 + (Unpac.Promote.backend_to_string backend) 599 + (match remote with Some url -> " → " ^ url | None -> "") 600 + ) exports 601 + end 602 + in 603 + let info = Cmd.info "export-list" ~doc in 604 + Cmd.v info Term.(const run $ logging_term) 605 + 203 606 (* Project command group *) 204 607 let project_cmd = 205 608 let doc = "Project management commands." in ··· 215 618 4. Build in project: cd project/myapp && dune build"; 216 619 `P "Multiple projects can share the same vendored dependencies - \ 217 620 each project merges the patches branch independently."; 621 + `S "PROMOTING PROJECTS"; 622 + `P "Once a project is complete, you can promote it to a vendored library:"; 623 + `Pre " unpac project promote mylib --backend opam"; 624 + `P "This creates clean vendor branches (filtering out vendored deps) so \ 625 + the library can be merged into other projects."; 626 + `S "PUBLISHING PROJECTS"; 627 + `P "Projects can be pushed to independent repositories:"; 628 + `Pre " unpac project set-remote mylib git@github.com:me/mylib.git 629 + unpac project push mylib"; 630 + `S "EXPORTING AS STANDALONE LIBRARY"; 631 + `P "To publish a promoted project as a standalone opam library:"; 632 + `Pre " # 1. Promote project to opam vendor 633 + unpac project promote mylib --backend opam 634 + 635 + # 2. Export with your patches (files moved to root) 636 + unpac export mylib --backend opam --from-patches 637 + 638 + # 3. Configure remotes and push 639 + unpac export-set-remote mylib git@github.com:me/mylib.git 640 + unpac export-push mylib --backend opam 641 + 642 + # 4. Configure upstream for pulling updates 643 + unpac opam set-upstream mylib git@github.com:me/mylib.git"; 644 + `P "The export branch has files at repository root (not in vendor/), \ 645 + suitable for a standalone git repository. The upstream remote \ 646 + allows 'unpac opam update' to fetch changes back."; 218 647 ] in 219 648 let info = Cmd.info "project" ~doc ~man in 220 - Cmd.group info [project_new_cmd; project_list_cmd] 649 + Cmd.group info [ 650 + project_new_cmd; 651 + project_list_cmd; 652 + project_info_cmd; 653 + project_promote_cmd; 654 + project_set_remote_cmd; 655 + project_push_cmd; 656 + ] 221 657 222 658 (* Opam repo add command *) 223 659 let opam_repo_add_cmd = ··· 613 1049 let info = Cmd.info "done" ~doc in 614 1050 Cmd.v info Term.(const run $ logging_term $ pkg_arg) 615 1051 1052 + (* Opam set-upstream command *) 1053 + let opam_set_upstream_cmd = 1054 + let doc = "Set the upstream URL for a vendored opam package." in 1055 + let man = [ 1056 + `S Manpage.s_description; 1057 + `P "Configures the upstream git URL for a vendored opam package. \ 1058 + This is used by 'unpac opam update' to fetch new changes from upstream."; 1059 + `P "For packages added via 'unpac opam add', the upstream is automatically \ 1060 + configured from the opam source URL. This command is mainly useful for \ 1061 + promoted local projects that don't have an opam source."; 1062 + `S Manpage.s_examples; 1063 + `Pre " unpac opam set-upstream ocaml-zstd git@github.com:user/ocaml-zstd.git"; 1064 + `S "SEE ALSO"; 1065 + `P "unpac-opam-update(1), unpac-export-set-remote(1)"; 1066 + ] in 1067 + let name_arg = 1068 + let doc = "Name of the package." in 1069 + Arg.(required & pos 0 (some string) None & info [] ~docv:"NAME" ~doc) 1070 + in 1071 + let url_arg = 1072 + let doc = "Upstream URL (git SSH or HTTPS URL)." in 1073 + Arg.(required & pos 1 (some string) None & info [] ~docv:"URL" ~doc) 1074 + in 1075 + let run () name url = 1076 + with_root @@ fun ~env:_ ~fs:_ ~proc_mgr ~root -> 1077 + match Unpac.Promote.set_upstream_remote ~proc_mgr ~root ~name ~url with 1078 + | `Created -> 1079 + Format.printf "Set upstream for %s: %s@." name url; 1080 + Format.printf "@.You can now run: unpac opam update %s@." name 1081 + | `Updated -> 1082 + Format.printf "Updated upstream for %s: %s@." name url 1083 + | `Existed -> 1084 + Format.printf "Upstream already set for %s: %s@." name url 1085 + in 1086 + let info = Cmd.info "set-upstream" ~doc ~man in 1087 + Cmd.v info Term.(const run $ logging_term $ name_arg $ url_arg) 1088 + 616 1089 (* Opam update command *) 617 1090 let opam_update_cmd = 618 1091 let doc = "Update a vendored opam package from upstream." in ··· 1251 1724 `P "4. View your changes:"; 1252 1725 `Pre " unpac opam diff mypackage"; 1253 1726 `S "UPDATING FROM UPSTREAM"; 1254 - `P "For packages with external upstreams (not local packages):"; 1727 + `P "For packages with external upstreams (added via 'opam add'):"; 1255 1728 `Pre " unpac opam update mypackage 1256 1729 unpac opam merge mypackage myapp"; 1730 + `P "For promoted local packages, first configure the upstream URL:"; 1731 + `Pre " unpac opam set-upstream mylib git@github.com:me/mylib.git 1732 + unpac opam update mylib"; 1257 1733 `S "FOR AI AGENTS"; 1258 1734 `P "When an agent needs to create a new dependency:"; 1259 1735 `Pre " # Option 1: Create from scratch ··· 1279 1755 opam_list_cmd; 1280 1756 opam_edit_cmd; 1281 1757 opam_done_cmd; 1758 + opam_set_upstream_cmd; 1282 1759 opam_update_cmd; 1283 1760 opam_merge_cmd; 1284 1761 opam_info_cmd; ··· 1820 2297 let doc = "Don't update README.md." in 1821 2298 Arg.(value & flag & info ["no-readme"] ~doc) 1822 2299 in 1823 - let run () short no_readme = 2300 + let verbose_flag = 2301 + let doc = "Enable verbose/debug logging to help diagnose issues." in 2302 + Arg.(value & flag & info ["v"; "verbose"] ~doc) 2303 + in 2304 + let run () short no_readme verbose = 2305 + setup_logging ~verbose (); 1824 2306 with_root @@ fun ~env:_ ~fs:_ ~proc_mgr ~root -> 2307 + Log.debug (fun m -> m "Starting status command..."); 1825 2308 let git = Unpac.Worktree.git_dir root in 1826 2309 let main_wt = Unpac.Worktree.path root Unpac.Worktree.Main in 2310 + Log.debug (fun m -> m "Git dir: %s" (snd git)); 2311 + Log.debug (fun m -> m "Main worktree: %s" (snd main_wt)); 1827 2312 1828 2313 (* Get all branches *) 2314 + Log.debug (fun m -> m "Listing all branches..."); 1829 2315 let all_branches = Unpac.Git.run_lines ~proc_mgr ~cwd:git 1830 2316 ["branch"; "--format=%(refname:short)"] in 2317 + Log.debug (fun m -> m "Found %d branches" (List.length all_branches)); 1831 2318 1832 2319 (* Categorize branches *) 2320 + Log.debug (fun m -> m "Categorizing branches..."); 1833 2321 let project_branches = List.filter (fun b -> 1834 2322 String.starts_with ~prefix:"project/" b 1835 2323 ) all_branches in 2324 + Log.debug (fun m -> m "Found %d project branches" (List.length project_branches)); 2325 + 2326 + Log.debug (fun m -> m "Listing opam packages..."); 1836 2327 let opam_packages = Unpac_opam.Opam.list_packages ~proc_mgr ~root in 2328 + Log.debug (fun m -> m "Found %d opam packages" (List.length opam_packages)); 2329 + 2330 + Log.debug (fun m -> m "Listing git repos..."); 1837 2331 let git_repos = Unpac.Git_backend.list_repos ~proc_mgr ~root in 2332 + Log.debug (fun m -> m "Found %d git repos" (List.length git_repos)); 1838 2333 1839 - (* Helper to count commits between branches *) 1840 - let commit_count from_ref to_ref = 1841 - try 1842 - let output = Unpac.Git.run_exn ~proc_mgr ~cwd:git 1843 - ["rev-list"; "--count"; from_ref ^ ".." ^ to_ref] in 1844 - int_of_string (String.trim output) 1845 - with _ -> 0 2334 + (* Parallel map helper using Eio fibers *) 2335 + let parallel_map f items = 2336 + Log.debug (fun m -> m "Running %d operations in parallel..." (List.length items)); 2337 + Eio.Switch.run @@ fun sw -> 2338 + let fibers = List.map (fun item -> 2339 + Eio.Fiber.fork_promise ~sw (fun () -> f item) 2340 + ) items in 2341 + List.map Eio.Promise.await_exn fibers 2342 + in 2343 + 2344 + (* Parallel commit count for all packages *) 2345 + let commit_count_calls = ref 0 in 2346 + let parallel_commit_counts pkgs vendor_fn patches_fn = 2347 + Log.debug (fun m -> m "Counting commits for %d items in parallel..." (List.length pkgs)); 2348 + parallel_map (fun pkg -> 2349 + let from_ref = vendor_fn pkg in 2350 + let to_ref = patches_fn pkg in 2351 + incr commit_count_calls; 2352 + try 2353 + let output = Unpac.Git.run_exn ~proc_mgr ~cwd:git 2354 + ["rev-list"; "--count"; from_ref ^ ".." ^ to_ref] in 2355 + (pkg, int_of_string (String.trim output)) 2356 + with _ -> (pkg, 0) 2357 + ) pkgs 1846 2358 in 1847 2359 1848 - (* Helper to check if branch A is ancestor of B *) 2360 + (* Helper to check if branch A is ancestor of B - single call *) 2361 + let is_ancestor_calls = ref 0 in 1849 2362 let is_ancestor a b = 2363 + incr is_ancestor_calls; 2364 + Log.debug (fun m -> m "is_ancestor #%d: %s in %s" !is_ancestor_calls a b); 1850 2365 match Unpac.Git.run ~proc_mgr ~cwd:git 1851 2366 ["merge-base"; "--is-ancestor"; a; b] with 1852 2367 | Ok _ -> true 1853 2368 | Error _ -> false 1854 2369 in 1855 2370 2371 + (* Parallel is_ancestor check for a list of (source, target) pairs *) 2372 + let parallel_is_ancestor pairs = 2373 + Log.debug (fun m -> m "Checking %d ancestry relations in parallel..." (List.length pairs)); 2374 + parallel_map (fun (a, b) -> 2375 + incr is_ancestor_calls; 2376 + match Unpac.Git.run ~proc_mgr ~cwd:git 2377 + ["merge-base"; "--is-ancestor"; a; b] with 2378 + | Ok _ -> (a, b, true) 2379 + | Error _ -> (a, b, false) 2380 + ) pairs 2381 + in 2382 + 1856 2383 (* Check for uncommitted changes in a worktree *) 2384 + let has_changes_calls = ref 0 in 1857 2385 let has_changes wt_path = 2386 + incr has_changes_calls; 2387 + Log.debug (fun m -> m "has_changes #%d: %s" !has_changes_calls (snd wt_path)); 1858 2388 if Sys.file_exists (snd wt_path) then 1859 2389 let status = Unpac.Git.run_exn ~proc_mgr ~cwd:wt_path ["status"; "--porcelain"] in 1860 2390 String.trim status <> "" ··· 1865 2395 let project_names = List.map (fun b -> 1866 2396 String.sub b 8 (String.length b - 8) 1867 2397 ) project_branches in 2398 + Log.debug (fun m -> m "Project names: %a" Fmt.(list ~sep:comma string) project_names); 1868 2399 1869 2400 if short then begin 1870 2401 (* Short summary *) 2402 + Log.debug (fun m -> m "Generating short summary..."); 1871 2403 Format.printf "Workspace: %s@." (snd (Unpac.Worktree.git_dir root) |> Filename.dirname); 1872 2404 Format.printf "Projects: %d | Opam: %d | Git: %d@." 1873 2405 (List.length project_branches) 1874 2406 (List.length opam_packages) 1875 2407 (List.length git_repos); 1876 2408 1877 - (* Count total patches *) 1878 - let opam_patches = List.fold_left (fun acc pkg -> 1879 - acc + commit_count (Unpac_opam.Opam.vendor_branch pkg) (Unpac_opam.Opam.patches_branch pkg) 1880 - ) 0 opam_packages in 1881 - let git_patches = List.fold_left (fun acc repo -> 1882 - acc + commit_count (Unpac.Git_backend.vendor_branch repo) (Unpac.Git_backend.patches_branch repo) 1883 - ) 0 git_repos in 2409 + (* Count total patches - parallel *) 2410 + Log.debug (fun m -> m "Counting opam patches (%d packages) in parallel..." (List.length opam_packages)); 2411 + let opam_patch_counts = parallel_commit_counts opam_packages 2412 + Unpac_opam.Opam.vendor_branch Unpac_opam.Opam.patches_branch in 2413 + let opam_patches = List.fold_left (fun acc (_, n) -> acc + n) 0 opam_patch_counts in 2414 + Log.debug (fun m -> m "Counting git patches (%d repos) in parallel..." (List.length git_repos)); 2415 + let git_patch_counts = parallel_commit_counts git_repos 2416 + Unpac.Git_backend.vendor_branch Unpac.Git_backend.patches_branch in 2417 + let git_patches = List.fold_left (fun acc (_, n) -> acc + n) 0 git_patch_counts in 1884 2418 if opam_patches + git_patches > 0 then 1885 2419 Format.printf "Local patches: %d commits@." (opam_patches + git_patches); 1886 2420 1887 2421 (* Check main for uncommitted *) 2422 + Log.debug (fun m -> m "Checking main worktree for changes..."); 1888 2423 if has_changes main_wt then 1889 - Format.printf "Warning: Uncommitted changes in main@." 2424 + Format.printf "Warning: Uncommitted changes in main@."; 2425 + Log.debug (fun m -> m "Short summary complete.") 1890 2426 end else begin 1891 2427 (* Full status *) 2428 + Log.debug (fun m -> m "Generating full status..."); 1892 2429 Format.printf "=== Unpac Workspace Status ===@.@."; 1893 2430 1894 2431 (* Main worktree status *) 2432 + Log.debug (fun m -> m "Checking main worktree status..."); 1895 2433 Format.printf "Main worktree: %s@." (snd main_wt); 1896 2434 if has_changes main_wt then 1897 2435 Format.printf " @{<yellow>Warning: Uncommitted changes@}@." ··· 1899 2437 Format.printf " Clean@."; 1900 2438 Format.printf "@."; 1901 2439 1902 - (* Projects *) 2440 + (* Projects - precompute all ancestry relationships in parallel *) 2441 + Log.debug (fun m -> m "Processing %d projects..." (List.length project_names)); 1903 2442 Format.printf "=== Projects (%d) ===@." (List.length project_names); 1904 2443 if project_names = [] then 1905 2444 Format.printf " (none)@." 1906 2445 else begin 1907 - List.iter (fun proj -> 2446 + (* Build all ancestry pairs to check: (patches_branch, project_branch) *) 2447 + let opam_pairs = List.concat_map (fun proj -> 2448 + let proj_branch = "project/" ^ proj in 2449 + List.map (fun pkg -> 2450 + (Unpac_opam.Opam.patches_branch pkg, proj_branch) 2451 + ) opam_packages 2452 + ) project_names in 2453 + let git_pairs = List.concat_map (fun proj -> 2454 + let proj_branch = "project/" ^ proj in 2455 + List.map (fun repo -> 2456 + (Unpac.Git_backend.patches_branch repo, proj_branch) 2457 + ) git_repos 2458 + ) project_names in 2459 + 2460 + (* Check all ancestry in parallel *) 2461 + Log.debug (fun m -> m "Checking %d ancestry relations in parallel..." 2462 + (List.length opam_pairs + List.length git_pairs)); 2463 + let all_pairs = opam_pairs @ git_pairs in 2464 + let ancestry_results = parallel_is_ancestor all_pairs in 2465 + 2466 + (* Build lookup table: (patches_branch, project_branch) -> is_ancestor *) 2467 + let ancestry_table = Hashtbl.create (List.length ancestry_results) in 2468 + List.iter (fun (a, b, result) -> 2469 + Hashtbl.add ancestry_table (a, b) result 2470 + ) ancestry_results; 2471 + 2472 + let is_ancestor_cached a b = 2473 + try Hashtbl.find ancestry_table (a, b) 2474 + with Not_found -> is_ancestor a b (* fallback *) 2475 + in 2476 + 2477 + List.iteri (fun i proj -> 2478 + Log.debug (fun m -> m " Project %d/%d: %s" (i+1) (List.length project_names) proj); 1908 2479 let proj_branch = "project/" ^ proj in 1909 2480 let proj_wt = Unpac.Worktree.path root (Unpac.Worktree.Project proj) in 1910 2481 let wt_exists = Sys.file_exists (snd proj_wt) in 1911 2482 let dirty = wt_exists && has_changes proj_wt in 1912 2483 1913 - (* Count merged packages *) 2484 + (* Count merged packages - use cached results *) 1914 2485 let merged_opam = List.filter (fun pkg -> 1915 - is_ancestor (Unpac_opam.Opam.patches_branch pkg) proj_branch 2486 + is_ancestor_cached (Unpac_opam.Opam.patches_branch pkg) proj_branch 1916 2487 ) opam_packages in 1917 2488 let merged_git = List.filter (fun repo -> 1918 - is_ancestor (Unpac.Git_backend.patches_branch repo) proj_branch 2489 + is_ancestor_cached (Unpac.Git_backend.patches_branch repo) proj_branch 1919 2490 ) git_repos in 1920 2491 1921 2492 Format.printf " %s" proj; ··· 1928 2499 end; 1929 2500 Format.printf "@."; 1930 2501 1931 - (* Opam packages *) 2502 + (* Opam packages - parallel commit counts and cached ancestry *) 2503 + Log.debug (fun m -> m "Processing %d opam packages..." (List.length opam_packages)); 1932 2504 Format.printf "=== Opam Packages (%d) ===@." (List.length opam_packages); 1933 2505 if opam_packages = [] then 1934 2506 Format.printf " (none)@." 1935 2507 else begin 2508 + (* Parallel commit counts *) 2509 + let opam_counts = parallel_commit_counts opam_packages 2510 + Unpac_opam.Opam.vendor_branch Unpac_opam.Opam.patches_branch in 2511 + let opam_count_table = Hashtbl.create (List.length opam_counts) in 2512 + List.iter (fun (pkg, count) -> Hashtbl.add opam_count_table pkg count) opam_counts; 2513 + 2514 + (* Build ancestry pairs for opam -> projects (reuse if possible) *) 2515 + let opam_to_proj_pairs = List.concat_map (fun pkg -> 2516 + let patches_branch = Unpac_opam.Opam.patches_branch pkg in 2517 + List.map (fun proj -> (patches_branch, "project/" ^ proj)) project_names 2518 + ) opam_packages in 2519 + let opam_ancestry_results = parallel_is_ancestor opam_to_proj_pairs in 2520 + let opam_ancestry_table = Hashtbl.create (List.length opam_ancestry_results) in 2521 + List.iter (fun (a, b, result) -> 2522 + Hashtbl.add opam_ancestry_table (a, b) result 2523 + ) opam_ancestry_results; 2524 + 1936 2525 Format.printf " %-25s %8s %s@." "Package" "Patches" "Merged into"; 1937 2526 Format.printf " %s@." (String.make 60 '-'); 1938 - List.iter (fun pkg -> 1939 - let vendor_branch = Unpac_opam.Opam.vendor_branch pkg in 2527 + List.iteri (fun i pkg -> 2528 + Log.debug (fun m -> m " Opam package %d/%d: %s" (i+1) (List.length opam_packages) pkg); 1940 2529 let patches_branch = Unpac_opam.Opam.patches_branch pkg in 1941 - let patch_count = commit_count vendor_branch patches_branch in 2530 + let patch_count = try Hashtbl.find opam_count_table pkg with Not_found -> 0 in 1942 2531 1943 2532 (* Check active worktrees *) 1944 2533 let patches_wt = Unpac.Worktree.path root (Unpac.Worktree.Opam_patches pkg) in 1945 2534 let has_wt = Sys.file_exists (snd patches_wt) in 1946 2535 let dirty = has_wt && has_changes patches_wt in 1947 2536 1948 - (* Check merged into which projects *) 2537 + (* Check merged into which projects - use cached results *) 1949 2538 let merged_into = List.filter (fun proj -> 1950 - is_ancestor patches_branch ("project/" ^ proj) 2539 + try Hashtbl.find opam_ancestry_table (patches_branch, "project/" ^ proj) 2540 + with Not_found -> false 1951 2541 ) project_names in 1952 2542 1953 2543 let merged_str = if merged_into = [] then "-" ··· 1962 2552 end; 1963 2553 Format.printf "@."; 1964 2554 1965 - (* Git repos *) 2555 + (* Git repos - parallel commit counts and cached ancestry *) 2556 + Log.debug (fun m -> m "Processing %d git repos..." (List.length git_repos)); 1966 2557 Format.printf "=== Git Repositories (%d) ===@." (List.length git_repos); 1967 2558 if git_repos = [] then 1968 2559 Format.printf " (none)@." 1969 2560 else begin 2561 + (* Parallel commit counts *) 2562 + let git_counts = parallel_commit_counts git_repos 2563 + Unpac.Git_backend.vendor_branch Unpac.Git_backend.patches_branch in 2564 + let git_count_table = Hashtbl.create (List.length git_counts) in 2565 + List.iter (fun (repo, count) -> Hashtbl.add git_count_table repo count) git_counts; 2566 + 2567 + (* Build ancestry pairs for git -> projects *) 2568 + let git_to_proj_pairs = List.concat_map (fun repo -> 2569 + let patches_branch = Unpac.Git_backend.patches_branch repo in 2570 + List.map (fun proj -> (patches_branch, "project/" ^ proj)) project_names 2571 + ) git_repos in 2572 + let git_ancestry_results = parallel_is_ancestor git_to_proj_pairs in 2573 + let git_ancestry_table = Hashtbl.create (List.length git_ancestry_results) in 2574 + List.iter (fun (a, b, result) -> 2575 + Hashtbl.add git_ancestry_table (a, b) result 2576 + ) git_ancestry_results; 2577 + 1970 2578 Format.printf " %-25s %8s %s@." "Repository" "Patches" "Merged into"; 1971 2579 Format.printf " %s@." (String.make 60 '-'); 1972 - List.iter (fun repo -> 1973 - let vendor_branch = Unpac.Git_backend.vendor_branch repo in 2580 + List.iteri (fun i repo -> 2581 + Log.debug (fun m -> m " Git repo %d/%d: %s" (i+1) (List.length git_repos) repo); 1974 2582 let patches_branch = Unpac.Git_backend.patches_branch repo in 1975 - let patch_count = commit_count vendor_branch patches_branch in 2583 + let patch_count = try Hashtbl.find git_count_table repo with Not_found -> 0 in 1976 2584 1977 2585 let patches_wt = Unpac.Worktree.path root (Unpac.Worktree.Git_patches repo) in 1978 2586 let has_wt = Sys.file_exists (snd patches_wt) in 1979 2587 let dirty = has_wt && has_changes patches_wt in 1980 2588 1981 2589 let merged_into = List.filter (fun proj -> 1982 - is_ancestor patches_branch ("project/" ^ proj) 2590 + try Hashtbl.find git_ancestry_table (patches_branch, "project/" ^ proj) 2591 + with Not_found -> false 1983 2592 ) project_names in 1984 2593 1985 2594 let merged_str = if merged_into = [] then "-" ··· 2018 2627 end; 2019 2628 2020 2629 (* Legend *) 2021 - Format.printf "Legend: * = worktree active@." 2630 + Format.printf "Legend: * = worktree active@."; 2631 + Log.debug (fun m -> m "Full status output complete.") 2022 2632 end; 2023 2633 2024 2634 (* Generate README.md unless --no-readme *) 2025 2635 if not no_readme then begin 2636 + Log.debug (fun m -> m "Generating README.md..."); 2026 2637 let buf = Buffer.create 4096 in 2027 2638 let add = Buffer.add_string buf in 2028 2639 let addf fmt = Printf.ksprintf add fmt in ··· 2034 2645 in 2035 2646 2036 2647 (* Get tangled.org base URL from origin remote *) 2648 + Log.debug (fun m -> m "Getting origin remote URL..."); 2037 2649 let tangled_base = 2038 2650 match Unpac.Git.remote_url ~proc_mgr ~cwd:git "origin" with 2039 - | None -> None 2651 + | None -> 2652 + Log.debug (fun m -> m "No origin remote found"); 2653 + None 2040 2654 | Some url -> 2041 2655 (* Parse git@git.recoil.org:user/repo or similar *) 2042 2656 let url = String.trim url in ··· 2083 2697 addf "| Opam Packages | %d |\n" (List.length opam_packages); 2084 2698 addf "| Git Repositories | %d |\n\n" (List.length git_repos); 2085 2699 2086 - (* Projects section *) 2700 + (* Projects section - parallel ancestry checks *) 2701 + Log.debug (fun m -> m "README: Processing %d projects..." (List.length project_names)); 2087 2702 add "## Projects\n\n"; 2088 2703 if project_names = [] then 2089 2704 add "_No projects created yet._\n\n" 2090 2705 else begin 2706 + (* Precompute all ancestry in parallel for README *) 2707 + let readme_opam_pairs = List.concat_map (fun proj -> 2708 + let proj_branch = "project/" ^ proj in 2709 + List.map (fun pkg -> 2710 + (Unpac_opam.Opam.patches_branch pkg, proj_branch) 2711 + ) opam_packages 2712 + ) project_names in 2713 + let readme_git_pairs = List.concat_map (fun proj -> 2714 + let proj_branch = "project/" ^ proj in 2715 + List.map (fun repo -> 2716 + (Unpac.Git_backend.patches_branch repo, proj_branch) 2717 + ) git_repos 2718 + ) project_names in 2719 + Log.debug (fun m -> m "README: Checking %d ancestry relations in parallel..." 2720 + (List.length readme_opam_pairs + List.length readme_git_pairs)); 2721 + let readme_ancestry_results = parallel_is_ancestor (readme_opam_pairs @ readme_git_pairs) in 2722 + let readme_ancestry_table = Hashtbl.create (List.length readme_ancestry_results) in 2723 + List.iter (fun (a, b, result) -> 2724 + Hashtbl.add readme_ancestry_table (a, b) result 2725 + ) readme_ancestry_results; 2726 + let readme_is_ancestor a b = 2727 + try Hashtbl.find readme_ancestry_table (a, b) 2728 + with Not_found -> false 2729 + in 2730 + 2091 2731 add "| Project | Opam Merged | Git Merged | Status |\n"; 2092 2732 add "|---------|-------------|------------|--------|\n"; 2093 - List.iter (fun proj -> 2733 + List.iteri (fun i proj -> 2734 + Log.debug (fun m -> m "README: Project %d/%d: %s" (i+1) (List.length project_names) proj); 2094 2735 let proj_branch = "project/" ^ proj in 2095 2736 let proj_wt = Unpac.Worktree.path root (Unpac.Worktree.Project proj) in 2096 2737 let wt_exists = Sys.file_exists (snd proj_wt) in 2097 2738 let dirty = wt_exists && has_changes proj_wt in 2098 2739 2099 2740 let merged_opam = List.filter (fun pkg -> 2100 - is_ancestor (Unpac_opam.Opam.patches_branch pkg) proj_branch 2741 + readme_is_ancestor (Unpac_opam.Opam.patches_branch pkg) proj_branch 2101 2742 ) opam_packages in 2102 2743 let merged_git = List.filter (fun repo -> 2103 - is_ancestor (Unpac.Git_backend.patches_branch repo) proj_branch 2744 + readme_is_ancestor (Unpac.Git_backend.patches_branch repo) proj_branch 2104 2745 ) git_repos in 2105 2746 2106 2747 let status = ··· 2113 2754 add "\n" 2114 2755 end; 2115 2756 2116 - (* Opam packages section *) 2757 + (* Opam packages section - parallel *) 2758 + Log.debug (fun m -> m "README: Processing %d opam packages..." (List.length opam_packages)); 2117 2759 add "## Opam Packages\n\n"; 2118 2760 if opam_packages = [] then 2119 2761 add "_No opam packages vendored yet._\n\n" 2120 2762 else begin 2763 + (* Parallel commit counts *) 2764 + let readme_opam_counts = parallel_commit_counts opam_packages 2765 + Unpac_opam.Opam.vendor_branch Unpac_opam.Opam.patches_branch in 2766 + let readme_opam_count_table = Hashtbl.create (List.length readme_opam_counts) in 2767 + List.iter (fun (pkg, count) -> Hashtbl.add readme_opam_count_table pkg count) readme_opam_counts; 2768 + 2769 + (* Parallel ancestry for opam -> projects *) 2770 + let readme_opam_to_proj = List.concat_map (fun pkg -> 2771 + let patches_branch = Unpac_opam.Opam.patches_branch pkg in 2772 + List.map (fun proj -> (patches_branch, "project/" ^ proj)) project_names 2773 + ) opam_packages in 2774 + let readme_opam_ancestry = parallel_is_ancestor readme_opam_to_proj in 2775 + let readme_opam_anc_table = Hashtbl.create (List.length readme_opam_ancestry) in 2776 + List.iter (fun (a, b, result) -> 2777 + Hashtbl.add readme_opam_anc_table (a, b) result 2778 + ) readme_opam_ancestry; 2779 + 2121 2780 add "| Package | Patches | Merged Into | Status |\n"; 2122 2781 add "|---------|---------|-------------|--------|\n"; 2123 - List.iter (fun pkg -> 2124 - let vendor_branch = Unpac_opam.Opam.vendor_branch pkg in 2782 + List.iteri (fun i pkg -> 2783 + Log.debug (fun m -> m "README: Opam package %d/%d: %s" (i+1) (List.length opam_packages) pkg); 2125 2784 let patches_branch = Unpac_opam.Opam.patches_branch pkg in 2126 - let patch_count = commit_count vendor_branch patches_branch in 2785 + let patch_count = try Hashtbl.find readme_opam_count_table pkg with Not_found -> 0 in 2127 2786 2128 2787 let patches_wt = Unpac.Worktree.path root (Unpac.Worktree.Opam_patches pkg) in 2129 2788 let has_wt = Sys.file_exists (snd patches_wt) in 2130 2789 let dirty = has_wt && has_changes patches_wt in 2131 2790 2132 2791 let merged_into = List.filter (fun proj -> 2133 - is_ancestor patches_branch ("project/" ^ proj) 2792 + try Hashtbl.find readme_opam_anc_table (patches_branch, "project/" ^ proj) 2793 + with Not_found -> false 2134 2794 ) project_names in 2135 2795 2136 2796 let merged_str = if merged_into = [] then "-" ··· 2146 2806 add "\n" 2147 2807 end; 2148 2808 2149 - (* Git repositories section *) 2809 + (* Git repositories section - parallel *) 2810 + Log.debug (fun m -> m "README: Processing %d git repos..." (List.length git_repos)); 2150 2811 add "## Git Repositories\n\n"; 2151 2812 if git_repos = [] then 2152 2813 add "_No git repositories vendored yet._\n\n" 2153 2814 else begin 2815 + (* Parallel commit counts *) 2816 + let readme_git_counts = parallel_commit_counts git_repos 2817 + Unpac.Git_backend.vendor_branch Unpac.Git_backend.patches_branch in 2818 + let readme_git_count_table = Hashtbl.create (List.length readme_git_counts) in 2819 + List.iter (fun (repo, count) -> Hashtbl.add readme_git_count_table repo count) readme_git_counts; 2820 + 2821 + (* Parallel ancestry for git -> projects *) 2822 + let readme_git_to_proj = List.concat_map (fun repo -> 2823 + let patches_branch = Unpac.Git_backend.patches_branch repo in 2824 + List.map (fun proj -> (patches_branch, "project/" ^ proj)) project_names 2825 + ) git_repos in 2826 + let readme_git_ancestry = parallel_is_ancestor readme_git_to_proj in 2827 + let readme_git_anc_table = Hashtbl.create (List.length readme_git_ancestry) in 2828 + List.iter (fun (a, b, result) -> 2829 + Hashtbl.add readme_git_anc_table (a, b) result 2830 + ) readme_git_ancestry; 2831 + 2154 2832 add "| Repository | Patches | Merged Into | Status |\n"; 2155 2833 add "|------------|---------|-------------|--------|\n"; 2156 - List.iter (fun repo -> 2157 - let vendor_branch = Unpac.Git_backend.vendor_branch repo in 2834 + List.iteri (fun i repo -> 2835 + Log.debug (fun m -> m "README: Git repo %d/%d: %s" (i+1) (List.length git_repos) repo); 2158 2836 let patches_branch = Unpac.Git_backend.patches_branch repo in 2159 - let patch_count = commit_count vendor_branch patches_branch in 2837 + let patch_count = try Hashtbl.find readme_git_count_table repo with Not_found -> 0 in 2160 2838 2161 2839 let patches_wt = Unpac.Worktree.path root (Unpac.Worktree.Git_patches repo) in 2162 2840 let has_wt = Sys.file_exists (snd patches_wt) in 2163 2841 let dirty = has_wt && has_changes patches_wt in 2164 2842 2165 2843 let merged_into = List.filter (fun proj -> 2166 - is_ancestor patches_branch ("project/" ^ proj) 2844 + try Hashtbl.find readme_git_anc_table (patches_branch, "project/" ^ proj) 2845 + with Not_found -> false 2167 2846 ) project_names in 2168 2847 2169 2848 let merged_str = if merged_into = [] then "-" ··· 2203 2882 add "\n" 2204 2883 end; 2205 2884 2885 + (* Changes section from audit log *) 2886 + Log.debug (fun m -> m "README: Generating Changes section from audit log..."); 2887 + let audit_path = Eio.Path.(main_wt / ".unpac-audit.json") |> snd in 2888 + (match Unpac.Audit.load audit_path with 2889 + | Error e -> 2890 + Log.debug (fun m -> m "README: Could not load audit log: %s" e) 2891 + | Ok audit_log -> 2892 + (* Filter to significant events and take most recent *) 2893 + let significant_ops = List.filter (fun (op : Unpac.Audit.operation) -> 2894 + match op.operation_type with 2895 + | Unpac.Audit.Project_new 2896 + | Unpac.Audit.Project_promote 2897 + | Unpac.Audit.Opam_add 2898 + | Unpac.Audit.Git_add 2899 + | Unpac.Audit.Init -> true 2900 + | _ -> false 2901 + ) audit_log.entries in 2902 + (* Take most recent 20 events, reverse to show oldest first *) 2903 + let recent_ops = 2904 + significant_ops 2905 + |> (fun l -> if List.length l > 20 then 2906 + List.filteri (fun i _ -> i < 20) l 2907 + else l) 2908 + |> List.rev 2909 + in 2910 + if recent_ops <> [] then begin 2911 + add "## Changes\n\n"; 2912 + add "| Date | Event | Details |\n"; 2913 + add "|------|-------|--------|\n"; 2914 + List.iter (fun (op : Unpac.Audit.operation) -> 2915 + let tm = Unix.localtime op.timestamp in 2916 + let date = Printf.sprintf "%04d-%02d-%02d" 2917 + (tm.Unix.tm_year + 1900) (tm.Unix.tm_mon + 1) tm.Unix.tm_mday in 2918 + let (event, details) = match op.operation_type, op.args with 2919 + | Unpac.Audit.Init, _ -> 2920 + ("Workspace initialized", "") 2921 + | Unpac.Audit.Project_new, name :: _ -> 2922 + ("Project created", Printf.sprintf "`%s`" name) 2923 + | Unpac.Audit.Project_promote, name :: _ -> 2924 + let backend = List.find_map (fun arg -> 2925 + if String.starts_with ~prefix:"--backend" arg then None 2926 + else match List.nth_opt op.args (1 + (List.length (List.filter ((=) arg) (List.filteri (fun i _ -> i = 0) op.args)))) with 2927 + | _ -> None 2928 + ) op.args in 2929 + let backend_str = match backend with Some b -> b | None -> 2930 + (* Try to find backend in args *) 2931 + let rec find_backend = function 2932 + | "--backend" :: b :: _ -> b 2933 + | "-b" :: b :: _ -> b 2934 + | _ :: rest -> find_backend rest 2935 + | [] -> "opam" 2936 + in find_backend op.args 2937 + in 2938 + ("Project promoted", Printf.sprintf "`%s` → %s vendor" name backend_str) 2939 + | Unpac.Audit.Opam_add, pkgs -> 2940 + let pkg_list = String.concat ", " (List.map (fun p -> Printf.sprintf "`%s`" p) pkgs) in 2941 + ("Opam packages added", pkg_list) 2942 + | Unpac.Audit.Git_add, name :: _ -> 2943 + ("Git repo added", Printf.sprintf "`%s`" name) 2944 + | _, args -> 2945 + (Unpac.Audit.operation_type_to_string op.operation_type, 2946 + String.concat " " args) 2947 + in 2948 + addf "| %s | %s | %s |\n" date event details 2949 + ) recent_ops; 2950 + add "\n" 2951 + end); 2952 + 2206 2953 (* Footer *) 2207 2954 add "---\n\n"; 2208 2955 add "_Generated by `unpac status`_\n"; 2209 2956 2210 2957 (* Write README.md *) 2958 + Log.debug (fun m -> m "README: Checking if README.md needs update..."); 2211 2959 let readme_path = Filename.concat (snd main_wt) "README.md" in 2212 2960 let content = Buffer.contents buf in 2213 2961 2214 2962 (* Check if content changed *) 2215 2963 let old_content = 2216 2964 if Sys.file_exists readme_path then begin 2965 + Log.debug (fun m -> m "README: Reading existing README.md..."); 2217 2966 let ic = open_in readme_path in 2218 2967 let len = in_channel_length ic in 2219 2968 let s = really_input_string ic len in 2220 2969 close_in ic; 2221 2970 Some s 2222 - end else None 2971 + end else begin 2972 + Log.debug (fun m -> m "README: No existing README.md"); 2973 + None 2974 + end 2223 2975 in 2224 2976 2225 2977 (* Only write and commit if changed (ignoring timestamp line) *) ··· 2233 2985 in 2234 2986 2235 2987 if changed then begin 2988 + Log.debug (fun m -> m "README: Writing updated README.md..."); 2236 2989 let oc = open_out readme_path in 2237 2990 output_string oc content; 2238 2991 close_out oc; 2239 2992 Format.printf "@.README.md updated.@."; 2240 2993 (* Git add and commit *) 2994 + Log.debug (fun m -> m "README: Staging README.md..."); 2241 2995 Unpac.Git.run_exn ~proc_mgr ~cwd:main_wt ["add"; "README.md"] |> ignore; 2242 2996 (try 2997 + Log.debug (fun m -> m "README: Committing README.md..."); 2243 2998 Unpac.Git.run_exn ~proc_mgr ~cwd:main_wt 2244 2999 ["commit"; "-m"; "Update workspace status in README.md"] |> ignore; 2245 3000 Format.printf "Committed README.md changes.@." 2246 3001 with _ -> 2247 3002 (* Commit might fail if nothing staged (e.g., only timestamp changed) *) 2248 - ()) 2249 - end 3003 + Log.debug (fun m -> m "README: Commit failed (likely nothing to commit)")) 3004 + end else 3005 + Log.debug (fun m -> m "README: No changes, skipping write"); 3006 + Log.debug (fun m -> m "Status command complete.") 2250 3007 end 2251 3008 in 2252 3009 let info = Cmd.info "status" ~doc ~man in 2253 - Cmd.v info Term.(const run $ logging_term $ short_flag $ no_readme_flag) 3010 + Cmd.v info Term.(const run $ const () $ short_flag $ no_readme_flag $ verbose_flag) 2254 3011 2255 3012 (* Main command *) 2256 3013 let main_cmd = ··· 2280 3037 `S "COMMANDS"; 2281 3038 ] in 2282 3039 let info = Cmd.info "unpac" ~version:"0.1.0" ~doc ~man in 2283 - Cmd.group info [init_cmd; status_cmd; project_cmd; opam_cmd; git_cmd; vendor_cmd; push_cmd; log_cmd] 3040 + Cmd.group info [init_cmd; status_cmd; project_cmd; opam_cmd; git_cmd; vendor_cmd; push_cmd; log_cmd; 3041 + export_cmd; export_set_remote_cmd; export_push_cmd; export_list_cmd] 2284 3042 2285 3043 let () = exit (Cmd.eval main_cmd)
+54 -106
bin/unpac-claude/main.ml
··· 1 - (** Unpac Claude agent - autonomous coding assistant. *) 1 + (** Unpac Claude agent - ralph-loop style autonomous coding for workspace projects. *) 2 2 3 3 open Cmdliner 4 4 5 5 let setup_logging verbose = 6 6 Fmt_tty.setup_std_outputs (); 7 - let level = if verbose then Logs.Debug else Logs.Info in 7 + (* Normal mode: Warning level (suppress Claude lib's JSON INFO logs) 8 + Verbose mode: Debug level (show everything) *) 9 + let level = if verbose then Logs.Debug else Logs.Warning in 8 10 Logs.set_level (Some level); 9 11 Logs.set_reporter (Logs_fmt.reporter ()) 10 12 11 - let run_agent model max_turns verbose autonomous sync_interval web_port workspace_path prompt_opt = 13 + let run_agent verbose web_port project workspace_path = 12 14 setup_logging verbose; 13 15 Eio_main.run @@ fun env -> 14 - let model = match model with 15 - | "sonnet" -> `Sonnet 16 - | "opus" -> `Opus 17 - | "haiku" -> `Haiku 18 - | _ -> `Sonnet 19 - in 20 16 let config : Unpac_claude.Agent.config = { 21 - model; 22 - max_turns; 23 17 verbose; 24 - autonomous; 25 - sync_interval; 26 18 web_port; 19 + max_iterations = 20; 20 + project; 27 21 } in 28 - Unpac_claude.Agent.run ~env ~config ~initial_prompt:prompt_opt ?workspace_path () 22 + Unpac_claude.Agent.run ~env ~config ~workspace_path () 29 23 30 24 (* CLI *) 31 - let model_arg = 32 - let doc = "Claude model to use: sonnet, opus, or haiku." in 33 - Arg.(value & opt string "sonnet" & info ["m"; "model"] ~docv:"MODEL" ~doc) 34 - 35 - let max_turns_arg = 36 - let doc = "Maximum number of conversation turns (default: unlimited)." in 37 - Arg.(value & opt (some int) None & info ["max-turns"] ~docv:"N" ~doc) 38 - 39 25 let verbose_arg = 40 26 let doc = "Enable verbose logging." in 41 27 Arg.(value & flag & info ["v"; "verbose"] ~doc) 42 - 43 - let autonomous_arg = 44 - let doc = "Run in autonomous mode. The agent will continuously analyze and \ 45 - improve projects without waiting for user input. It will regularly \ 46 - run unpac status/push to sync with remote." in 47 - Arg.(value & flag & info ["a"; "autonomous"] ~doc) 48 - 49 - let sync_interval_arg = 50 - let doc = "In autonomous mode, run unpac status and push every N turns." in 51 - Arg.(value & opt int 10 & info ["sync-interval"] ~docv:"N" ~doc) 52 28 53 29 let web_port_arg = 54 - let doc = "Enable web UI on this port. Opens a live dashboard showing agent \ 55 - events, tool calls, and responses in real-time via WebSocket." in 30 + let doc = "Enable web UI on this port. Shows live streaming events." in 56 31 Arg.(value & opt (some int) None & info ["web"] ~docv:"PORT" ~doc) 57 32 58 - let workspace_arg = 59 - let doc = "Path to the unpac workspace to analyze. If not specified, searches \ 60 - upward from current directory." in 61 - Arg.(value & opt (some string) None & info ["w"; "workspace"] ~docv:"PATH" ~doc) 33 + let project_arg = 34 + let doc = "Specific project to work on. If not specified, runs all \ 35 + projects sequentially in random order." in 36 + Arg.(value & opt (some string) None & info ["p"; "project"] ~docv:"NAME" ~doc) 62 37 63 - let prompt_arg = 64 - let doc = "Initial prompt to start the agent with (optional)." in 65 - Arg.(value & pos 0 (some string) None & info [] ~docv:"PROMPT" ~doc) 38 + let workspace_arg = 39 + let doc = "Path to the unpac workspace. Required." in 40 + Arg.(required & pos 0 (some string) None & info [] ~docv:"WORKSPACE" ~doc) 66 41 67 42 let cmd = 68 - let doc = "Autonomous Claude agent for unpac workspace analysis and improvement" in 43 + let doc = "Ralph-loop style Claude agent for unpac workspace projects" in 69 44 let man = [ 70 45 `S Manpage.s_description; 71 - `P "An autonomous Claude agent that analyzes and improves OCaml projects \ 72 - in an unpac workspace. It can run interactively or autonomously."; 73 - `S "MODES"; 74 - `I ("Interactive (default)", "Responds to user prompts in a REPL-style \ 75 - conversation. Use for directed exploration \ 76 - and specific tasks."); 77 - `I ("Autonomous (-a)", "Continuously analyzes all projects, updates STATUS.md \ 78 - files, refactors code, and syncs with remote. Runs \ 79 - until max-turns or manual interruption."); 46 + `P "Runs an autonomous Claude agent using the ralph-loop pattern: \ 47 + the same prompt is fed each iteration, with state persisting in \ 48 + files. The agent works on projects until either:"; 49 + `I ("Iterations", "20 iterations have completed"); 50 + `I ("Completion", "The agent outputs the completion promise"); 51 + `S "RALPH-LOOP PATTERN"; 52 + `P "Unlike traditional agentic loops that vary prompts based on \ 53 + previous responses, ralph-loop feeds the SAME prompt every \ 54 + iteration. Claude's progress persists in files (STATUS.md, \ 55 + source code, git commits) which it reads on each iteration."; 56 + `P "This creates a self-referential improvement loop where Claude \ 57 + sees its own previous work and continues from there."; 58 + `S "COMPLETION PROMISE"; 59 + `P (Printf.sprintf "When all significant work is complete, Claude \ 60 + outputs exactly: %s" Unpac_claude.Agent.completion_promise); 61 + `P "This signals the loop to stop early before 20 iterations."; 62 + `S "MODEL"; 63 + `P "Always uses Claude Opus 4.5 for maximum capability."; 80 64 `S "WEB UI"; 81 - `P "Use --web PORT to enable a live web dashboard that shows:"; 82 - `I ("Events", "Real-time streaming of agent activity"); 65 + `P "Use --web PORT to enable a live web dashboard showing:"; 66 + `I ("Events", "Real-time streaming from the agent"); 83 67 `I ("Tool calls", "Each tool invocation with input/output"); 84 - `I ("Text output", "Assistant responses as they stream in"); 85 - `I ("Costs", "Running total of API costs"); 86 - `P "The web UI connects via WebSocket and auto-reconnects if disconnected."; 87 - `S "AUTONOMOUS ANALYSIS"; 88 - `P "In autonomous mode, the agent will:"; 89 - `I ("1. Project Analysis", "Read STATUS.md, scan source files, check tests"); 90 - `I ("2. Documentation", "Update STATUS.md with current state and TODOs"); 91 - `I ("3. Code Quality", "Refactor using OCaml Stdlib combinators, remove \ 92 - repetitive code, use higher-order functions"); 93 - `I ("4. Test Coverage", "Identify missing tests, flag untested code"); 94 - `I ("5. Sync", "Periodically run 'unpac status' and 'unpac push origin'"); 95 - `S "RATE LIMITING"; 96 - `P "The agent automatically handles API rate limits with exponential backoff. \ 97 - If rate limited, it will pause and retry automatically."; 68 + `I ("Iterations", "Current iteration progress"); 98 69 `S Manpage.s_examples; 99 - `P "Start interactive mode:"; 100 - `Pre " unpac-claude"; 101 - `P "Start with web UI on port 8080:"; 102 - `Pre " unpac-claude --web 8080"; 103 - `P "Autonomous mode with web UI:"; 104 - `Pre " unpac-claude -a --web 8080 -w /path/to/workspace"; 105 - `P "Start autonomous analysis of a workspace:"; 106 - `Pre " unpac-claude -a -w /path/to/workspace"; 107 - `P "Autonomous with custom sync interval:"; 108 - `Pre " unpac-claude -a --sync-interval 5 -w /path/to/workspace"; 109 - `P "Autonomous with turn limit:"; 110 - `Pre " unpac-claude -a --max-turns 50 -w /path/to/workspace"; 111 - `P "Interactive with initial prompt:"; 112 - `Pre " unpac-claude \"Analyze the brotli project and update its STATUS.md\""; 113 - `P "Use Opus model for more complex analysis:"; 114 - `Pre " unpac-claude -m opus -a -w /path/to/workspace"; 115 - `S "AVAILABLE TOOLS"; 116 - `P "The agent has access to these tools:"; 117 - `I ("unpac_status", "Get workspace overview"); 118 - `I ("unpac_status_sync", "Run unpac status to update README.md"); 119 - `I ("unpac_push", "Push all branches to remote"); 120 - `I ("unpac_git_list", "List vendored git repos"); 121 - `I ("unpac_git_add", "Add a git repository"); 122 - `I ("unpac_git_info", "Get repo details"); 123 - `I ("unpac_git_diff", "Show local changes"); 124 - `I ("unpac_opam_list", "List vendored opam packages"); 125 - `I ("unpac_project_list", "List projects"); 126 - `I ("read_file", "Read source code and config files"); 127 - `I ("write_file", "Update code or STATUS.md"); 128 - `I ("list_directory", "Explore directory structure"); 129 - `I ("glob_files", "Find files by pattern"); 130 - `I ("run_shell", "Run dune build/test commands"); 131 - `I ("git_commit", "Commit changes"); 70 + `P "Run agent on all projects (random order):"; 71 + `Pre " unpac-claude /path/to/workspace"; 72 + `P "Run agent on a specific project:"; 73 + `Pre " unpac-claude -p mylib /path/to/workspace"; 74 + `P "With web UI on port 8080:"; 75 + `Pre " unpac-claude --web 8080 /path/to/workspace"; 76 + `P "Verbose logging:"; 77 + `Pre " unpac-claude -v /path/to/workspace"; 78 + `S "WORKING DIRECTORY"; 79 + `P "State is maintained in <workspace>/.unpac-claude/<project>/ \ 80 + with a .claude subdirectory for ralph-loop state."; 132 81 `S "EXIT STATUS"; 133 - `P "The agent exits with 0 on normal completion (max-turns reached or \ 134 - user quit). It can be interrupted with Ctrl+C."; 82 + `P "Exits with 0 when all projects complete (either by iteration \ 83 + limit or completion promise). Can be interrupted with Ctrl+C."; 135 84 ] in 136 - let info = Cmd.info "unpac-claude" ~version:"0.3.0" ~doc ~man in 137 - Cmd.v info Term.(const run_agent $ model_arg $ max_turns_arg $ verbose_arg $ 138 - autonomous_arg $ sync_interval_arg $ web_port_arg $ 139 - workspace_arg $ prompt_arg) 85 + let info = Cmd.info "unpac-claude" ~version:"0.5.0" ~doc ~man in 86 + Cmd.v info Term.(const run_agent $ verbose_arg $ web_port_arg $ 87 + project_arg $ workspace_arg) 140 88 141 89 let () = exit (Cmd.eval cmd)
+1
claude.dev
··· 1 + ../claude.dev
+6
lib/audit.ml
··· 30 30 type operation_type = 31 31 | Init 32 32 | Project_new 33 + | Project_promote 34 + | Project_set_remote 33 35 | Opam_add 34 36 | Opam_init 35 37 | Opam_promote ··· 129 131 let operation_type_to_string = function 130 132 | Init -> "init" 131 133 | Project_new -> "project.new" 134 + | Project_promote -> "project.promote" 135 + | Project_set_remote -> "project.set-remote" 132 136 | Opam_add -> "opam.add" 133 137 | Opam_init -> "opam.init" 134 138 | Opam_promote -> "opam.promote" ··· 147 151 let operation_type_of_string = function 148 152 | "init" -> Init 149 153 | "project.new" -> Project_new 154 + | "project.promote" -> Project_promote 155 + | "project.set-remote" -> Project_set_remote 150 156 | "opam.add" -> Opam_add 151 157 | "opam.init" -> Opam_init 152 158 | "opam.promote" -> Opam_promote
+5
lib/audit.mli
··· 42 42 type operation_type = 43 43 | Init 44 44 | Project_new 45 + | Project_promote 46 + | Project_set_remote 45 47 | Opam_add 46 48 | Opam_init 47 49 | Opam_promote ··· 58 60 | Unknown of string 59 61 60 62 val operation_type_jsont : operation_type Jsont.t 63 + 64 + val operation_type_to_string : operation_type -> string 65 + (** Convert operation type to string representation *) 61 66 62 67 (** An unpac operation with its git operations *) 63 68 type operation = {
+132
lib/claude/prompt.ml
··· 289 289 add (get_workspace_state ~proc_mgr ~root); 290 290 291 291 Buffer.contents buf 292 + 293 + let project_base_prompt project project_dir = Printf.sprintf 294 + {|You are an autonomous coding agent assigned to work on the '%s' project. 295 + 296 + ## Your Mission 297 + 298 + You are working EXCLUSIVELY on the '%s' project located at: %s 299 + 300 + Your goals are to: 301 + 1. **Understand**: Read and analyze all project source code 302 + 2. **Document**: Update STATUS.md with accurate project state 303 + 3. **Improve**: Make focused code quality improvements 304 + 4. **Test**: Ensure code builds and tests pass 305 + 5. **Commit**: Commit meaningful changes with clear messages 306 + 307 + ## OCaml Code Quality Guidelines 308 + 309 + When improving code, look for: 310 + 311 + ### Functional Idioms 312 + - Replace `for` loops with `List.iter`, `List.map`, `List.fold_left` 313 + - Use `Option.map`, `Option.bind`, `Result.map`, `Result.bind` 314 + - Use `|>` pipeline operator for cleaner composition 315 + - Prefer `List.filter_map` over filter + map 316 + 317 + ### Stdlib Combinators 318 + ```ocaml 319 + List.filter_map (* filter and map in one pass *) 320 + List.concat_map (* map then flatten *) 321 + List.find_opt (* safe find *) 322 + Option.value (* provide default *) 323 + Option.join (* flatten option option *) 324 + String.concat (* join strings *) 325 + String.split_on_char 326 + ``` 327 + 328 + ## STATUS.md Format 329 + 330 + Maintain a STATUS.md in the project with: 331 + 332 + ```markdown 333 + # %s 334 + 335 + **Status**: [STUB | IN_PROGRESS | COMPLETE | NEEDS_REVIEW] 336 + 337 + ## Overview 338 + Brief description of what this project does. 339 + 340 + ## Current State 341 + - What is implemented 342 + - What works 343 + 344 + ## TODO 345 + - [ ] Task 1 346 + - [ ] Task 2 347 + - [x] Completed task 348 + 349 + ## Known Issues 350 + - Issue 1 351 + 352 + ## Test Coverage 353 + - What is tested 354 + - What needs tests 355 + ``` 356 + 357 + ## Workflow 358 + 359 + 1. Check if STATUS.md exists and read it 360 + 2. Glob all *.ml and *.mli files 361 + 3. Read and analyze source files 362 + 4. Run dune build to check compilation 363 + 5. Update STATUS.md with findings 364 + 6. Make focused improvements 365 + 7. Commit changes 366 + 8. Repeat until work is complete 367 + 368 + ## Completion Signal 369 + 370 + When you have completed all significant work on this project: 371 + - Updated STATUS.md comprehensively 372 + - Made all reasonable improvements 373 + - Verified builds pass 374 + - No obvious remaining issues 375 + 376 + Then output exactly: AGENTIC-HUMPS-COUNT-2 377 + 378 + This signals the ralph-loop to stop iterating. 379 + 380 + ## Important Rules 381 + 382 + 1. **Focus**: Only work on %s - ignore other projects 383 + 2. **Small Changes**: Make incremental improvements 384 + 3. **Test First**: Run dune build before committing 385 + 4. **Be Honest**: Record actual shortcomings in STATUS.md 386 + 5. **Commit Often**: Commit after each logical change 387 + 388 + ## Available Tools 389 + 390 + - **read_file**: Read source code and config files 391 + - **write_file**: Update code or STATUS.md 392 + - **list_directory**: Explore directory structure 393 + - **glob_files**: Find files by pattern 394 + - **run_shell**: Run dune build/test commands 395 + - **git_commit**: Commit changes 396 + - **unpac_status_sync**: Update workspace status 397 + - **unpac_push**: Push changes to remote 398 + 399 + Start by exploring the project structure and reading existing files. 400 + |} project project project_dir project project 401 + 402 + let generate_for_project ~proc_mgr:_ ~root ~project = 403 + let buf = Buffer.create 16384 in 404 + let add s = Buffer.add_string buf s in 405 + 406 + (* Get project directory *) 407 + let project_path = Unpac.Worktree.path root (Unpac.Worktree.Project project) in 408 + let project_dir = snd project_path in 409 + 410 + (* Add project-specific prompt *) 411 + add (project_base_prompt project project_dir); 412 + 413 + add "\n\n---\n\n"; 414 + 415 + (* Add architecture docs if available *) 416 + (match read_architecture ~root with 417 + | Some arch -> 418 + add "## Workspace Architecture\n\n"; 419 + add arch; 420 + add "\n\n"; 421 + | None -> ()); 422 + 423 + Buffer.contents buf
+8
lib/claude/prompt.mli
··· 14 14 If [autonomous] is true, includes detailed instructions for autonomous 15 15 code maintenance and improvement. *) 16 16 17 + val generate_for_project : 18 + proc_mgr:Unpac.Git.proc_mgr -> 19 + root:Unpac.Worktree.root -> 20 + project:string -> 21 + string 22 + (** Generate a system prompt for a specific project agent. 23 + The agent will focus exclusively on the given project. *) 24 + 17 25 val autonomous_base_prompt : string 18 26 (** Base system prompt for autonomous mode. *) 19 27
+49
lib/git.ml
··· 484 484 (* Cleanup and re-raise *) 485 485 ignore (run ~proc_mgr ~cwd ["worktree"; "remove"; "-f"; temp_wt_relpath]); 486 486 raise (err e)) 487 + 488 + let filter_repo_from_subdirectory ~proc_mgr ~cwd ~branch ~subdirectory = 489 + Log.info (fun m -> m "Extracting %s from subdirectory %s to root..." branch subdirectory); 490 + (* Use git-filter-repo with --subdirectory-filter to extract files from subdirectory 491 + to root. This is the inverse of --to-subdirectory-filter. 492 + Preserves history for files that were in the subdirectory. 493 + 494 + For bare repositories, we need to create a temporary worktree, run filter-repo 495 + there, and then update the branch in the bare repo. *) 496 + 497 + (* Create a unique temporary worktree name using the branch name *) 498 + let safe_branch = String.map (fun c -> if c = '/' then '-' else c) branch in 499 + let temp_wt_name = ".filter-tmp-" ^ safe_branch in 500 + let temp_wt_relpath = "../" ^ temp_wt_name in 501 + 502 + (* Construct the worktree path - cwd is (fs, path_string), so we go up one level *) 503 + let fs = fst cwd in 504 + let git_path = snd cwd in 505 + let parent_path = Filename.dirname git_path in 506 + let temp_wt_path = Filename.concat parent_path temp_wt_name in 507 + let temp_wt : path = (fs, temp_wt_path) in 508 + 509 + (* Remove any existing temp worktree *) 510 + ignore (run ~proc_mgr ~cwd ["worktree"; "remove"; "-f"; temp_wt_relpath]); 511 + 512 + (* Create worktree for the branch *) 513 + run_exn ~proc_mgr ~cwd ["worktree"; "add"; temp_wt_relpath; branch] |> ignore; 514 + 515 + (* Run git-filter-repo in the worktree with --subdirectory-filter *) 516 + let result = run ~proc_mgr ~cwd:temp_wt [ 517 + "filter-repo"; 518 + "--subdirectory-filter"; subdirectory; 519 + "--force"; 520 + "--refs"; "HEAD" 521 + ] in 522 + 523 + (* Handle result: get the new SHA, cleanup worktree, then update branch *) 524 + (match result with 525 + | Ok _ -> 526 + (* Get the new HEAD SHA from the worktree BEFORE removing it *) 527 + let new_sha = run_exn ~proc_mgr ~cwd:temp_wt ["rev-parse"; "HEAD"] |> string_trim in 528 + (* Cleanup temporary worktree first (must do this before updating branch) *) 529 + ignore (run ~proc_mgr ~cwd ["worktree"; "remove"; "-f"; temp_wt_relpath]); 530 + (* Now update the branch in the bare repo *) 531 + run_exn ~proc_mgr ~cwd ["branch"; "-f"; branch; new_sha] |> ignore 532 + | Error e -> 533 + (* Cleanup and re-raise *) 534 + ignore (run ~proc_mgr ~cwd ["worktree"; "remove"; "-f"; temp_wt_relpath]); 535 + raise (err e))
+12
lib/git.mli
··· 385 385 (** [filter_repo_to_subdirectory ~proc_mgr ~cwd ~branch ~subdirectory] 386 386 rewrites the history of [branch] so all files are moved into [subdirectory]. 387 387 Uses git-filter-repo for fast history rewriting. Preserves full commit history. *) 388 + 389 + val filter_repo_from_subdirectory : 390 + proc_mgr:proc_mgr -> 391 + cwd:path -> 392 + branch:string -> 393 + subdirectory:string -> 394 + unit 395 + (** [filter_repo_from_subdirectory ~proc_mgr ~cwd ~branch ~subdirectory] 396 + rewrites the history of [branch] extracting only files from [subdirectory] 397 + and placing them at the repository root. This is the inverse of 398 + [filter_repo_to_subdirectory]. Uses git-filter-repo --subdirectory-filter. 399 + Preserves full commit history for files that were in the subdirectory. *)
+10 -5
lib/opam/opam.ml
··· 141 141 if not (Worktree.branch_exists ~proc_mgr root (patches_kind pkg)) then 142 142 Backend.Update_failed { name = pkg; error = "Package not vendored" } 143 143 else begin 144 - (* Get remote URL - check vendor-cache remote first, then origin-<pkg> *) 145 - let remote = "origin-" ^ pkg in 146 - let url = match Git.remote_url ~proc_mgr ~cwd:git remote with 147 - | Some u -> u 148 - | None -> failwith ("Remote not found: " ^ remote) 144 + (* Get remote URL - check origin-<pkg> first, then upstream-<pkg> for promoted packages *) 145 + let origin_remote = "origin-" ^ pkg in 146 + let upstream_remote = "upstream-" ^ pkg in 147 + let (remote, url) = match Git.remote_url ~proc_mgr ~cwd:git origin_remote with 148 + | Some u -> (origin_remote, u) 149 + | None -> 150 + (* Try upstream remote for promoted/local packages *) 151 + match Git.remote_url ~proc_mgr ~cwd:git upstream_remote with 152 + | Some u -> (upstream_remote, u) 153 + | None -> failwith (Printf.sprintf "No remote found. Set one with: unpac opam set-upstream %s <url>" pkg) 149 154 in 150 155 151 156 (* Fetch latest - either via cache or directly (with tags for completeness) *)
+454
lib/promote.ml
··· 1 + (** Project promotion to vendor library. 2 + 3 + Promotes a locally-developed project to a vendored library by: 4 + 1. Filtering out the vendor/ directory from the project history 5 + 2. Creating vendor branches (upstream/vendor/patches) for the specified backend 6 + 3. Recording the promotion in the audit log 7 + 8 + This allows the project to be merged into other projects as a dependency. *) 9 + 10 + let src = Logs.Src.create "unpac.promote" ~doc:"Project promotion" 11 + module Log = (val Logs.src_log src : Logs.LOG) 12 + 13 + (** Backend types for promotion *) 14 + type backend = 15 + | Opam 16 + | Git 17 + 18 + let backend_of_string = function 19 + | "opam" -> Some Opam 20 + | "git" -> Some Git 21 + | _ -> None 22 + 23 + let backend_to_string = function 24 + | Opam -> "opam" 25 + | Git -> "git" 26 + 27 + (** Branch names for a backend *) 28 + let upstream_branch backend name = match backend with 29 + | Opam -> "opam/upstream/" ^ name 30 + | Git -> "git/upstream/" ^ name 31 + 32 + let vendor_branch backend name = match backend with 33 + | Opam -> "opam/vendor/" ^ name 34 + | Git -> "git/vendor/" ^ name 35 + 36 + let patches_branch backend name = match backend with 37 + | Opam -> "opam/patches/" ^ name 38 + | Git -> "git/patches/" ^ name 39 + 40 + let vendor_path backend name = match backend with 41 + | Opam -> "vendor/opam/" ^ name 42 + | Git -> "vendor/git/" ^ name 43 + 44 + (** Result of promotion *) 45 + type promote_result = 46 + | Promoted of { 47 + name : string; 48 + backend : backend; 49 + original_commits : int; 50 + filtered_commits : int; 51 + } 52 + | Already_promoted of string 53 + | Project_not_found of string 54 + | Failed of { name : string; error : string } 55 + 56 + (** Filter a branch to exclude vendor/ directory. 57 + Uses git-filter-repo to rewrite history. *) 58 + let filter_vendor_directory ~proc_mgr ~cwd ~branch = 59 + Log.info (fun m -> m "Filtering vendor/ directory from branch %s..." branch); 60 + 61 + (* Use git-filter-repo with path filtering to exclude vendor/ *) 62 + let fs = fst cwd in 63 + let git_path = snd cwd in 64 + let parent_path = Filename.dirname git_path in 65 + 66 + (* Create a unique temporary worktree *) 67 + let safe_branch = String.map (fun c -> if c = '/' then '-' else c) branch in 68 + let temp_wt_name = ".filter-vendor-" ^ safe_branch in 69 + let temp_wt_relpath = "../" ^ temp_wt_name in 70 + let temp_wt_path = Filename.concat parent_path temp_wt_name in 71 + let temp_wt : Git.path = (fs, temp_wt_path) in 72 + 73 + (* Remove any existing temp worktree *) 74 + ignore (Git.run ~proc_mgr ~cwd ["worktree"; "remove"; "-f"; temp_wt_relpath]); 75 + 76 + (* Create worktree for the branch *) 77 + Git.run_exn ~proc_mgr ~cwd ["worktree"; "add"; temp_wt_relpath; branch] |> ignore; 78 + 79 + (* Count commits before filtering *) 80 + let commits_before = 81 + int_of_string (String.trim (Git.run_exn ~proc_mgr ~cwd:temp_wt ["rev-list"; "--count"; "HEAD"])) 82 + in 83 + 84 + (* Run git-filter-repo to exclude vendor/ *) 85 + let result = Git.run ~proc_mgr ~cwd:temp_wt [ 86 + "filter-repo"; 87 + "--invert-paths"; 88 + "--path"; "vendor/"; 89 + "--force"; 90 + "--refs"; "HEAD" 91 + ] in 92 + 93 + match result with 94 + | Ok _ -> 95 + (* Count commits after filtering *) 96 + let commits_after = 97 + int_of_string (String.trim (Git.run_exn ~proc_mgr ~cwd:temp_wt ["rev-list"; "--count"; "HEAD"])) 98 + in 99 + (* Get the new HEAD SHA *) 100 + let new_sha = Git.run_exn ~proc_mgr ~cwd:temp_wt ["rev-parse"; "HEAD"] |> String.trim in 101 + (* Cleanup temporary worktree *) 102 + ignore (Git.run ~proc_mgr ~cwd ["worktree"; "remove"; "-f"; temp_wt_relpath]); 103 + (* Update the branch in the bare repo *) 104 + Git.run_exn ~proc_mgr ~cwd ["branch"; "-f"; branch; new_sha] |> ignore; 105 + Ok (commits_before, commits_after) 106 + | Error e -> 107 + (* Cleanup and return error *) 108 + ignore (Git.run ~proc_mgr ~cwd ["worktree"; "remove"; "-f"; temp_wt_relpath]); 109 + Error (Fmt.str "%a" Git.pp_error e) 110 + 111 + (** Promote a project to a vendored library *) 112 + let promote ~proc_mgr ~root ~project ~backend ~vendor_name = 113 + let git = Worktree.git_dir root in 114 + let name = Option.value ~default:project vendor_name in 115 + 116 + (* Check if project exists *) 117 + if not (Worktree.branch_exists ~proc_mgr root (Worktree.Project project)) then 118 + Project_not_found project 119 + else begin 120 + (* Check if already promoted for this backend *) 121 + let patches_br = patches_branch backend name in 122 + if Git.branch_exists ~proc_mgr ~cwd:git patches_br then 123 + Already_promoted name 124 + else begin 125 + try 126 + Log.info (fun m -> m "Promoting project %s as %s vendor %s..." project (backend_to_string backend) name); 127 + 128 + let project_branch = Worktree.branch (Worktree.Project project) in 129 + 130 + (* Step 1: Create a temporary branch from the project for filtering *) 131 + let temp_branch = "promote-temp-" ^ name in 132 + Git.run_exn ~proc_mgr ~cwd:git ["branch"; "-f"; temp_branch; project_branch] |> ignore; 133 + 134 + (* Step 2: Filter out vendor/ directory from the temp branch *) 135 + let (commits_before, commits_after) = 136 + match filter_vendor_directory ~proc_mgr ~cwd:git ~branch:temp_branch with 137 + | Ok counts -> counts 138 + | Error msg -> 139 + (* Cleanup temp branch *) 140 + ignore (Git.run ~proc_mgr ~cwd:git ["branch"; "-D"; temp_branch]); 141 + failwith msg 142 + in 143 + 144 + Log.info (fun m -> m "Filtered %d -> %d commits" commits_before commits_after); 145 + 146 + (* Step 3: Create upstream branch (filtered, files at root) *) 147 + (* For local projects, upstream is the same as filtered temp - no external upstream *) 148 + Git.run_exn ~proc_mgr ~cwd:git ["branch"; "-f"; upstream_branch backend name; temp_branch] |> ignore; 149 + 150 + (* Step 4: Create vendor branch from upstream and rewrite to vendor path *) 151 + Git.run_exn ~proc_mgr ~cwd:git ["branch"; "-f"; vendor_branch backend name; upstream_branch backend name] |> ignore; 152 + 153 + (* Rewrite vendor branch to move files into vendor/<backend>/<name>/ *) 154 + Git.filter_repo_to_subdirectory ~proc_mgr ~cwd:git 155 + ~branch:(vendor_branch backend name) 156 + ~subdirectory:(vendor_path backend name); 157 + 158 + (* Step 5: Create patches branch from vendor *) 159 + Git.run_exn ~proc_mgr ~cwd:git ["branch"; patches_branch backend name; vendor_branch backend name] |> ignore; 160 + 161 + (* Step 6: Cleanup temp branch *) 162 + ignore (Git.run ~proc_mgr ~cwd:git ["branch"; "-D"; temp_branch]); 163 + 164 + Promoted { 165 + name; 166 + backend; 167 + original_commits = commits_before; 168 + filtered_commits = commits_after 169 + } 170 + with exn -> 171 + (* Cleanup on failure *) 172 + let temp_branch = "promote-temp-" ^ name in 173 + ignore (Git.run ~proc_mgr ~cwd:git ["branch"; "-D"; temp_branch]); 174 + ignore (Git.run ~proc_mgr ~cwd:git ["branch"; "-D"; upstream_branch backend name]); 175 + ignore (Git.run ~proc_mgr ~cwd:git ["branch"; "-D"; vendor_branch backend name]); 176 + Failed { name = project; error = Printexc.to_string exn } 177 + end 178 + end 179 + 180 + (** {1 Remote Management} *) 181 + 182 + (** Remote name for a project *) 183 + let project_remote_name project = "origin-" ^ project 184 + 185 + (** Result of set-remote operation *) 186 + type set_remote_result = 187 + | Remote_set of { project : string; url : string; created : bool } 188 + | Project_not_found of string 189 + | Set_remote_failed of { project : string; error : string } 190 + 191 + (** Set the remote URL for a project *) 192 + let set_remote ~proc_mgr ~root ~project ~url = 193 + let git = Worktree.git_dir root in 194 + 195 + (* Check if project exists *) 196 + if not (Worktree.branch_exists ~proc_mgr root (Worktree.Project project)) then 197 + Project_not_found project 198 + else begin 199 + try 200 + let remote_name = project_remote_name project in 201 + Log.info (fun m -> m "Setting remote %s -> %s for project %s" remote_name url project); 202 + 203 + let created = match Git.ensure_remote ~proc_mgr ~cwd:git ~name:remote_name ~url with 204 + | `Created -> true 205 + | `Updated | `Existed -> false 206 + in 207 + 208 + Remote_set { project; url; created } 209 + with exn -> 210 + Set_remote_failed { project; error = Printexc.to_string exn } 211 + end 212 + 213 + (** Get the remote URL for a project *) 214 + let get_remote ~proc_mgr ~root ~project = 215 + let git = Worktree.git_dir root in 216 + let remote_name = project_remote_name project in 217 + Git.remote_url ~proc_mgr ~cwd:git remote_name 218 + 219 + (** Result of push operation *) 220 + type push_result = 221 + | Pushed of { project : string; branch : string; remote : string } 222 + | No_remote of string 223 + | Project_not_found of string 224 + | Push_failed of { project : string; error : string } 225 + 226 + (** Push a project to its configured remote *) 227 + let push ~proc_mgr ~root ~project = 228 + let git = Worktree.git_dir root in 229 + 230 + (* Check if project exists *) 231 + if not (Worktree.branch_exists ~proc_mgr root (Worktree.Project project)) then 232 + Project_not_found project 233 + else begin 234 + let remote_name = project_remote_name project in 235 + match Git.remote_url ~proc_mgr ~cwd:git remote_name with 236 + | None -> No_remote project 237 + | Some _url -> 238 + try 239 + let branch = Worktree.branch (Worktree.Project project) in 240 + Log.info (fun m -> m "Pushing %s to %s..." branch remote_name); 241 + Git.run_exn ~proc_mgr ~cwd:git ["push"; "-u"; remote_name; branch] |> ignore; 242 + Pushed { project; branch; remote = remote_name } 243 + with exn -> 244 + Push_failed { project; error = Printexc.to_string exn } 245 + end 246 + 247 + (** {1 Project Info} *) 248 + 249 + type project_info = { 250 + name : string; 251 + origin : [`Local | `Vendored]; 252 + remote : string option; 253 + promoted_as : (backend * string) option; (* backend, vendor_name *) 254 + } 255 + 256 + (** Get information about a project *) 257 + let get_info ~proc_mgr ~root ~project = 258 + let git = Worktree.git_dir root in 259 + 260 + if not (Worktree.branch_exists ~proc_mgr root (Worktree.Project project)) then 261 + None 262 + else begin 263 + (* Check for remote *) 264 + let remote = get_remote ~proc_mgr ~root ~project in 265 + 266 + (* Check if promoted - look for opam/patches/<project> or git/patches/<project> *) 267 + let promoted_as = 268 + if Git.branch_exists ~proc_mgr ~cwd:git (patches_branch Opam project) then 269 + Some (Opam, project) 270 + else if Git.branch_exists ~proc_mgr ~cwd:git (patches_branch Git project) then 271 + Some (Git, project) 272 + else 273 + None 274 + in 275 + 276 + Some { 277 + name = project; 278 + origin = `Local; (* All projects created via `unpac project new` are local *) 279 + remote; 280 + promoted_as; 281 + } 282 + end 283 + 284 + (** {1 Export (Unvendor)} *) 285 + 286 + (** Export branch name - where unvendored code goes *) 287 + let export_branch backend name = match backend with 288 + | Opam -> "opam/export/" ^ name 289 + | Git -> "git/export/" ^ name 290 + 291 + (** Result of export operation *) 292 + type export_result = 293 + | Exported of { 294 + name : string; 295 + backend : backend; 296 + source_branch : string; 297 + export_branch : string; 298 + commits : int; 299 + } 300 + | Not_vendored of string 301 + | Already_exported of string 302 + | Export_failed of { name : string; error : string } 303 + 304 + (** Export a vendored package back to root-level files. 305 + This is the inverse of vendoring - takes a vendor branch and creates 306 + an export branch with files moved from vendor/<backend>/<name>/ to root. 307 + 308 + Can export from either vendor/* or patches/* branch. *) 309 + let export ~proc_mgr ~root ~name ~backend ~from_patches = 310 + let git = Worktree.git_dir root in 311 + 312 + (* Determine source branch *) 313 + let source_br = if from_patches then patches_branch backend name 314 + else vendor_branch backend name in 315 + let export_br = export_branch backend name in 316 + let subdir = vendor_path backend name in 317 + 318 + (* Check if source branch exists *) 319 + if not (Git.branch_exists ~proc_mgr ~cwd:git source_br) then 320 + Not_vendored name 321 + else if Git.branch_exists ~proc_mgr ~cwd:git export_br then 322 + Already_exported name 323 + else begin 324 + try 325 + Log.info (fun m -> m "Exporting %s from %s to %s..." name source_br export_br); 326 + 327 + (* Step 1: Create export branch from source *) 328 + Git.run_exn ~proc_mgr ~cwd:git ["branch"; "-f"; export_br; source_br] |> ignore; 329 + 330 + (* Step 2: Count commits before transformation *) 331 + let commits = 332 + int_of_string (String.trim ( 333 + Git.run_exn ~proc_mgr ~cwd:git ["rev-list"; "--count"; export_br])) 334 + in 335 + 336 + (* Step 3: Rewrite export branch to move files from subdirectory to root *) 337 + Git.filter_repo_from_subdirectory ~proc_mgr ~cwd:git 338 + ~branch:export_br 339 + ~subdirectory:subdir; 340 + 341 + Exported { 342 + name; 343 + backend; 344 + source_branch = source_br; 345 + export_branch = export_br; 346 + commits; 347 + } 348 + with exn -> 349 + (* Cleanup on failure *) 350 + ignore (Git.run ~proc_mgr ~cwd:git ["branch"; "-D"; export_br]); 351 + Export_failed { name; error = Printexc.to_string exn } 352 + end 353 + 354 + (** Remote name for export (where we push to) *) 355 + let export_remote_name name = "export-" ^ name 356 + 357 + (** Remote name for upstream (where we fetch from) *) 358 + let upstream_remote_name name = "upstream-" ^ name 359 + 360 + (** Result of export push operation *) 361 + type export_push_result = 362 + | Export_pushed of { 363 + name : string; 364 + backend : backend; 365 + remote : string; 366 + branch : string; 367 + commits : int; 368 + } 369 + | Export_not_found of string 370 + | No_export_remote of string 371 + | Export_push_failed of { name : string; error : string } 372 + 373 + (** Set the remote URL for exporting a package *) 374 + let set_export_remote ~proc_mgr ~root ~name ~url = 375 + let git = Worktree.git_dir root in 376 + let remote_name = export_remote_name name in 377 + Log.info (fun m -> m "Setting export remote %s -> %s" remote_name url); 378 + Git.ensure_remote ~proc_mgr ~cwd:git ~name:remote_name ~url 379 + 380 + (** Get the export remote URL for a package *) 381 + let get_export_remote ~proc_mgr ~root ~name = 382 + let git = Worktree.git_dir root in 383 + let remote_name = export_remote_name name in 384 + Git.remote_url ~proc_mgr ~cwd:git remote_name 385 + 386 + (** Set the remote URL for fetching upstream updates. 387 + This is used for promoted local packages that don't have an opam source URL. *) 388 + let set_upstream_remote ~proc_mgr ~root ~name ~url = 389 + let git = Worktree.git_dir root in 390 + let remote_name = upstream_remote_name name in 391 + Log.info (fun m -> m "Setting upstream remote %s -> %s" remote_name url); 392 + Git.ensure_remote ~proc_mgr ~cwd:git ~name:remote_name ~url 393 + 394 + (** Get the upstream remote URL for a package *) 395 + let get_upstream_remote ~proc_mgr ~root ~name = 396 + let git = Worktree.git_dir root in 397 + let remote_name = upstream_remote_name name in 398 + Git.remote_url ~proc_mgr ~cwd:git remote_name 399 + 400 + (** Push an exported branch to its remote *) 401 + let push_export ~proc_mgr ~root ~name ~backend = 402 + let git = Worktree.git_dir root in 403 + let export_br = export_branch backend name in 404 + let remote_name = export_remote_name name in 405 + 406 + (* Check if export branch exists *) 407 + if not (Git.branch_exists ~proc_mgr ~cwd:git export_br) then 408 + Export_not_found name 409 + else begin 410 + match Git.remote_url ~proc_mgr ~cwd:git remote_name with 411 + | None -> No_export_remote name 412 + | Some _url -> 413 + try 414 + (* Count commits *) 415 + let commits = 416 + int_of_string (String.trim ( 417 + Git.run_exn ~proc_mgr ~cwd:git ["rev-list"; "--count"; export_br])) 418 + in 419 + 420 + Log.info (fun m -> m "Pushing %s to %s..." export_br remote_name); 421 + (* Push the export branch - push to main/master on the remote *) 422 + Git.run_exn ~proc_mgr ~cwd:git [ 423 + "push"; "-u"; remote_name; 424 + export_br ^ ":main" (* Push export branch as 'main' on remote *) 425 + ] |> ignore; 426 + 427 + Export_pushed { 428 + name; 429 + backend; 430 + remote = remote_name; 431 + branch = export_br; 432 + commits; 433 + } 434 + with exn -> 435 + Export_push_failed { name; error = Printexc.to_string exn } 436 + end 437 + 438 + (** List all exported packages *) 439 + let list_exports ~proc_mgr ~root = 440 + let git = Worktree.git_dir root in 441 + let branches = Git.run_lines ~proc_mgr ~cwd:git ["branch"; "--list"; "*/export/*"] in 442 + List.filter_map (fun line -> 443 + let branch = String.trim line in 444 + let branch = if String.length branch > 0 && branch.[0] = '*' then 445 + String.trim (String.sub branch 1 (String.length branch - 1)) 446 + else branch in 447 + (* Parse backend/export/name *) 448 + match String.split_on_char '/' branch with 449 + | [backend_str; "export"; name] -> 450 + (match backend_of_string backend_str with 451 + | Some backend -> Some (backend, name) 452 + | None -> None) 453 + | _ -> None 454 + ) branches
+292
lib/promote.mli
··· 1 + (** Project promotion to vendor library. 2 + 3 + Promotes a locally-developed project to a vendored library by: 4 + 1. Filtering out the vendor/ directory from the project history 5 + 2. Creating vendor branches (upstream/vendor/patches) for the specified backend 6 + 3. Recording the promotion in the audit log 7 + 8 + This allows the project to be merged into other projects as a dependency. *) 9 + 10 + (** {1 Backend Types} *) 11 + 12 + (** Vendor backend for the promoted library *) 13 + type backend = 14 + | Opam (** OCaml package - creates opam/* branches, vendor/opam/<name>/ path *) 15 + | Git (** Git repository - creates git/* branches, vendor/git/<name>/ path *) 16 + 17 + val backend_of_string : string -> backend option 18 + (** Parse backend from string: "opam" or "git" *) 19 + 20 + val backend_to_string : backend -> string 21 + (** Convert backend to string *) 22 + 23 + (** {1 Branch Names} *) 24 + 25 + val upstream_branch : backend -> string -> string 26 + (** [upstream_branch backend name] returns the upstream branch name, 27 + e.g., "opam/upstream/brotli" or "git/upstream/brotli" *) 28 + 29 + val vendor_branch : backend -> string -> string 30 + (** [vendor_branch backend name] returns the vendor branch name *) 31 + 32 + val patches_branch : backend -> string -> string 33 + (** [patches_branch backend name] returns the patches branch name *) 34 + 35 + val vendor_path : backend -> string -> string 36 + (** [vendor_path backend name] returns the vendor directory path, 37 + e.g., "vendor/opam/brotli" or "vendor/git/brotli" *) 38 + 39 + (** {1 Promotion} *) 40 + 41 + (** Result of a promote operation *) 42 + type promote_result = 43 + | Promoted of { 44 + name : string; (** Vendor library name *) 45 + backend : backend; (** Backend used *) 46 + original_commits : int; (** Commits in project before filtering *) 47 + filtered_commits : int; (** Commits after removing vendor/ *) 48 + } 49 + | Already_promoted of string 50 + (** Library already exists with this name *) 51 + | Project_not_found of string 52 + (** Source project does not exist *) 53 + | Failed of { name : string; error : string } 54 + (** Promotion failed *) 55 + 56 + val promote : 57 + proc_mgr:Git.proc_mgr -> 58 + root:Worktree.root -> 59 + project:string -> 60 + backend:backend -> 61 + vendor_name:string option -> 62 + promote_result 63 + (** [promote ~proc_mgr ~root ~project ~backend ~vendor_name] promotes 64 + a local project to a vendored library. 65 + 66 + The operation: 67 + 1. Checks that the project exists and hasn't been promoted yet 68 + 2. Creates a filtered copy of project history (excluding vendor/) 69 + 3. Creates upstream/vendor/patches branches for the backend 70 + 4. The original project branch is preserved unchanged 71 + 72 + @param project Name of the project to promote (e.g., "brotli") 73 + @param backend Backend type (Opam or Git) 74 + @param vendor_name Optional override for the vendor library name 75 + 76 + After promotion, the library can be merged into other projects using: 77 + - [unpac opam merge <name> <project>] for Opam backend 78 + - [unpac git merge <name> <project>] for Git backend *) 79 + 80 + (** {1 Remote Management} *) 81 + 82 + val project_remote_name : string -> string 83 + (** [project_remote_name project] returns the git remote name for a project, 84 + e.g., "origin-brotli" *) 85 + 86 + (** Result of set-remote operation *) 87 + type set_remote_result = 88 + | Remote_set of { project : string; url : string; created : bool } 89 + | Project_not_found of string 90 + | Set_remote_failed of { project : string; error : string } 91 + 92 + val set_remote : 93 + proc_mgr:Git.proc_mgr -> 94 + root:Worktree.root -> 95 + project:string -> 96 + url:string -> 97 + set_remote_result 98 + (** [set_remote ~proc_mgr ~root ~project ~url] sets the remote URL for a project. 99 + 100 + Creates or updates a git remote named "origin-<project>" pointing to the URL. 101 + This allows the project to be pushed independently using [push]. *) 102 + 103 + val get_remote : 104 + proc_mgr:Git.proc_mgr -> 105 + root:Worktree.root -> 106 + project:string -> 107 + string option 108 + (** [get_remote ~proc_mgr ~root ~project] returns the remote URL for a project, 109 + or None if no remote is configured. *) 110 + 111 + (** Result of push operation *) 112 + type push_result = 113 + | Pushed of { project : string; branch : string; remote : string } 114 + | No_remote of string 115 + | Project_not_found of string 116 + | Push_failed of { project : string; error : string } 117 + 118 + val push : 119 + proc_mgr:Git.proc_mgr -> 120 + root:Worktree.root -> 121 + project:string -> 122 + push_result 123 + (** [push ~proc_mgr ~root ~project] pushes a project to its configured remote. 124 + 125 + Pushes the project/<name> branch to the remote configured via [set_remote]. 126 + Returns [No_remote] if no remote has been configured. *) 127 + 128 + (** {1 Project Info} *) 129 + 130 + type project_info = { 131 + name : string; 132 + origin : [`Local | `Vendored]; 133 + remote : string option; 134 + promoted_as : (backend * string) option; (** backend, vendor_name *) 135 + } 136 + 137 + val get_info : 138 + proc_mgr:Git.proc_mgr -> 139 + root:Worktree.root -> 140 + project:string -> 141 + project_info option 142 + (** [get_info ~proc_mgr ~root ~project] returns information about a project, 143 + or None if the project doesn't exist. *) 144 + 145 + (** {1 Export (Unvendor)} 146 + 147 + Export reverses the vendoring process, creating a branch with files 148 + at the repository root suitable for pushing to an external git repo. 149 + 150 + This is the inverse of vendoring: 151 + - Vendoring: files at root → files in vendor/<backend>/<name>/ 152 + - Exporting: files in vendor/<backend>/<name>/ → files at root *) 153 + 154 + val export_branch : backend -> string -> string 155 + (** [export_branch backend name] returns the export branch name, 156 + e.g., "opam/export/brotli" or "git/export/brotli" *) 157 + 158 + (** Result of export operation *) 159 + type export_result = 160 + | Exported of { 161 + name : string; (** Package name *) 162 + backend : backend; (** Backend used *) 163 + source_branch : string; (** Branch exported from (vendor or patches) *) 164 + export_branch : string; (** Created export branch *) 165 + commits : int; (** Number of commits in export *) 166 + } 167 + | Not_vendored of string 168 + (** No vendor branch exists for this package *) 169 + | Already_exported of string 170 + (** Export branch already exists *) 171 + | Export_failed of { name : string; error : string } 172 + (** Export operation failed *) 173 + 174 + val export : 175 + proc_mgr:Git.proc_mgr -> 176 + root:Worktree.root -> 177 + name:string -> 178 + backend:backend -> 179 + from_patches:bool -> 180 + export_result 181 + (** [export ~proc_mgr ~root ~name ~backend ~from_patches] exports a vendored 182 + package back to root-level files. 183 + 184 + Creates an export branch where files are moved from [vendor/<backend>/<name>/] 185 + to the repository root. This branch can then be pushed to an upstream repo. 186 + 187 + @param name The vendored package name 188 + @param backend The backend (Opam or Git) 189 + @param from_patches If true, exports from patches/* branch (includes local mods); 190 + if false, exports from vendor/* branch (pristine upstream) 191 + 192 + The export branch is named [<backend>/export/<name>], e.g., "git/export/brotli". 193 + 194 + Example workflow: 195 + {[ 196 + (* Export with local patches *) 197 + export ~from_patches:true ... 198 + 199 + (* Set remote and push *) 200 + set_export_remote ~url:"git@github.com:me/brotli.git" ... 201 + push_export ... 202 + ]} *) 203 + 204 + val export_remote_name : string -> string 205 + (** [export_remote_name name] returns the git remote name for exports, 206 + e.g., "export-brotli" *) 207 + 208 + val set_export_remote : 209 + proc_mgr:Git.proc_mgr -> 210 + root:Worktree.root -> 211 + name:string -> 212 + url:string -> 213 + [ `Created | `Existed | `Updated ] 214 + (** [set_export_remote ~proc_mgr ~root ~name ~url] sets the remote URL 215 + for pushing exports of a package. Creates remote "export-<name>". *) 216 + 217 + val get_export_remote : 218 + proc_mgr:Git.proc_mgr -> 219 + root:Worktree.root -> 220 + name:string -> 221 + string option 222 + (** [get_export_remote ~proc_mgr ~root ~name] returns the export remote URL, 223 + or None if no export remote is configured. *) 224 + 225 + (** {2 Upstream Remote} 226 + 227 + The upstream remote is where we fetch updates from. For packages added 228 + via [opam add], the upstream is automatically configured. For promoted 229 + local projects, use [set_upstream_remote] to configure where updates 230 + should be fetched from. *) 231 + 232 + val upstream_remote_name : string -> string 233 + (** [upstream_remote_name name] returns the git remote name for upstream, 234 + e.g., "upstream-brotli" *) 235 + 236 + val set_upstream_remote : 237 + proc_mgr:Git.proc_mgr -> 238 + root:Worktree.root -> 239 + name:string -> 240 + url:string -> 241 + [ `Created | `Existed | `Updated ] 242 + (** [set_upstream_remote ~proc_mgr ~root ~name ~url] sets the remote URL 243 + for fetching upstream updates. Creates remote "upstream-<name>". 244 + 245 + This is used by [opam update] to fetch new changes. For promoted local 246 + projects, this typically points to the same repo as the export remote. *) 247 + 248 + val get_upstream_remote : 249 + proc_mgr:Git.proc_mgr -> 250 + root:Worktree.root -> 251 + name:string -> 252 + string option 253 + (** [get_upstream_remote ~proc_mgr ~root ~name] returns the upstream remote URL, 254 + or None if no upstream remote is configured. *) 255 + 256 + (** Result of export push operation *) 257 + type export_push_result = 258 + | Export_pushed of { 259 + name : string; 260 + backend : backend; 261 + remote : string; 262 + branch : string; 263 + commits : int; 264 + } 265 + | Export_not_found of string 266 + (** No export branch exists for this package *) 267 + | No_export_remote of string 268 + (** No export remote configured *) 269 + | Export_push_failed of { name : string; error : string } 270 + (** Push operation failed *) 271 + 272 + val push_export : 273 + proc_mgr:Git.proc_mgr -> 274 + root:Worktree.root -> 275 + name:string -> 276 + backend:backend -> 277 + export_push_result 278 + (** [push_export ~proc_mgr ~root ~name ~backend] pushes an export branch 279 + to its configured remote. 280 + 281 + Pushes the [<backend>/export/<name>] branch to the remote configured 282 + via [set_export_remote], targeting the 'main' branch on the remote. 283 + 284 + Returns [Export_not_found] if the package hasn't been exported yet. 285 + Returns [No_export_remote] if no remote has been configured. *) 286 + 287 + val list_exports : 288 + proc_mgr:Git.proc_mgr -> 289 + root:Worktree.root -> 290 + (backend * string) list 291 + (** [list_exports ~proc_mgr ~root] returns all exported packages as 292 + (backend, name) pairs. *)
+1
lib/unpac.ml
··· 11 11 module Backend = Backend 12 12 module Audit = Audit 13 13 module Git_backend = Git_backend 14 + module Promote = Promote