gitgrip 2.0.0a4__tar.gz → 2.0.0a5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (241) hide show
  1. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/PKG-INFO +1 -1
  2. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/gitgrip.egg-info/PKG-INFO +1 -1
  3. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/gitgrip.egg-info/SOURCES.txt +8 -0
  4. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/pyproject.toml +1 -1
  5. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/python_cli/app.py +326 -59
  6. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/python_cli/clone_exec.py +52 -0
  7. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/python_cli/consent.py +27 -2
  8. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/python_cli/gitops.py +148 -2
  9. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/python_cli/grip.py +8 -0
  10. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/python_cli/grip_cli.py +74 -6
  11. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/python_cli/hooks.py +94 -6
  12. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/python_cli/platform.py +59 -3
  13. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/python_cli/pr.py +49 -0
  14. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/python_cli/spec_apply.py +61 -11
  15. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/python_cli/syncops.py +51 -10
  16. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_apply_convergence.py +13 -13
  17. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_conftest_git_isolation.py +1 -0
  18. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_hooks_consent.py +276 -0
  19. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_lane_create_fork_base_cli.py +33 -0
  20. gitgrip-2.0.0a5/tests/test_lane_create_refusal_leaves_nothing.py +354 -0
  21. gitgrip-2.0.0a5/tests/test_lane_enter_hook_warnings.py +205 -0
  22. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_lane_lifecycle_safety.py +4 -0
  23. gitgrip-2.0.0a5/tests/test_materialize_pin.py +413 -0
  24. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_open_gr_enter.py +16 -0
  25. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_pr_events.py +7 -0
  26. gitgrip-2.0.0a5/tests/test_pr_group_legibility.py +236 -0
  27. gitgrip-2.0.0a5/tests/test_repo_path_read_through.py +382 -0
  28. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_rmtree_or_refuse.py +12 -3
  29. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_spec_apply_event_outcomes.py +5 -2
  30. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_sprint21_sync_platform.py +224 -0
  31. gitgrip-2.0.0a5/tests/test_store_member_state.py +138 -0
  32. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_submodule_member_predicate.py +1 -1
  33. gitgrip-2.0.0a5/tests/test_superproject_import.py +384 -0
  34. gitgrip-2.0.0a5/tests/test_validate_repo_path_conflict.py +169 -0
  35. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_version_static.py +2 -2
  36. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/README.md +0 -0
  37. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/gitgrip.egg-info/dependency_links.txt +0 -0
  38. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/gitgrip.egg-info/entry_points.txt +0 -0
  39. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/gitgrip.egg-info/requires.txt +0 -0
  40. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/gitgrip.egg-info/top_level.txt +0 -0
  41. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/gr2/__init__.py +0 -0
  42. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/gr2/overlay/__init__.py +0 -0
  43. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/gr2/overlay/activate.py +0 -0
  44. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/gr2/overlay/agent_manifest.py +0 -0
  45. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/gr2/overlay/cli.py +0 -0
  46. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/gr2/overlay/cross_repo.py +0 -0
  47. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/gr2/overlay/drivers.py +0 -0
  48. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/gr2/overlay/introspection.py +0 -0
  49. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/gr2/overlay/language_drivers.py +0 -0
  50. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/gr2/overlay/objects.py +0 -0
  51. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/gr2/overlay/perf.py +0 -0
  52. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/gr2/overlay/refs.py +0 -0
  53. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/gr2/overlay/trust.py +0 -0
  54. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/gr2/overlay/types.py +0 -0
  55. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/gr2/overlay/units.py +0 -0
  56. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/gr2/overlay/workspace_spec.py +0 -0
  57. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/gr2/schemas/gr2-materialization-plan-v1.schema.json +0 -0
  58. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/gr2_overlay/__init__.py +0 -0
  59. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/prototypes/__init__.py +0 -0
  60. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/prototypes/cache_materialization_probe.py +0 -0
  61. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/prototypes/concurrent_event_stress.py +0 -0
  62. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/prototypes/concurrent_lease_stress.py +0 -0
  63. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/prototypes/concurrent_workspace_cap_stress.py +0 -0
  64. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/prototypes/contribution_protocol.py +0 -0
  65. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/prototypes/cross_mode_lane_stress.py +0 -0
  66. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/prototypes/jsonl_store.py +0 -0
  67. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/prototypes/lane_workspace_prototype.py +0 -0
  68. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/prototypes/layout_model_probe.py +0 -0
  69. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/prototypes/propagation_daemon.py +0 -0
  70. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/prototypes/propagation_state_machine.py +0 -0
  71. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/prototypes/python_exec_playground.py +0 -0
  72. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/prototypes/python_hook_runtime_playground.py +0 -0
  73. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/prototypes/python_migration_playground.py +0 -0
  74. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/prototypes/python_review_checkout_playground.py +0 -0
  75. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/prototypes/python_spec_apply_playground.py +0 -0
  76. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/prototypes/real_git_lane_materialization.py +0 -0
  77. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/prototypes/real_git_playground.py +0 -0
  78. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/prototypes/recall_lane_history.py +0 -0
  79. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/prototypes/repo_maintenance_prototype.py +0 -0
  80. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/prototypes/repo_transport_probe.py +0 -0
  81. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/python_cli/__init__.py +0 -0
  82. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/python_cli/__main__.py +0 -0
  83. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/python_cli/add.py +0 -0
  84. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/python_cli/branch.py +0 -0
  85. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/python_cli/channel_bridge.py +0 -0
  86. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/python_cli/commit.py +0 -0
  87. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/python_cli/config.py +0 -0
  88. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/python_cli/env_exec.py +0 -0
  89. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/python_cli/events.py +0 -0
  90. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/python_cli/execops.py +0 -0
  91. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/python_cli/failures.py +0 -0
  92. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/python_cli/file_exec.py +0 -0
  93. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/python_cli/git_review.py +0 -0
  94. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/python_cli/gitinclude.py +0 -0
  95. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/python_cli/launch_exec.py +0 -0
  96. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/python_cli/merge_verification.py +0 -0
  97. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/python_cli/migration.py +0 -0
  98. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/python_cli/open_gr_review.py +0 -0
  99. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/python_cli/project_review.py +0 -0
  100. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/python_cli/prune.py +0 -0
  101. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/python_cli/push.py +0 -0
  102. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/python_cli/review.py +0 -0
  103. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/python_cli/review_dispatch.py +0 -0
  104. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/python_cli/review_ephemeral.py +0 -0
  105. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/python_cli/review_rebind.py +0 -0
  106. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/python_cli/review_run.py +0 -0
  107. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/python_cli/review_runners.py +0 -0
  108. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/python_cli/staging_cleanup.py +0 -0
  109. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/python_cli/target.py +0 -0
  110. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/python_cli/workspace_guidance.py +0 -0
  111. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/python_cli/workspace_snapshot.py +0 -0
  112. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/setup.cfg +0 -0
  113. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_add.py +0 -0
  114. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_agent_manifest_overlay_source.py +0 -0
  115. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_app_event_outcomes.py +0 -0
  116. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_branch.py +0 -0
  117. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_channel_bridge.py +0 -0
  118. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_cli_adapter_error_boundary.py +0 -0
  119. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_cli_shape_witnesses.py +0 -0
  120. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_clone_materialization.py +0 -0
  121. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_commit.py +0 -0
  122. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_config_base_path_guard.py +0 -0
  123. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_config_overlay.py +0 -0
  124. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_contribution_protocol.py +0 -0
  125. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_ensure_lane_checkout_reference_clone.py +0 -0
  126. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_env_materialization.py +0 -0
  127. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_event_log_integrity.py +0 -0
  128. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_event_outcome_policy.py +0 -0
  129. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_events.py +0 -0
  130. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_events_torn_line.py +0 -0
  131. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_exec_apostrophe_and_oserror.py +0 -0
  132. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_exec_single_string_and_materialize_plan.py +0 -0
  133. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_execops.py +0 -0
  134. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_failure_event_outcome.py +0 -0
  135. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_first_five_minutes.py +0 -0
  136. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_foreground_launch_runtime.py +0 -0
  137. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_git_review.py +0 -0
  138. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_gitinclude_compiler.py +0 -0
  139. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_gr1_first_run_guidance.py +0 -0
  140. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_gr2_packaging.py +0 -0
  141. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_gr2_review_receipt.py +0 -0
  142. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_grip_cli.py +0 -0
  143. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_grip_hardening.py +0 -0
  144. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_grip_object_model.py +0 -0
  145. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_grip_snapshot.py +0 -0
  146. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_hook_events.py +0 -0
  147. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_hook_hardening.py +0 -0
  148. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_jsonl_store.py +0 -0
  149. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_lane_create_refuses_own_branch.py +0 -0
  150. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_lane_lease_show_json.py +0 -0
  151. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_lane_repo_paths_and_all_skipped.py +0 -0
  152. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_lane_toml_serialization.py +0 -0
  153. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_launch_primitive.py +0 -0
  154. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_m2_overlay_hindsight.py +0 -0
  155. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_m2_overlay_mem0.py +0 -0
  156. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_m2_overlay_zep.py +0 -0
  157. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_materialization_plan_contract.py +0 -0
  158. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_merge_evidence.py +0 -0
  159. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_merge_parent_verification.py +0 -0
  160. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_migration.py +0 -0
  161. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_migration_overlay_compatibility.py +0 -0
  162. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_migration_unadopted_notice.py +0 -0
  163. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_no_bare_python_cli_imports.py +0 -0
  164. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_null_current_lane_refusal.py +0 -0
  165. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_open_project_refusal_reason_cli.py +0 -0
  166. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_overlay_acceptance.py +0 -0
  167. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_overlay_activate.py +0 -0
  168. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_overlay_cli.py +0 -0
  169. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_overlay_cli_wiring.py +0 -0
  170. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_overlay_composition_merge.py +0 -0
  171. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_overlay_cross_repo_atomic_apply.py +0 -0
  172. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_overlay_cross_repo_perf_gate.py +0 -0
  173. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_overlay_cross_repo_rollback.py +0 -0
  174. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_overlay_driver_registry.py +0 -0
  175. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_overlay_introspection.py +0 -0
  176. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_overlay_multi_stack.py +0 -0
  177. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_overlay_object_encoding.py +0 -0
  178. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_overlay_perf_gates.py +0 -0
  179. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_overlay_python_driver.py +0 -0
  180. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_overlay_tier_b_encoding.py +0 -0
  181. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_overlay_tier_b_perf_gates.py +0 -0
  182. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_overlay_tier_b_repo_state.py +0 -0
  183. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_overlay_tier_b_staging_discipline.py +0 -0
  184. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_overlay_trust_model.py +0 -0
  185. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_overlay_workspace_spec.py +0 -0
  186. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_pr_merge_method.py +0 -0
  187. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_pr_merge_partial_failure.py +0 -0
  188. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_project_file_materialization.py +0 -0
  189. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_project_review_range_reconstruction.py +0 -0
  190. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_project_review_three_repo.py +0 -0
  191. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_projection_tracked_dest.py +0 -0
  192. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_propagation_contribution.py +0 -0
  193. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_propagation_daemon.py +0 -0
  194. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_propagation_state_machine.py +0 -0
  195. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_prune.py +0 -0
  196. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_push.py +0 -0
  197. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_readme_verbs_exist.py +0 -0
  198. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_repo_status_linked_worktree.py +0 -0
  199. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_repo_status_schema_version.py +0 -0
  200. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_repo_status_symlinked_git_dir.py +0 -0
  201. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_review_bind_fork_base.py +0 -0
  202. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_review_bind_from_range.py +0 -0
  203. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_review_bind_verify.py +0 -0
  204. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_review_cli.py +0 -0
  205. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_review_close_gr.py +0 -0
  206. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_review_create_project_cli.py +0 -0
  207. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_review_dispatch.py +0 -0
  208. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_review_ephemeral.py +0 -0
  209. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_review_open_lane.py +0 -0
  210. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_review_rebind.py +0 -0
  211. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_review_run.py +0 -0
  212. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_review_run_import_isolation.py +0 -0
  213. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_review_run_rerun_and_refusal_receipt.py +0 -0
  214. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_review_runners.py +0 -0
  215. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_review_verify_inside.py +0 -0
  216. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_single_repo_cwd_refusal.py +0 -0
  217. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_snapshot_id_prefix_resolution.py +0 -0
  218. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_spec_declaration_ref_pin.py +0 -0
  219. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_staging_cleanup.py +0 -0
  220. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_store_verb_rename.py +0 -0
  221. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_sync_event_outcomes.py +0 -0
  222. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_sync_fast_forward.py +0 -0
  223. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_sync_fetch.py +0 -0
  224. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_sync_seed_local.py +0 -0
  225. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_sync_ssh_rewrite.py +0 -0
  226. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_sync_status_probe.py +0 -0
  227. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_target.py +0 -0
  228. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_tmux_launch_runtime.py +0 -0
  229. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_unit_abort.py +0 -0
  230. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_unit_apply.py +0 -0
  231. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_unit_apply_dry_run.py +0 -0
  232. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_unit_manifest_schema.py +0 -0
  233. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_unit_propose.py +0 -0
  234. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_unit_topological_apply.py +0 -0
  235. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_workspace_convert_clone.py +0 -0
  236. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_workspace_edit_lease_cap.py +0 -0
  237. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_workspace_identity_boundary.py +0 -0
  238. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_workspace_init_from_topology.py +0 -0
  239. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_workspace_init_local_repos.py +0 -0
  240. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_workspace_root_defaults.py +0 -0
  241. {gitgrip-2.0.0a4 → gitgrip-2.0.0a5}/tests/test_workspace_snapshot.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitgrip
3
- Version: 2.0.0a4
3
+ Version: 2.0.0a5
4
4
  Summary: Workspace layer for multi-repo work: workspaces, isolated lanes, and one grouped review per slice
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitgrip
3
- Version: 2.0.0a4
3
+ Version: 2.0.0a5
4
4
  Summary: Workspace layer for multi-repo work: workspaces, isolated lanes, and one grouped review per slice
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -128,7 +128,9 @@ tests/test_hook_hardening.py
128
128
  tests/test_hooks_consent.py
129
129
  tests/test_jsonl_store.py
130
130
  tests/test_lane_create_fork_base_cli.py
131
+ tests/test_lane_create_refusal_leaves_nothing.py
131
132
  tests/test_lane_create_refuses_own_branch.py
133
+ tests/test_lane_enter_hook_warnings.py
132
134
  tests/test_lane_lease_show_json.py
133
135
  tests/test_lane_lifecycle_safety.py
134
136
  tests/test_lane_repo_paths_and_all_skipped.py
@@ -138,6 +140,7 @@ tests/test_m2_overlay_hindsight.py
138
140
  tests/test_m2_overlay_mem0.py
139
141
  tests/test_m2_overlay_zep.py
140
142
  tests/test_materialization_plan_contract.py
143
+ tests/test_materialize_pin.py
141
144
  tests/test_merge_evidence.py
142
145
  tests/test_merge_parent_verification.py
143
146
  tests/test_migration.py
@@ -168,6 +171,7 @@ tests/test_overlay_tier_b_staging_discipline.py
168
171
  tests/test_overlay_trust_model.py
169
172
  tests/test_overlay_workspace_spec.py
170
173
  tests/test_pr_events.py
174
+ tests/test_pr_group_legibility.py
171
175
  tests/test_pr_merge_method.py
172
176
  tests/test_pr_merge_partial_failure.py
173
177
  tests/test_project_file_materialization.py
@@ -180,6 +184,7 @@ tests/test_propagation_state_machine.py
180
184
  tests/test_prune.py
181
185
  tests/test_push.py
182
186
  tests/test_readme_verbs_exist.py
187
+ tests/test_repo_path_read_through.py
183
188
  tests/test_repo_status_linked_worktree.py
184
189
  tests/test_repo_status_schema_version.py
185
190
  tests/test_repo_status_symlinked_git_dir.py
@@ -205,8 +210,10 @@ tests/test_spec_apply_event_outcomes.py
205
210
  tests/test_spec_declaration_ref_pin.py
206
211
  tests/test_sprint21_sync_platform.py
207
212
  tests/test_staging_cleanup.py
213
+ tests/test_store_member_state.py
208
214
  tests/test_store_verb_rename.py
209
215
  tests/test_submodule_member_predicate.py
216
+ tests/test_superproject_import.py
210
217
  tests/test_sync_event_outcomes.py
211
218
  tests/test_sync_fast_forward.py
212
219
  tests/test_sync_fetch.py
@@ -221,6 +228,7 @@ tests/test_unit_apply_dry_run.py
221
228
  tests/test_unit_manifest_schema.py
222
229
  tests/test_unit_propose.py
223
230
  tests/test_unit_topological_apply.py
231
+ tests/test_validate_repo_path_conflict.py
224
232
  tests/test_version_static.py
225
233
  tests/test_workspace_convert_clone.py
226
234
  tests/test_workspace_edit_lease_cap.py
@@ -11,7 +11,7 @@ name = "gitgrip"
11
11
  # number and it is not derived from Cargo.toml. gr1 releases as crates.io/GitHub
12
12
  # `v1.x.y`; gr2 releases as PyPI `gr2/v2.x.y`. Runtime reads it via
13
13
  # importlib.metadata.version("gitgrip"), never a checked-in file.
14
- version = "2.0.0a4"
14
+ version = "2.0.0a5"
15
15
  description = "Workspace layer for multi-repo work: workspaces, isolated lanes, and one grouped review per slice"
16
16
  readme = "README.md"
17
17
  requires-python = ">=3.11"
@@ -26,6 +26,7 @@ from . import prune as prune_ops
26
26
  from . import target as target_ops
27
27
  from . import project_review
28
28
  from . import push as push_ops
29
+ from .clone_exec import rmtree_or_refuse
29
30
  from .events import EventType, emit_after_outcome
30
31
  from .gitops import (
31
32
  branch_exists,
@@ -58,6 +59,8 @@ from .hooks import (
58
59
  apply_file_projections,
59
60
  load_repo_hooks,
60
61
  run_lifecycle_stage,
62
+ run_materialize_hook_block,
63
+ HookResult,
61
64
  )
62
65
  from .merge_verification import MergeVerificationTarget
63
66
  from .platform import PRRef, get_platform_adapter
@@ -178,8 +181,29 @@ def _materialize_lane_repos(workspace_root: Path, owner_unit: str, lane_name: st
178
181
  for repo_name in lane_doc.get("repos", []):
179
182
  repo_spec = _workspace_repo_spec(workspace_root, repo_name)
180
183
  source_repo_root = (workspace_root / str(repo_spec["path"])).resolve()
181
- if not source_repo_root.exists():
182
- raise SystemExit(f"source repo path does not exist for lane materialization: {source_repo_root}")
184
+ # The state helper decides what the declared path holds, because
185
+ # asking only `.exists()` was the read-through's fifth site: on an
186
+ # adopted superproject the declared path is the EMPTY placeholder,
187
+ # so `materialize_lane_clone` asked the PLACEHOLDER for its origin,
188
+ # git answered for the workspace root, and the provenance check
189
+ # refused the lane ("seeded from <root>, not the declared
190
+ # upstream"). The three answers decide here: a repo root is used
191
+ # as-is; a placeholder is replaced by the unit's materialized copy
192
+ # of that member (the clone materialize placed at its pin); a
193
+ # present path that is neither is refused with the verb that fixes
194
+ # it.
195
+ state = gitops.repo_path_state(source_repo_root)
196
+ if state == "empty_placeholder":
197
+ unit = lane_proto.find_unit_spec(workspace_root, owner_unit)
198
+ unit_member = (workspace_root / str(unit.get("path", "")) / repo_name).resolve()
199
+ if gitops.repo_path_state(unit_member) != "repo_root":
200
+ raise SystemExit(
201
+ f"run gr2 workspace materialize first: the unit's copy of {repo_name} "
202
+ f"is not a repository at {unit_member}"
203
+ )
204
+ source_repo_root = unit_member
205
+ elif state == "neither":
206
+ raise SystemExit(f"run gr2 workspace materialize first: {source_repo_root} is not a repository")
183
207
  target_repo_root = _lane_repo_root(workspace_root, owner_unit, lane_name, repo_name)
184
208
  first_materialize = ensure_lane_checkout(
185
209
  source_repo_root=source_repo_root,
@@ -207,10 +231,8 @@ def _materialize_lane_repos(workspace_root: Path, owner_unit: str, lane_name: st
207
231
  lane_subject=repo_name,
208
232
  lane_name=lane_name,
209
233
  )
210
- apply_file_projections(hooks, ctx)
211
- run_lifecycle_stage(
234
+ run_materialize_hook_block(
212
235
  hooks,
213
- "on_materialize",
214
236
  ctx,
215
237
  repo_dirty=repo_dirty(target_repo_root),
216
238
  first_materialize=first_materialize,
@@ -224,9 +246,15 @@ def _materialize_lane_repos(workspace_root: Path, owner_unit: str, lane_name: st
224
246
  lane_proto.record_fork_base(workspace_root, owner_unit, lane_name, fork_base)
225
247
 
226
248
 
227
- def _run_lane_stage(workspace_root: Path, owner_unit: str, lane_name: str, stage: str, *, manual_hooks: bool = False) -> None:
249
+ def _run_lane_stage(
250
+ workspace_root: Path, owner_unit: str, lane_name: str, stage: str, *, manual_hooks: bool = False
251
+ ) -> list[HookResult]:
252
+ """Run one lifecycle stage across the lane's repos and return every hook
253
+ result, so a caller can record what did not run to completion instead of
254
+ absorbing it into a plain success."""
228
255
  lane_doc = lane_proto.load_lane_doc(workspace_root, owner_unit, lane_name)
229
256
  lane_root = lane_proto.lane_dir(workspace_root, owner_unit, lane_name)
257
+ results: list[HookResult] = []
230
258
  for repo_name in lane_doc.get("repos", []):
231
259
  repo_root = _lane_repo_root(workspace_root, owner_unit, lane_name, repo_name)
232
260
  if not repo_root.exists():
@@ -247,14 +275,27 @@ def _run_lane_stage(workspace_root: Path, owner_unit: str, lane_name: str, stage
247
275
  lane_subject=repo_name,
248
276
  lane_name=lane_name,
249
277
  )
250
- run_lifecycle_stage(
251
- hooks,
252
- stage,
253
- ctx,
254
- repo_dirty=repo_dirty(repo_root),
255
- first_materialize=False,
256
- allow_manual=manual_hooks,
278
+ results.extend(
279
+ run_lifecycle_stage(
280
+ hooks,
281
+ stage,
282
+ ctx,
283
+ repo_dirty=repo_dirty(repo_root),
284
+ first_materialize=False,
285
+ allow_manual=manual_hooks,
286
+ )
257
287
  )
288
+ return results
289
+
290
+
291
+ def _hook_failures_from(results: list) -> list[dict]:
292
+ """The ruled failure record: one entry per hook that ran and exited
293
+ non-zero, naming the hook and its rc."""
294
+ return [
295
+ {"hook": r.name, "returncode": r.returncode}
296
+ for r in results
297
+ if r.returncode is not None and r.returncode != 0
298
+ ]
258
299
 
259
300
 
260
301
  def _prepare_review_branch(workspace_root: Path, repo: str, pr_number: int, branch: str | None) -> str:
@@ -374,7 +415,13 @@ def _merge_verification_targets(
374
415
  if host_repo in targets:
375
416
  raise SystemExit(f"duplicate host repo in merge verification targets: {host_repo}")
376
417
  repo_root = (workspace_root / str(repo_spec.get("path", ""))).resolve()
377
- if not repo_root.is_dir() or not is_git_repo(repo_root):
418
+ # The state helper, not is_git_repo: the read-through let a plain
419
+ # directory at a declared repo path pass this guard as a live
420
+ # merge-verification target (and an empty placeholder through with
421
+ # it), and the DAG collection then operated on a directory that is
422
+ # not a repository. Neither shape is a live target: the DAG is
423
+ # unavailable for both, which is this guard's whole contract.
424
+ if gitops.repo_path_state(repo_root) != "repo_root":
378
425
  raise SystemExit(f"local merge-verification DAG is unavailable: {repo_root}")
379
426
  targets[host_repo] = MergeVerificationTarget(repo_root=repo_root, remote=remote)
380
427
  return targets
@@ -686,10 +733,27 @@ def _exit(code: int) -> None:
686
733
  raise typer.Exit(code=code)
687
734
 
688
735
 
689
- def _consume_lane_transition(outcome: lane_proto.LaneTransitionOutcome | int) -> lane_proto.LaneTransitionOutcome | None:
690
- """Render the state writer's one outcome instead of inferring one in the CLI."""
736
+ def _consume_lane_transition(
737
+ outcome: lane_proto.LaneTransitionOutcome | int,
738
+ hook_failures: list[dict] | None = None,
739
+ ) -> lane_proto.LaneTransitionOutcome | None:
740
+ """Render the state writer's one outcome instead of inferring one in the CLI.
741
+
742
+ A failed warn-tier hook is recorded, not absorbed: when hook_failures is
743
+ non-empty the payload's status is "warned" (the string "ok" appears
744
+ nowhere), hook_failures names the hook and its rc, and the same text goes
745
+ to stderr. Exit code stays 0 — warn is the caller's own declaration."""
691
746
  if isinstance(outcome, lane_proto.LaneTransitionOutcome):
692
- typer.echo(json.dumps(outcome.as_dict(), indent=2))
747
+ payload = outcome.as_dict()
748
+ if hook_failures:
749
+ payload["status"] = "warned"
750
+ payload["hook_failures"] = hook_failures
751
+ rendered = json.dumps(payload, indent=2)
752
+ typer.echo(rendered)
753
+ typer.echo(rendered, err=True)
754
+ else:
755
+ payload["hook_failures"] = []
756
+ typer.echo(json.dumps(payload, indent=2))
693
757
  _exit(outcome.exit_code)
694
758
  return outcome
695
759
  _exit(outcome)
@@ -728,42 +792,112 @@ def sync_run(
728
792
  raise typer.Exit(code=1)
729
793
 
730
794
 
731
- def _superproject_report(
732
- workspace_root: Path, repos: list[dict[str, object]]
733
- ) -> dict[str, int] | None:
795
+ def _require_superproject(workspace_root: Path) -> None:
796
+ """Refuse ``--from-superproject`` on a root whose tree pins no members.
797
+
798
+ The flag's value is that it is the DECLARED entry. The plain verb scans any
799
+ directory of repos and succeeds wherever it finds one, so a user who asked
800
+ for the superproject path and silently got the directory path would have no
801
+ signal that their root is not what they thought it was -- and the two paths
802
+ produce different member SETS, because a declared member that is not
803
+ materialized is still a member while a directory that is not there is not.
804
+
805
+ THE GITLINK IS THE DECLARATION, which is git's own rule and therefore ours.
806
+ An earlier version of this function also accepted a ``.gitmodules`` on its
807
+ own, and that was wrong in both directions of the same run: on a root whose
808
+ members are materialized the verb exited 0 and then printed no superproject
809
+ line at all, and on one whose members are not it failed with "no git repos
810
+ found", a message about the wrong problem. ``git submodule status`` and
811
+ ``git submodule init`` both report zero members on such a root -- measured,
812
+ not assumed -- because a ``.gitmodules`` is a lookup table that the tree's
813
+ 160000 entries are what actually reference.
814
+ """
815
+ if repo_proto.root_gitlink_pins(workspace_root):
816
+ return
817
+ if repo_proto.root_submodule_declarations(workspace_root):
818
+ # Name this case when it applies: a user who has a .gitmodules has
819
+ # reason to believe the root declares members, and the sentence has to
820
+ # say why git disagrees rather than leaving them to find out.
821
+ detail = (
822
+ ", though it carries a .gitmodules: a .gitmodules alone declares nothing, "
823
+ "because git resolves members from the tree's 160000 gitlink entries -- "
824
+ "`git submodule status` and `git submodule init` both report none here"
825
+ )
826
+ else:
827
+ detail = " (no gitlink entries in its tree and no .gitmodules)"
828
+ raise SystemExit(
829
+ "workspace init --from-superproject wants a root whose tree pins members, and "
830
+ f"{workspace_root} pins none{detail}. Run `gr2 workspace init` for a plain "
831
+ "directory of repos, or point this verb at the root of a superproject"
832
+ )
833
+
834
+
835
+ def _superproject_report(workspace_root: Path) -> dict[str, int] | None:
734
836
  """Superproject facts about a root, or None when it is not one.
735
837
 
736
838
  None and ``{"members": 0}`` are deliberately different answers: the first
737
839
  says this root is not a superproject, the second would print a claim about
738
840
  zero members on every workspace.
739
841
 
740
- Membership comes from the predicate and the STATE is looked up beside it,
741
- never the other way round. Reading the member list off the states meant a
742
- state that could not be determined removed its member from the report --
743
- a SHA-256 member's detached HEAD read as nothing under a 40-character test,
744
- and the superproject line disappeared from a root that plainly has one.
842
+ MEMBERSHIP IS THE ROOT'S OWN DECLARATION -- the paths its tree pins with a
843
+ 160000 gitlink -- and the STATE is looked up beside it, never the other way
844
+ round. Three earlier versions of this function each lost or invented members
845
+ by deriving membership from something else, and each one is worth naming
846
+ because the shape recurs:
847
+
848
+ - from the STATES: a SHA-256 member's detached HEAD read as nothing under a
849
+ 40-character test, so the member left the count and the whole line left the
850
+ output; and
851
+ - from a MEMBERSHIP PREDICATE: a DECLARED member that is not MATERIALIZED --
852
+ the ordinary state after `git clone` without ``--recurse-submodules``,
853
+ where the member path is an empty directory -- satisfies no predicate at
854
+ all, because there is no member on disk to satisfy one. The line vanished
855
+ from exactly the root the launch copy leads with; and
856
+ - from the SCAN'S OWN ``pin`` FIELD: ``declared_repo_state`` sets ``pin``
857
+ from the member's HEAD whenever the root pins nothing, so every ordinary
858
+ repo carried a ``pin`` and a plain directory of two repos reported
859
+ ``superproject = true (members: 2 pinned)``. A control caught that one.
860
+
861
+ So the declaration is read HERE, from the root, and a declared member that
862
+ is not on disk is counted and named ``not_materialized``: the root declares
863
+ it, which is what makes it a member, and whether its working tree exists yet
864
+ is an observation about the moment.
745
865
  """
746
- members: list[str] = []
747
- for repo in repos:
748
- path = workspace_root / str(repo["path"])
749
- if not repo_proto.is_submodule_member(path):
750
- continue
751
- members.append(repo_proto.submodule_member_state(path) or "unknown")
752
- if not members:
866
+ pinned = repo_proto.root_gitlink_pins(workspace_root)
867
+ if not pinned:
753
868
  return None
869
+
870
+ states: list[str] = []
871
+ for rel in sorted(pinned):
872
+ path = workspace_root / rel
873
+ if repo_proto.is_submodule_member(path):
874
+ states.append(repo_proto.submodule_member_state(path) or "unknown")
875
+ elif repo_proto.is_own_toplevel(path):
876
+ # Materialized, but as its own clone rather than as a submodule:
877
+ # there is no module directory to read a state from, and calling it
878
+ # "not materialized" would be false about a directory plainly there.
879
+ states.append("unknown")
880
+ else:
881
+ states.append("not_materialized")
754
882
  return {
755
- "members": len(members),
756
- "detached": sum(1 for state in members if state == "detached"),
757
- "unknown": sum(1 for state in members if state == "unknown"),
883
+ "members": len(states),
884
+ "detached": sum(1 for state in states if state == "detached"),
885
+ "unknown": sum(1 for state in states if state == "unknown"),
886
+ "not_materialized": sum(1 for state in states if state == "not_materialized"),
758
887
  }
759
888
 
760
889
 
761
890
  def _superproject_line(report: dict[str, int]) -> str:
762
- return (
891
+ line = (
763
892
  f"superproject = true (members: {report['members']} pinned, "
764
- f"{report['detached']} detached) -- the root is adopted; "
765
- "its branches and worktree are untouched"
893
+ f"{report['detached']} detached"
766
894
  )
895
+ # Named only when it applies, so the ordinary line is unchanged for every
896
+ # caller that already reads it; a root whose members are not checked out yet
897
+ # is the case the extra clause exists for.
898
+ if report.get("not_materialized"):
899
+ line += f", {report['not_materialized']} not materialized"
900
+ return line + ") -- the root is adopted; its branches and worktree are untouched"
767
901
 
768
902
 
769
903
  @workspace_app.command("init")
@@ -771,9 +905,19 @@ def workspace_init(
771
905
  workspace_root: Optional[Path] = typer.Argument(None),
772
906
  default_unit: str = typer.Option("default", help="Default owner unit for scanned repos"),
773
907
  json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
908
+ from_superproject: bool = typer.Option(
909
+ False,
910
+ "--from-superproject",
911
+ help=(
912
+ "Adopt an existing superproject: require that the root declares members, "
913
+ "then read them from .gitmodules and the root's gitlink pins"
914
+ ),
915
+ ),
774
916
  ) -> None:
775
917
  """Create a bare workspace_spec.toml by scanning an existing directory of repos."""
776
918
  workspace_root = (workspace_root or Path.cwd()).resolve()
919
+ if from_superproject:
920
+ _require_superproject(workspace_root)
777
921
  repos = _scan_existing_repos(workspace_root)
778
922
  bare = _scan_bare_repos(workspace_root)
779
923
  if not repos and not bare:
@@ -789,7 +933,7 @@ def workspace_init(
789
933
  # A root that is already a superproject is the entry the launch copy leads
790
934
  # with ("point gr2 at your existing superproject"), and `repo_count` alone
791
935
  # does not say it: the adopted root is the thing, not the member count.
792
- superproject = _superproject_report(workspace_root, repos)
936
+ superproject = _superproject_report(workspace_root)
793
937
  payload = {
794
938
  "workspace_root": str(workspace_root),
795
939
  "spec_path": str(spec_path),
@@ -1704,7 +1848,7 @@ def hooks_trust(
1704
1848
  if hooks is None:
1705
1849
  raise SystemExit(f"no .gr2/hooks.toml found in member: {repo_root}")
1706
1850
  state, record = consent_state(ws, key, repo_root)
1707
- lines = describe_member(ws.resolve(), repo_root, key, hooks)
1851
+ lines, escaped_rows = describe_member(ws.resolve(), repo_root, key, hooks)
1708
1852
  # the screen shows every RESOLVED
1709
1853
  # destination; a row that cannot resolve at trust time is refused, not
1710
1854
  # deferred, and the record does not exist (it binds the whole table by
@@ -1725,6 +1869,17 @@ def hooks_trust(
1725
1869
  f"bound: {key} sha {consent_rec['hooks_sha'][:12]} "
1726
1870
  f"granted_by {consent_rec['granted_by']} at {consent_rec['granted_at']}"
1727
1871
  )
1872
+ # the screen shows, the runtime refuses — a row that carries an ESCAPE
1873
+ # flag will be refused when the hook block runs, even though the record
1874
+ # binds. Saying so here stops a user consenting to a row that can
1875
+ # never apply. The count is per-row (from the screen's flag results),
1876
+ # never a string match over lines the member authored.
1877
+ if escaped_rows:
1878
+ typer.echo(
1879
+ f"warning: {len(escaped_rows)} projection row(s) will be refused at run time "
1880
+ "(escape flags above); fix or remove the row(s) and re-trust "
1881
+ "(the record lapses by hash)"
1882
+ )
1728
1883
 
1729
1884
 
1730
1885
  @hooks_app.command("revoke")
@@ -1873,6 +2028,54 @@ def repo_projection_run(
1873
2028
  typer.echo(json.dumps(payload, indent=2))
1874
2029
 
1875
2030
 
2031
+ def _lane_carries_a_fork_base(workspace_root: Path, owner_unit: str, lane_name: str) -> bool:
2032
+ """True when the lane already carries a recorded fork base.
2033
+
2034
+ The fork base is what separates the two kinds of refusal, and therefore what the
2035
+ removal keys on:
2036
+
2037
+ * A refusal that lands BEFORE it is recorded leaves an ORPHAN. Measured on the
2038
+ refusal path: a missing source made ``lane create`` exit 1 after writing
2039
+ ``lane.toml``, and ``lane enter``, ``lane exit`` and ``exec run`` all accepted the
2040
+ leftover; the failure then surfaced at ``exec run`` as "repos missing", two steps
2041
+ away from the create that refused, with nothing in between pointing back at it.
2042
+ * A refusal that lands AFTER it -- a projection hook that blocks, say -- leaves
2043
+ a RECOVERABLE lane, which must survive WITH its fork base so
2044
+ ``review create-project`` still succeeds on it.
2045
+
2046
+ The fork base is recorded PER REPO, in the materialization loop, so EXISTENCE is not
2047
+ the discriminator: a lane whose later repos never materialized carries a fork base
2048
+ for the repos that did, and it is exactly as unusable as one carrying none --
2049
+ ``lane enter`` accepts it while ``review create-project`` refuses it as not
2050
+ materialized. COVERAGE is the discriminator: every repo the lane document names must
2051
+ have a fork base entry.
2052
+
2053
+ Both refuse and both exit non-zero; only the covered lane is something a verb can use.
2054
+ """
2055
+ try:
2056
+ doc = lane_proto.load_lane_doc(workspace_root, owner_unit, lane_name)
2057
+ except SystemExit:
2058
+ # load_lane_doc refuses a lane that is not on disk; there is nothing to keep,
2059
+ # and nothing to remove either.
2060
+ return False
2061
+ fork_base = doc.get("fork_base") or {}
2062
+ repos = doc.get("repos") or []
2063
+ return bool(fork_base) and set(repos) <= set(fork_base)
2064
+
2065
+
2066
+ def _remove_lane_artifacts(workspace_root: Path, owner_unit: str, lane_name: str) -> None:
2067
+ """Remove the lane directory a REFUSED create wrote, when nothing in it is usable.
2068
+
2069
+ A refusal that never reached a fork base must leave no lane that any verb accepts,
2070
+ and the caller checks that precondition before calling this. Removal goes through
2071
+ ``rmtree_or_refuse`` so a partial cleanup raises instead of reporting success: a lane
2072
+ half-removed is the same defect as a lane never removed.
2073
+ """
2074
+ lane_root = lane_proto.lane_dir(workspace_root, owner_unit, lane_name)
2075
+ if lane_root.exists():
2076
+ rmtree_or_refuse(lane_root)
2077
+
2078
+
1876
2079
  @lane_app.command("create")
1877
2080
  def lane_create(
1878
2081
  workspace_root: Path,
@@ -1913,7 +2116,36 @@ def lane_create(
1913
2116
  # event comes from the lane document create_lane just wrote (derived from the
1914
2117
  # bound worktree), not from the --branch arg, which --bind ignores.
1915
2118
  if bind is None:
1916
- _materialize_lane_repos(workspace_root, owner_unit, lane_name, manual_hooks=manual_hooks)
2119
+ try:
2120
+ _materialize_lane_repos(workspace_root, owner_unit, lane_name, manual_hooks=manual_hooks)
2121
+ except BaseException:
2122
+ # BaseException and not Exception, deliberately: the refusal this guards
2123
+ # against is a SystemExit, which is not an Exception and would sail past a
2124
+ # narrower clause, leaving exactly the orphan this exists to prevent.
2125
+ #
2126
+ # ...but only when the refusal left nothing usable. A blocked projection hook
2127
+ # refuses AFTER the fork base is recorded, and that lane is recoverable by
2128
+ # design -- `review create-project` must still succeed on it -- so the fork
2129
+ # base, not the position of the raise, decides.
2130
+ if not _lane_carries_a_fork_base(workspace_root, owner_unit, lane_name):
2131
+ _remove_lane_artifacts(workspace_root, owner_unit, lane_name)
2132
+ else:
2133
+ # The kept path must be LEGIBLE, not silent. A user who sees "create
2134
+ # failed" and later finds the lane on disk would otherwise read it as the
2135
+ # very orphan this change exists to prevent. So the message says the lane
2136
+ # was KEPT, why it is recoverable, and both ways forward. It names no
2137
+ # removal verb because none exists: `lane` has create/enter/resolve/exit/
2138
+ # current/bind and nothing that removes one, and a message that names a
2139
+ # command a user cannot run is worse than one that names the path.
2140
+ lane_root = lane_proto.lane_dir(workspace_root, owner_unit, lane_name)
2141
+ typer.echo(
2142
+ f"lane create: the lane {owner_unit}/{lane_name} was KEPT because its fork "
2143
+ f"base is recorded, so it is recoverable.\n"
2144
+ f" continue with it: gr2 review create-project {workspace_root} {owner_unit} {lane_name}\n"
2145
+ f" remove it: delete {lane_root} (no lane-removal verb exists yet)",
2146
+ err=True,
2147
+ )
2148
+ raise
1917
2149
  repo_list = [r.strip() for r in repos.split(",")]
1918
2150
  # The event payload carries lane_kind (and bound_worktree for a bound lane)
1919
2151
  # so an event-stream consumer can tell a bound lane from a materialized one
@@ -1979,7 +2211,7 @@ def lane_enter(
1979
2211
  )
1980
2212
  raise typer.Exit(code=1)
1981
2213
  try:
1982
- _run_lane_stage(workspace_root, owner_unit, lane_name, "on_enter", manual_hooks=manual_hooks)
2214
+ enter_results = _run_lane_stage(workspace_root, owner_unit, lane_name, "on_enter", manual_hooks=manual_hooks)
1983
2215
  except HookRuntimeError as exc:
1984
2216
  payload = exc.payload
1985
2217
  repo_name = Path(str(payload.get("cwd", ""))).name or lane_name
@@ -2004,7 +2236,9 @@ def lane_enter(
2004
2236
  notify_channel=notify_channel,
2005
2237
  recall=recall,
2006
2238
  )
2007
- outcome = _consume_lane_transition(lane_proto.enter_lane(ns))
2239
+ outcome = _consume_lane_transition(
2240
+ lane_proto.enter_lane(ns), _hook_failures_from(enter_results)
2241
+ )
2008
2242
  lane_doc = lane_proto.load_lane_doc(workspace_root, owner_unit, lane_name)
2009
2243
  emit_after_outcome(
2010
2244
  event_type=EventType.LANE_ENTERED,
@@ -2063,7 +2297,7 @@ def lane_exit(
2063
2297
  if repo_root.exists():
2064
2298
  if stash_if_dirty(repo_root, f"gr2 exit {owner_unit}/{lane_name}"):
2065
2299
  stashed_repos.append(repo_name)
2066
- _run_lane_stage(workspace_root, owner_unit, lane_name, "on_exit", manual_hooks=manual_hooks)
2300
+ exit_results = _run_lane_stage(workspace_root, owner_unit, lane_name, "on_exit", manual_hooks=manual_hooks)
2067
2301
  ns = SimpleNamespace(
2068
2302
  workspace_root=workspace_root,
2069
2303
  owner_unit=owner_unit,
@@ -2071,7 +2305,9 @@ def lane_exit(
2071
2305
  notify_channel=notify_channel,
2072
2306
  recall=recall,
2073
2307
  )
2074
- outcome = _consume_lane_transition(lane_proto.exit_lane(ns))
2308
+ outcome = _consume_lane_transition(
2309
+ lane_proto.exit_lane(ns), _hook_failures_from(exit_results)
2310
+ )
2075
2311
  emit_after_outcome(
2076
2312
  event_type=EventType.LANE_EXITED,
2077
2313
  workspace_root=workspace_root,
@@ -2450,6 +2686,17 @@ def review_close(
2450
2686
  )
2451
2687
 
2452
2688
 
2689
+ def _default_pr_group_body(owner_unit: str, lane_name: str, repos: list[str]) -> str:
2690
+ """The body every PR in a group gets when the caller passes none.
2691
+
2692
+ It names the group AND lists its member repos, so a reviewer who lands on one PR
2693
+ can see the set it belongs to. A body naming only the lane is invisible in exactly
2694
+ that way: every PR in the set reads identically, and none points at another.
2695
+ """
2696
+ members = "\n".join(f"- {repo}" for repo in repos)
2697
+ return f"gr2 PR group for {owner_unit}/{lane_name}\n\nRepos in this group:\n{members}\n"
2698
+
2699
+
2453
2700
  @pr_app.command("create")
2454
2701
  def pr_create(
2455
2702
  workspace_root: Path,
@@ -2458,6 +2705,9 @@ def pr_create(
2458
2705
  platform: str = typer.Option("github", "--platform", help="Platform adapter name"),
2459
2706
  base_branch: str = typer.Option("main", "--base", help="Base branch for created PRs"),
2460
2707
  draft: bool = typer.Option(False, "--draft", help="Create PRs as drafts"),
2708
+ title: Optional[str] = typer.Option(None, "--title", help="Title for every PR in the group. Defaults to the lane name, which makes every PR in a set read identically; pass one when a reviewer must be able to tell the PRs apart."),
2709
+ body: Optional[str] = typer.Option(None, "--body", help="Body for every PR in the group. Defaults to a line naming the group and listing its repos."),
2710
+ body_file: Optional[Path] = typer.Option(None, "--body-file", help="Read the group body from a file. Use this for anything long or shell-sensitive: the body is passed to gh through a file, so quoting is not the caller's problem."),
2461
2711
  json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
2462
2712
  ) -> None:
2463
2713
  """Create a grouped set of per-repo PRs for a lane."""
@@ -2487,19 +2737,36 @@ def pr_create(
2487
2737
  for repo_name in lane_doc.get("repos", []):
2488
2738
  repo_spec = next(repo for repo in spec.get("repos", []) if repo.get("name") == repo_name)
2489
2739
  repos.append(_repo_slug_from_url(str(repo_spec.get("url", "")), repo_name))
2490
- payload = pr_ops.create_pr_group(
2491
- workspace_root=workspace_root,
2492
- owner_unit=owner_unit,
2493
- lane_name=resolved_lane,
2494
- title=resolved_lane,
2495
- base_branch=base_branch,
2496
- head_branch=str(branch_map.get(next(iter(lane_doc.get("repos", [])), resolved_lane), resolved_lane)),
2497
- repos=repos,
2498
- adapter=adapter,
2499
- actor=f"agent:{owner_unit}",
2500
- body=f"gr2 PR group for {owner_unit}/{resolved_lane}",
2501
- draft=draft,
2502
- )
2740
+ if body is not None and body_file is not None:
2741
+ typer.echo("pass one of --body or --body-file, not both", err=True)
2742
+ raise typer.Exit(code=2)
2743
+ group_body = body
2744
+ if body_file is not None:
2745
+ try:
2746
+ group_body = body_file.read_text(encoding="utf-8")
2747
+ except OSError as exc:
2748
+ typer.echo(f"cannot read --body-file {body_file}: {exc}", err=True)
2749
+ raise typer.Exit(code=2)
2750
+ try:
2751
+ payload = pr_ops.create_pr_group(
2752
+ workspace_root=workspace_root,
2753
+ owner_unit=owner_unit,
2754
+ lane_name=resolved_lane,
2755
+ title=title or resolved_lane,
2756
+ base_branch=base_branch,
2757
+ head_branch=str(branch_map.get(next(iter(lane_doc.get("repos", [])), resolved_lane), resolved_lane)),
2758
+ repos=repos,
2759
+ adapter=adapter,
2760
+ actor=f"agent:{owner_unit}",
2761
+ body=group_body or _default_pr_group_body(owner_unit, resolved_lane, repos),
2762
+ draft=draft,
2763
+ )
2764
+ except pr_ops.SiblingLinkError as exc:
2765
+ # The group is already persisted, so print it and fail: a half-linked set that
2766
+ # exits 0 is the failure mode this whole path exists to stop.
2767
+ typer.echo(json.dumps(exc.group, indent=2))
2768
+ typer.echo(str(exc), err=True)
2769
+ raise typer.Exit(code=1)
2503
2770
  if json_output:
2504
2771
  typer.echo(json.dumps(payload, indent=2))
2505
2772
  else:
@@ -2743,7 +3010,7 @@ def _normalize_review_row(raw: object) -> dict:
2743
3010
  def review_bind(
2744
3011
  workspace_root: Optional[Path] = typer.Argument(None),
2745
3012
  key: Optional[str] = typer.Option(None, "--repo", help="Repository key for a single bound row"),
2746
- remote: Optional[str] = typer.Option(None, "--remote", help="Remote URL or path of the row"),
3013
+ remote: Optional[str] = typer.Option(None, "--remote", help="Remote URL or absolute path of the row (relative paths and remote names are not resolved from your shell's directory)"),
2747
3014
  base: Optional[str] = typer.Option(None, "--base", help="Base SHA (must be the live remote head of --ref)"),
2748
3015
  head: Optional[str] = typer.Option(None, "--head", help="Reviewed head SHA (the pre-push head under review)"),
2749
3016
  ref: str = typer.Option("refs/heads/dev", "--ref", help="Target ref whose live head must equal --base"),