gitgrip 2.0.0a2__tar.gz → 2.0.0a3__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 (227) hide show
  1. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/PKG-INFO +12 -3
  2. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/README.md +11 -2
  3. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/gitgrip.egg-info/PKG-INFO +12 -3
  4. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/gitgrip.egg-info/SOURCES.txt +6 -0
  5. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/prototypes/lane_workspace_prototype.py +99 -0
  6. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/prototypes/repo_maintenance_prototype.py +355 -7
  7. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/pyproject.toml +2 -2
  8. gitgrip-2.0.0a3/python_cli/__main__.py +8 -0
  9. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/python_cli/app.py +38 -9
  10. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/python_cli/gitops.py +21 -1
  11. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/python_cli/grip.py +3 -1
  12. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/python_cli/grip_cli.py +5 -1
  13. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/python_cli/migration.py +29 -0
  14. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/python_cli/syncops.py +6 -1
  15. gitgrip-2.0.0a3/python_cli/workspace_guidance.py +22 -0
  16. gitgrip-2.0.0a3/tests/test_cli_adapter_error_boundary.py +226 -0
  17. gitgrip-2.0.0a3/tests/test_first_five_minutes.py +56 -0
  18. gitgrip-2.0.0a3/tests/test_gr1_first_run_guidance.py +80 -0
  19. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_lane_create_fork_base_cli.py +7 -7
  20. gitgrip-2.0.0a3/tests/test_lane_create_refuses_own_branch.py +231 -0
  21. gitgrip-2.0.0a3/tests/test_migration_unadopted_notice.py +122 -0
  22. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_review_cli.py +1 -1
  23. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_version_static.py +2 -2
  24. gitgrip-2.0.0a3/tests/test_workspace_convert_clone.py +603 -0
  25. gitgrip-2.0.0a2/python_cli/__main__.py +0 -6
  26. gitgrip-2.0.0a2/tests/test_workspace_convert_clone.py +0 -211
  27. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/gitgrip.egg-info/dependency_links.txt +0 -0
  28. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/gitgrip.egg-info/entry_points.txt +0 -0
  29. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/gitgrip.egg-info/requires.txt +0 -0
  30. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/gitgrip.egg-info/top_level.txt +0 -0
  31. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/gr2/__init__.py +0 -0
  32. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/gr2/overlay/__init__.py +0 -0
  33. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/gr2/overlay/activate.py +0 -0
  34. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/gr2/overlay/agent_manifest.py +0 -0
  35. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/gr2/overlay/cli.py +0 -0
  36. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/gr2/overlay/cross_repo.py +0 -0
  37. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/gr2/overlay/drivers.py +0 -0
  38. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/gr2/overlay/introspection.py +0 -0
  39. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/gr2/overlay/language_drivers.py +0 -0
  40. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/gr2/overlay/objects.py +0 -0
  41. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/gr2/overlay/perf.py +0 -0
  42. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/gr2/overlay/refs.py +0 -0
  43. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/gr2/overlay/trust.py +0 -0
  44. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/gr2/overlay/types.py +0 -0
  45. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/gr2/overlay/units.py +0 -0
  46. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/gr2/overlay/workspace_spec.py +0 -0
  47. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/gr2/schemas/gr2-materialization-plan-v1.schema.json +0 -0
  48. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/gr2_overlay/__init__.py +0 -0
  49. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/prototypes/__init__.py +0 -0
  50. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/prototypes/cache_materialization_probe.py +0 -0
  51. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/prototypes/concurrent_event_stress.py +0 -0
  52. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/prototypes/concurrent_lease_stress.py +0 -0
  53. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/prototypes/concurrent_workspace_cap_stress.py +0 -0
  54. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/prototypes/contribution_protocol.py +0 -0
  55. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/prototypes/cross_mode_lane_stress.py +0 -0
  56. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/prototypes/jsonl_store.py +0 -0
  57. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/prototypes/layout_model_probe.py +0 -0
  58. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/prototypes/propagation_daemon.py +0 -0
  59. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/prototypes/propagation_state_machine.py +0 -0
  60. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/prototypes/python_exec_playground.py +0 -0
  61. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/prototypes/python_hook_runtime_playground.py +0 -0
  62. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/prototypes/python_migration_playground.py +0 -0
  63. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/prototypes/python_review_checkout_playground.py +0 -0
  64. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/prototypes/python_spec_apply_playground.py +0 -0
  65. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/prototypes/real_git_lane_materialization.py +0 -0
  66. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/prototypes/real_git_playground.py +0 -0
  67. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/prototypes/recall_lane_history.py +0 -0
  68. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/prototypes/repo_transport_probe.py +0 -0
  69. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/python_cli/__init__.py +0 -0
  70. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/python_cli/add.py +0 -0
  71. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/python_cli/branch.py +0 -0
  72. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/python_cli/channel_bridge.py +0 -0
  73. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/python_cli/clone_exec.py +0 -0
  74. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/python_cli/commit.py +0 -0
  75. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/python_cli/config.py +0 -0
  76. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/python_cli/env_exec.py +0 -0
  77. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/python_cli/events.py +0 -0
  78. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/python_cli/execops.py +0 -0
  79. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/python_cli/failures.py +0 -0
  80. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/python_cli/file_exec.py +0 -0
  81. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/python_cli/git_review.py +0 -0
  82. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/python_cli/hooks.py +0 -0
  83. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/python_cli/launch_exec.py +0 -0
  84. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/python_cli/merge_verification.py +0 -0
  85. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/python_cli/open_gr_review.py +0 -0
  86. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/python_cli/platform.py +0 -0
  87. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/python_cli/pr.py +0 -0
  88. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/python_cli/project_review.py +0 -0
  89. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/python_cli/prune.py +0 -0
  90. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/python_cli/push.py +0 -0
  91. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/python_cli/review.py +0 -0
  92. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/python_cli/review_dispatch.py +0 -0
  93. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/python_cli/review_ephemeral.py +0 -0
  94. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/python_cli/review_rebind.py +0 -0
  95. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/python_cli/review_run.py +0 -0
  96. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/python_cli/review_runners.py +0 -0
  97. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/python_cli/spec_apply.py +0 -0
  98. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/python_cli/staging_cleanup.py +0 -0
  99. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/python_cli/target.py +0 -0
  100. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/python_cli/workspace_snapshot.py +0 -0
  101. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/setup.cfg +0 -0
  102. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_add.py +0 -0
  103. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_agent_manifest_overlay_source.py +0 -0
  104. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_app_event_outcomes.py +0 -0
  105. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_apply_convergence.py +0 -0
  106. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_branch.py +0 -0
  107. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_channel_bridge.py +0 -0
  108. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_clone_materialization.py +0 -0
  109. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_commit.py +0 -0
  110. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_config_base_path_guard.py +0 -0
  111. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_config_overlay.py +0 -0
  112. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_conftest_git_isolation.py +0 -0
  113. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_contribution_protocol.py +0 -0
  114. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_ensure_lane_checkout_reference_clone.py +0 -0
  115. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_env_materialization.py +0 -0
  116. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_event_log_integrity.py +0 -0
  117. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_event_outcome_policy.py +0 -0
  118. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_events.py +0 -0
  119. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_events_torn_line.py +0 -0
  120. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_exec_apostrophe_and_oserror.py +0 -0
  121. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_exec_single_string_and_materialize_plan.py +0 -0
  122. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_execops.py +0 -0
  123. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_failure_event_outcome.py +0 -0
  124. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_foreground_launch_runtime.py +0 -0
  125. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_git_review.py +0 -0
  126. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_gr2_packaging.py +0 -0
  127. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_gr2_review_receipt.py +0 -0
  128. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_grip_cli.py +0 -0
  129. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_grip_hardening.py +0 -0
  130. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_grip_object_model.py +0 -0
  131. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_grip_snapshot.py +0 -0
  132. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_hook_events.py +0 -0
  133. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_hook_hardening.py +0 -0
  134. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_jsonl_store.py +0 -0
  135. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_lane_lease_show_json.py +0 -0
  136. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_lane_lifecycle_safety.py +0 -0
  137. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_lane_repo_paths_and_all_skipped.py +0 -0
  138. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_lane_toml_serialization.py +0 -0
  139. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_launch_primitive.py +0 -0
  140. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_m2_overlay_hindsight.py +0 -0
  141. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_m2_overlay_mem0.py +0 -0
  142. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_m2_overlay_zep.py +0 -0
  143. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_materialization_plan_contract.py +0 -0
  144. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_merge_evidence.py +0 -0
  145. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_merge_parent_verification.py +0 -0
  146. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_migration.py +0 -0
  147. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_migration_overlay_compatibility.py +0 -0
  148. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_no_bare_python_cli_imports.py +0 -0
  149. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_null_current_lane_refusal.py +0 -0
  150. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_open_gr_enter.py +0 -0
  151. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_open_project_refusal_reason_cli.py +0 -0
  152. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_overlay_acceptance.py +0 -0
  153. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_overlay_activate.py +0 -0
  154. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_overlay_cli.py +0 -0
  155. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_overlay_cli_wiring.py +0 -0
  156. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_overlay_composition_merge.py +0 -0
  157. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_overlay_cross_repo_atomic_apply.py +0 -0
  158. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_overlay_cross_repo_perf_gate.py +0 -0
  159. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_overlay_cross_repo_rollback.py +0 -0
  160. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_overlay_driver_registry.py +0 -0
  161. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_overlay_introspection.py +0 -0
  162. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_overlay_multi_stack.py +0 -0
  163. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_overlay_object_encoding.py +0 -0
  164. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_overlay_perf_gates.py +0 -0
  165. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_overlay_python_driver.py +0 -0
  166. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_overlay_tier_b_encoding.py +0 -0
  167. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_overlay_tier_b_perf_gates.py +0 -0
  168. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_overlay_tier_b_repo_state.py +0 -0
  169. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_overlay_tier_b_staging_discipline.py +0 -0
  170. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_overlay_trust_model.py +0 -0
  171. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_overlay_workspace_spec.py +0 -0
  172. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_pr_events.py +0 -0
  173. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_pr_merge_method.py +0 -0
  174. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_pr_merge_partial_failure.py +0 -0
  175. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_project_file_materialization.py +0 -0
  176. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_project_review_range_reconstruction.py +0 -0
  177. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_project_review_three_repo.py +0 -0
  178. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_projection_tracked_dest.py +0 -0
  179. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_propagation_contribution.py +0 -0
  180. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_propagation_daemon.py +0 -0
  181. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_propagation_state_machine.py +0 -0
  182. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_prune.py +0 -0
  183. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_push.py +0 -0
  184. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_readme_verbs_exist.py +0 -0
  185. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_repo_status_linked_worktree.py +0 -0
  186. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_repo_status_schema_version.py +0 -0
  187. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_repo_status_symlinked_git_dir.py +0 -0
  188. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_review_bind_fork_base.py +0 -0
  189. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_review_bind_from_range.py +0 -0
  190. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_review_bind_verify.py +0 -0
  191. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_review_close_gr.py +0 -0
  192. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_review_create_project_cli.py +0 -0
  193. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_review_dispatch.py +0 -0
  194. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_review_ephemeral.py +0 -0
  195. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_review_open_lane.py +0 -0
  196. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_review_rebind.py +0 -0
  197. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_review_run.py +0 -0
  198. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_review_run_import_isolation.py +0 -0
  199. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_review_run_rerun_and_refusal_receipt.py +0 -0
  200. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_review_runners.py +0 -0
  201. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_review_verify_inside.py +0 -0
  202. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_rmtree_or_refuse.py +0 -0
  203. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_single_repo_cwd_refusal.py +0 -0
  204. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_snapshot_id_prefix_resolution.py +0 -0
  205. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_spec_apply_event_outcomes.py +0 -0
  206. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_sprint21_sync_platform.py +0 -0
  207. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_staging_cleanup.py +0 -0
  208. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_store_verb_rename.py +0 -0
  209. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_sync_event_outcomes.py +0 -0
  210. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_sync_fast_forward.py +0 -0
  211. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_sync_fetch.py +0 -0
  212. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_sync_seed_local.py +0 -0
  213. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_sync_ssh_rewrite.py +0 -0
  214. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_sync_status_probe.py +0 -0
  215. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_target.py +0 -0
  216. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_tmux_launch_runtime.py +0 -0
  217. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_unit_abort.py +0 -0
  218. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_unit_apply.py +0 -0
  219. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_unit_apply_dry_run.py +0 -0
  220. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_unit_manifest_schema.py +0 -0
  221. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_unit_propose.py +0 -0
  222. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_unit_topological_apply.py +0 -0
  223. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_workspace_edit_lease_cap.py +0 -0
  224. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_workspace_identity_boundary.py +0 -0
  225. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_workspace_init_from_topology.py +0 -0
  226. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/tests/test_workspace_init_local_repos.py +0 -0
  227. {gitgrip-2.0.0a2 → gitgrip-2.0.0a3}/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.0a2
3
+ Version: 2.0.0a3
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
@@ -19,13 +19,22 @@ Requires-Dist: ruff>=0.4; extra == "dev"
19
19
 
20
20
  ## Install
21
21
 
22
+ ```bash
23
+ uv tool install --pre gitgrip # the PyPI package is "gitgrip"; the command is "gr2"
24
+ gr2 --version
25
+ ```
26
+
27
+ Without uv, a venv and pip do the same:
28
+
22
29
  ```bash
23
30
  python3 -m venv ~/gr2-venv && source ~/gr2-venv/bin/activate
24
- pip install --pre gitgrip # the PyPI package is "gitgrip"; the command is "gr2"
31
+ pip install --pre gitgrip
25
32
  gr2 --version
26
33
  ```
27
34
 
28
- `--pre` is required while 2.x is an alpha — without it pip installs 1.5.1, which is gr1. To run unreleased development code instead, install from a checkout: `pip install -e gr2/` from the repository root.
35
+ `--pre` is required while every gr2 release is an alpha: 1.5.0 and 1.5.1 were pre-alpha builds and are yanked, so without `--pre` there is nothing to install.
36
+
37
+ Already installed 1.5.0 or 1.5.1? The install commands above leave it in place, because an installed version already satisfies them. Upgrade explicitly: `uv tool upgrade --prerelease allow gitgrip`, or `pip install --pre -U gitgrip`. To run unreleased development code instead, install from a checkout: `pip install -e gr2/` from the repository root.
29
38
 
30
39
  The Rust `gr` (gr1, 1.x) installs with `brew install synapt-dev/tap/gitgrip` or `cargo install gitgrip`. The two do not collide; you can have both installed.
31
40
 
@@ -4,13 +4,22 @@
4
4
 
5
5
  ## Install
6
6
 
7
+ ```bash
8
+ uv tool install --pre gitgrip # the PyPI package is "gitgrip"; the command is "gr2"
9
+ gr2 --version
10
+ ```
11
+
12
+ Without uv, a venv and pip do the same:
13
+
7
14
  ```bash
8
15
  python3 -m venv ~/gr2-venv && source ~/gr2-venv/bin/activate
9
- pip install --pre gitgrip # the PyPI package is "gitgrip"; the command is "gr2"
16
+ pip install --pre gitgrip
10
17
  gr2 --version
11
18
  ```
12
19
 
13
- `--pre` is required while 2.x is an alpha — without it pip installs 1.5.1, which is gr1. To run unreleased development code instead, install from a checkout: `pip install -e gr2/` from the repository root.
20
+ `--pre` is required while every gr2 release is an alpha: 1.5.0 and 1.5.1 were pre-alpha builds and are yanked, so without `--pre` there is nothing to install.
21
+
22
+ Already installed 1.5.0 or 1.5.1? The install commands above leave it in place, because an installed version already satisfies them. Upgrade explicitly: `uv tool upgrade --prerelease allow gitgrip`, or `pip install --pre -U gitgrip`. To run unreleased development code instead, install from a checkout: `pip install -e gr2/` from the repository root.
14
23
 
15
24
  The Rust `gr` (gr1, 1.x) installs with `brew install synapt-dev/tap/gitgrip` or `cargo install gitgrip`. The two do not collide; you can have both installed.
16
25
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitgrip
3
- Version: 2.0.0a2
3
+ Version: 2.0.0a3
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
@@ -19,13 +19,22 @@ Requires-Dist: ruff>=0.4; extra == "dev"
19
19
 
20
20
  ## Install
21
21
 
22
+ ```bash
23
+ uv tool install --pre gitgrip # the PyPI package is "gitgrip"; the command is "gr2"
24
+ gr2 --version
25
+ ```
26
+
27
+ Without uv, a venv and pip do the same:
28
+
22
29
  ```bash
23
30
  python3 -m venv ~/gr2-venv && source ~/gr2-venv/bin/activate
24
- pip install --pre gitgrip # the PyPI package is "gitgrip"; the command is "gr2"
31
+ pip install --pre gitgrip
25
32
  gr2 --version
26
33
  ```
27
34
 
28
- `--pre` is required while 2.x is an alpha — without it pip installs 1.5.1, which is gr1. To run unreleased development code instead, install from a checkout: `pip install -e gr2/` from the repository root.
35
+ `--pre` is required while every gr2 release is an alpha: 1.5.0 and 1.5.1 were pre-alpha builds and are yanked, so without `--pre` there is nothing to install.
36
+
37
+ Already installed 1.5.0 or 1.5.1? The install commands above leave it in place, because an installed version already satisfies them. Upgrade explicitly: `uv tool upgrade --prerelease allow gitgrip`, or `pip install --pre -U gitgrip`. To run unreleased development code instead, install from a checkout: `pip install -e gr2/` from the repository root.
29
38
 
30
39
  The Rust `gr` (gr1, 1.x) installs with `brew install synapt-dev/tap/gitgrip` or `cargo install gitgrip`. The two do not collide; you can have both installed.
31
40
 
@@ -84,6 +84,7 @@ python_cli/spec_apply.py
84
84
  python_cli/staging_cleanup.py
85
85
  python_cli/syncops.py
86
86
  python_cli/target.py
87
+ python_cli/workspace_guidance.py
87
88
  python_cli/workspace_snapshot.py
88
89
  tests/test_add.py
89
90
  tests/test_agent_manifest_overlay_source.py
@@ -91,6 +92,7 @@ tests/test_app_event_outcomes.py
91
92
  tests/test_apply_convergence.py
92
93
  tests/test_branch.py
93
94
  tests/test_channel_bridge.py
95
+ tests/test_cli_adapter_error_boundary.py
94
96
  tests/test_clone_materialization.py
95
97
  tests/test_commit.py
96
98
  tests/test_config_base_path_guard.py
@@ -107,8 +109,10 @@ tests/test_exec_apostrophe_and_oserror.py
107
109
  tests/test_exec_single_string_and_materialize_plan.py
108
110
  tests/test_execops.py
109
111
  tests/test_failure_event_outcome.py
112
+ tests/test_first_five_minutes.py
110
113
  tests/test_foreground_launch_runtime.py
111
114
  tests/test_git_review.py
115
+ tests/test_gr1_first_run_guidance.py
112
116
  tests/test_gr2_packaging.py
113
117
  tests/test_gr2_review_receipt.py
114
118
  tests/test_grip_cli.py
@@ -119,6 +123,7 @@ tests/test_hook_events.py
119
123
  tests/test_hook_hardening.py
120
124
  tests/test_jsonl_store.py
121
125
  tests/test_lane_create_fork_base_cli.py
126
+ tests/test_lane_create_refuses_own_branch.py
122
127
  tests/test_lane_lease_show_json.py
123
128
  tests/test_lane_lifecycle_safety.py
124
129
  tests/test_lane_repo_paths_and_all_skipped.py
@@ -132,6 +137,7 @@ tests/test_merge_evidence.py
132
137
  tests/test_merge_parent_verification.py
133
138
  tests/test_migration.py
134
139
  tests/test_migration_overlay_compatibility.py
140
+ tests/test_migration_unadopted_notice.py
135
141
  tests/test_no_bare_python_cli_imports.py
136
142
  tests/test_null_current_lane_refusal.py
137
143
  tests/test_open_gr_enter.py
@@ -923,6 +923,97 @@ def _validate_bound_worktree(bind_path: Path, workspace_root: Path) -> tuple[str
923
923
  return head, branch
924
924
 
925
925
 
926
+ #: How long the origin may take to report its default branch before the answer is
927
+ #: "cannot tell". Generous for a symref read, and short enough that an origin which
928
+ #: accepts and never replies cannot block `lane create` with no output. Without it,
929
+ #: an offline laptop or a blackholed origin hangs the verb where it used to create a
930
+ #: lane without any network call at all.
931
+ _LS_REMOTE_TIMEOUT_SECONDS = 5.0
932
+
933
+
934
+ def _origin_default_branch(repo_path: Path, url: str | None = None) -> str | None:
935
+ """The repo's integration branch, or None when it cannot be determined.
936
+
937
+ A clone records it as `origin/HEAD`. That symref is ABSENT in a clone made
938
+ before the remote had any refs — which is the normal state of a repo cloned
939
+ empty and pushed later, and it is exactly the state in which a lane created on
940
+ the default branch looks harmless. So when the local record is missing, ask the
941
+ origin itself (`ls-remote --symref`), which is the same remote the lane is about
942
+ to clone from anyway. A remote that cannot be reached gives None: "cannot tell"
943
+ is never allowed to become a refusal. Unreachable includes UNANSWERED — the call
944
+ is bounded, and an expiry is None on the same channel as any other git failure
945
+ rather than an exception the caller has to remember to catch separately.
946
+ """
947
+ proc = gitops.git(repo_path, "rev-parse", "--abbrev-ref", "origin/HEAD")
948
+ if proc.returncode == 0:
949
+ ref = proc.stdout.strip()
950
+ if ref.startswith("origin/"):
951
+ return ref.split("/", 1)[1]
952
+ if not url:
953
+ return None
954
+ remote = gitops.git(
955
+ repo_path, "ls-remote", "--symref", url, "HEAD", timeout=_LS_REMOTE_TIMEOUT_SECONDS
956
+ )
957
+ if remote.returncode != 0:
958
+ # A git that never returned is the same answer as a git that refused:
959
+ # "cannot tell". The bound above is what makes that reachable at all — an
960
+ # origin that accepts and says nothing used to block the verb outright.
961
+ return None
962
+ for line in remote.stdout.splitlines():
963
+ if line.startswith("ref:") and line.rstrip().endswith("HEAD"):
964
+ ref = line.split()[1]
965
+ # Strip the PREFIX, never split on every slash: a default branch whose
966
+ # name contains one (`release/1.x`) came back as its last segment, so the
967
+ # guard compared the requested branch against a string the origin never
968
+ # reported and the no-isolation lane was created in silence.
969
+ if not ref.startswith("refs/heads/"):
970
+ return None
971
+ return ref.removeprefix("refs/heads/")
972
+ return None
973
+
974
+
975
+ def _lane_branch_refusal(workspace_root: Path, spec: dict, repo: str, branch: str) -> str | None:
976
+ """The one sentence that refuses a lane branch with no isolation, or None.
977
+
978
+ A lane is the isolation primitive. A lane whose branch IS the repository's own
979
+ branch has no isolation at all: work committed there and pushed with `gr2 push`
980
+ lands on the branch the lane was supposed to be forked from, and the first
981
+ refusal arrived at `pr create` — after the remote had already been written
982
+ (measured on the released 2.0.0a2: `lane create --branch main`, push exit 0
983
+ "Pushed main", then `pr create` exit 1 "head branch main is the same as base
984
+ branch main"). Nothing refused at the point the choice was made.
985
+
986
+ The comparison is against what is CHECKED OUT here and what the origin says its
987
+ default is. A repo with no local checkout, or one whose origin cannot be
988
+ reached, is skipped rather than refused: this guard may not invent a conflict it
989
+ cannot see.
990
+ """
991
+ entry = next((row for row in spec.get("repos", []) if row.get("name") == repo), None)
992
+ if entry is None:
993
+ return None
994
+ rel = entry.get("path")
995
+ if not rel:
996
+ return None
997
+ checkout = workspace_root / str(rel)
998
+ if not gitops.is_git_repo(checkout):
999
+ return None
1000
+ matched: str | None = None
1001
+ if branch == gitops.current_branch(checkout):
1002
+ matched = f"the branch checked out at {rel}"
1003
+ else:
1004
+ default = _origin_default_branch(checkout, str(entry.get("url") or "") or None)
1005
+ if default and branch == default:
1006
+ matched = f"the repo's integration branch ({default})"
1007
+ if matched is None:
1008
+ return None
1009
+ return (
1010
+ f"lane branch '{branch}' for repo '{repo}' is {matched}: a lane forked from the branch it will "
1011
+ f"land on has no isolation, and a push would go straight to '{branch}'. Pass a lane branch that is "
1012
+ f"neither the repo's checked-out nor its integration branch (e.g. feat/<name>), or use --bind to "
1013
+ f"label an existing worktree."
1014
+ )
1015
+
1016
+
926
1017
  def create_lane(args: argparse.Namespace) -> int:
927
1018
  workspace_root = args.workspace_root.resolve()
928
1019
  validate_lane_path_component(args.owner_unit, "owner_unit")
@@ -963,6 +1054,14 @@ def create_lane(args: argparse.Namespace) -> int:
963
1054
  branch_map = {repos[0]: branch}
964
1055
  else:
965
1056
  branch_map = parse_branch_arg(args.branch, repos)
1057
+ # A materialized lane's branch may not be the repo's own branch: the lane
1058
+ # would have no isolation and its push would land on the integration
1059
+ # branch. Refused HERE, where the choice is made, rather than at the first
1060
+ # `pr create` after the remote had been written.
1061
+ for _repo in repos:
1062
+ refusal = _lane_branch_refusal(workspace_root, spec, _repo, branch_map[_repo])
1063
+ if refusal:
1064
+ raise SystemExit(refusal)
966
1065
 
967
1066
  # Fork base: record what the caller supplies, do not derive.
968
1067
  # Each entry names a repo IN the lane and carries a 40-hex integration-branch tip.
@@ -16,6 +16,7 @@ from __future__ import annotations
16
16
  import argparse
17
17
  import dataclasses
18
18
  import json
19
+ import os
19
20
  import shutil
20
21
  import stat
21
22
  import subprocess
@@ -221,6 +222,112 @@ def run_git(repo_path: Path, *args: str) -> subprocess.CompletedProcess[str]:
221
222
  )
222
223
 
223
224
 
225
+ ASIDE_KEEP = "KEEP"
226
+ ASIDE_DROP = "DROP"
227
+ ASIDE_UNREADABLE = "UNREADABLE"
228
+ ASIDE_SUSPECT = "SUSPECT"
229
+
230
+ # The ambient repo-identity variables. With any of these inherited, `git -C <aside>`
231
+ # answers about a DIFFERENT tree -- rc 0, empty output -- and the tree it answered for is
232
+ # not the one being judged. Same class as a stale index root redirecting a recall read.
233
+ _REPO_IDENTITY_ENV = (
234
+ "GIT_DIR",
235
+ "GIT_WORK_TREE",
236
+ "GIT_INDEX_FILE",
237
+ "GIT_COMMON_DIR",
238
+ "GIT_OBJECT_DIRECTORY",
239
+ )
240
+
241
+
242
+ def _git_scrubbed(repo_path: Path, *args: str) -> subprocess.CompletedProcess[str]:
243
+ """`run_git` with the ambient repo-identity variables removed.
244
+
245
+ `run_git` is right for every other caller, where the subject is the caller's own cwd.
246
+ Here the subject is a directory git has to be told about explicitly, and an inherited
247
+ `GIT_DIR`/`GIT_WORK_TREE` pair silently redirects the question somewhere else.
248
+
249
+ An unenterable cwd is reported as a failed run rather than raised. `subprocess.run`
250
+ resolves `cwd` before git exists, so a directory that exists with mode 000 -- or one
251
+ that vanishes between the caller's check and this call -- raises `PermissionError` or
252
+ `NotADirectoryError` from the resolution itself. That is the same answer as git failing:
253
+ we could not interrogate the tree. Returning it as a non-zero result keeps ONE path to
254
+ UNREADABLE instead of an exception that `aside_disposition`'s contract does not mention
255
+ and its call site does not handle. Found by Sentinel's r2 on the previous version, whose
256
+ P4 probe caught the function propagating PermissionError where its own docstring promised
257
+ a verdict.
258
+ """
259
+ env = {k: v for k, v in os.environ.items() if k not in _REPO_IDENTITY_ENV}
260
+ try:
261
+ return subprocess.run(
262
+ ["git", *args],
263
+ cwd=repo_path,
264
+ check=False,
265
+ capture_output=True,
266
+ text=True,
267
+ env=env,
268
+ )
269
+ except OSError as exc:
270
+ return subprocess.CompletedProcess(
271
+ ["git", *args], returncode=1, stdout="", stderr=f"{type(exc).__name__}: {exc}"
272
+ )
273
+
274
+
275
+ def aside_disposition(aside: Path) -> tuple[str, int, str]:
276
+ """Disposition of the moved-aside original: KEEP / DROP / UNREADABLE / SUSPECT.
277
+
278
+ MUST be called BEFORE the prune. Pruning deletes the registration this copy's `.git`
279
+ pointer names, after which git cannot answer for it at all -- and an empty answer from
280
+ a dead instrument reads exactly like a clean tree.
281
+
282
+ Two independent instruments, in this order, because each covers the other's blind spot:
283
+
284
+ * the INDEX AUDIT (`ls-files -v`) runs FIRST, and the order is load-bearing. A
285
+ lowercase tag means assume-unchanged and `S` means skip-worktree; either one
286
+ switches off the comparison itself, so no `status` flag can reach that path and its
287
+ silence proves nothing. If the comparison ran first and returned a clean DROP, the
288
+ audit would never run and this bypass would return in full. The audit is also the
289
+ weaker instrument alone -- under a per-worktree `config.worktree` redirect
290
+ `ls-files` goes quiet while `status` fails loudly (rc 128 -> UNREADABLE) -- so each
291
+ one covers the other only in this order.
292
+ * then the COMPARISON, WIDE: `--porcelain --ignored -uall`. Wide on the DELETE side
293
+ only: we delete a tree only when it is provably empty of anything git does not
294
+ already carry. An ignored file is invisible to a narrow `--porcelain` in steady
295
+ state, with no race, and a `status.showUntrackedFiles=no` in shared config hides an
296
+ untracked draft from every check except one that passes `-uall` explicitly -- a
297
+ command-line flag outranks config, which is the durable reason to pass it every time
298
+ rather than inherit a default someone else can set.
299
+
300
+ The refusal gates upstream stay narrow on purpose, and the asymmetry is the point: a
301
+ `*.env` must not block a conversion, it must only stop a deletion. The cost is that an
302
+ aside is kept more often, at a NAMED printed path, and a kept directory is recoverable
303
+ where a deleted one is not.
304
+ """
305
+ bits = _git_scrubbed(aside, "ls-files", "-v")
306
+ if bits.returncode != 0:
307
+ lines = (bits.stderr or "").strip().splitlines()
308
+ return (ASIDE_UNREADABLE, 0, lines[-1] if lines else "git refused to read the tree")
309
+
310
+ hidden = [
311
+ line for line in bits.stdout.splitlines() if line[:1].islower() or line[:1] == "S"
312
+ ]
313
+ if hidden:
314
+ return (
315
+ ASIDE_SUSPECT,
316
+ len(hidden),
317
+ f"the index marks {len(hidden)} path(s) assume-unchanged or skip-worktree, "
318
+ "so the comparison is switched off for them and its silence proves nothing",
319
+ )
320
+
321
+ out = _git_scrubbed(aside, "status", "--porcelain", "--ignored", "-uall")
322
+ if out.returncode != 0:
323
+ lines = (out.stderr or "").strip().splitlines()
324
+ return (ASIDE_UNREADABLE, 0, lines[-1] if lines else "git refused to read the tree")
325
+ n = sum(1 for line in out.stdout.splitlines() if line.strip())
326
+ if n == 0:
327
+ return (ASIDE_DROP, 0, "")
328
+ return (ASIDE_KEEP, n, out.stdout.strip())
329
+
330
+
224
331
  def is_git_dir_symlink(path: Path) -> bool:
225
332
  """A repo path's ``.git`` IS a symlink into another clone's git state.
226
333
 
@@ -278,6 +385,68 @@ def _lstat_summary(git_path: Path) -> dict[str, object]:
278
385
  }
279
386
 
280
387
 
388
+ def _origin_url(path: Path) -> str | None:
389
+ """The repo's ``origin`` URL, or None when it has no origin."""
390
+ proc = run_git(path, "remote", "get-url", "origin")
391
+ if proc.returncode != 0:
392
+ return None
393
+ url = proc.stdout.strip()
394
+ return url or None
395
+
396
+
397
+ def restore_original(target: Path, aside: Path) -> bool:
398
+ """Put the moved-aside original back at ``target``. Destroys nothing.
399
+
400
+ PARAMETERISED ON PURPOSE, so the witness runs the same bytes the caller
401
+ does rather than a copy free to drift.
402
+
403
+ An occupant at ``target`` is MOVED ASIDE AND NAMED, never deleted: this is
404
+ the recovery path, so by definition it runs once something has already
405
+ gone wrong, and the first version of the hand-built equivalent did an
406
+ unguarded ``rm -rf`` here and then printed "(nothing lost)". A recovery
407
+ path carrying the defect its forward path was fixed for is the worst
408
+ place for it to live.
409
+ """
410
+ target = Path(target)
411
+ aside = Path(aside)
412
+ if target.exists():
413
+ keep = target.with_name(f"{target.name}.interloper.{os.getpid()}")
414
+ try:
415
+ os.rename(target, keep)
416
+ except OSError:
417
+ return False
418
+ print(
419
+ f" NOTE: something occupied {target} during recovery; moved to {keep} (NOT deleted)",
420
+ file=sys.stderr,
421
+ )
422
+ try:
423
+ os.rename(aside, target)
424
+ except OSError:
425
+ print(
426
+ f" CANNOT RESTORE. Your original tree is at: {aside}",
427
+ file=sys.stderr,
428
+ )
429
+ return False
430
+ return True
431
+
432
+
433
+ def swap_in_place(staged: Path, target: Path) -> None:
434
+ """Move ``staged`` onto ``target``, refusing to nest.
435
+
436
+ ``mv src dst`` with dst PRESENT nests src inside it, leaving the path a
437
+ non-git directory and reporting the failure only after the damage
438
+ . If anything recreated the target inside the
439
+ window -- gr1's materializer, a hook, a peer -- refuse and leave both
440
+ copies findable.
441
+ """
442
+ if Path(target).exists():
443
+ raise ConvertCloneError(
444
+ f"{target} was recreated during the conversion; refusing to move "
445
+ f"onto it (the replacement is at {staged})"
446
+ )
447
+ shutil.move(str(staged), str(target))
448
+
449
+
281
450
  def convert_worktree_to_clone(path: Path) -> dict[str, object]:
282
451
  """Convert a linked worktree into an own clone, in place.
283
452
 
@@ -339,6 +508,31 @@ def convert_worktree_to_clone(path: Path) -> dict[str, object]:
339
508
 
340
509
  head_sha = run_git(path, "rev-parse", "HEAD").stdout.strip()
341
510
 
511
+ # A worktree's commits live in the OWNER'S object store, which
512
+ # has been acting as an accidental backup nobody designed. Converting to a
513
+ # clone removes that fallback, so this is the last moment at which
514
+ # "unpushed" is cheap to say.
515
+ # HEAD is explicit on purpose: `git log --not --remotes` with no positive
516
+ # ref yields NOTHING -- git has only exclusions to walk -- so the check
517
+ # could never fire. Measured: a desk with one commit on no remote
518
+ # reported 0 until HEAD was named.
519
+ unmerged = run_git(path, "log", "--oneline", "HEAD", "--not", "--remotes")
520
+ unpushed_commits = len([ln for ln in unmerged.stdout.splitlines() if ln.strip()])
521
+ if unpushed_commits:
522
+ print(
523
+ f" WARNING: {path} carries {unpushed_commits} commit(s) on no "
524
+ "remote. After conversion this clone is their only copy -- push "
525
+ "before relying on it.",
526
+ file=sys.stderr,
527
+ )
528
+
529
+ # Read the ORIGINAL's origin before anything is mutated. The
530
+ # staging clone's own origin will point at the owner's directory, because
531
+ # that is what `git clone <path>` sets, so leaving it is a .git coupling
532
+ # traded for an origin coupling -- the lane doing the opposite of its
533
+ # purpose while reporting success.
534
+ original_origin = _origin_url(path)
535
+
342
536
  staging_path = path.parent / f".convert-staging-{path.name}"
343
537
  if staging_path.exists():
344
538
  raise ConvertCloneError(f"{staging_path}: staging path already exists")
@@ -361,6 +555,19 @@ def convert_worktree_to_clone(path: Path) -> dict[str, object]:
361
555
  f"checkout of {branch} in the staging clone failed: {checkout_proc.stderr.strip()}"
362
556
  )
363
557
 
558
+ # The clone's origin is the owner's directory. Restore the
559
+ # original's origin, or REMOVE it when the original had none -- inventing
560
+ # a coupling is worse than having no remote.
561
+ if original_origin is not None:
562
+ set_proc = run_git(staging_path, "remote", "set-url", "origin", original_origin)
563
+ if set_proc.returncode != 0:
564
+ shutil.rmtree(staging_path)
565
+ raise ConvertCloneError(
566
+ f"could not restore origin {original_origin}: {set_proc.stderr.strip()}"
567
+ )
568
+ else:
569
+ run_git(staging_path, "remote", "remove", "origin")
570
+
364
571
  staging_git = staging_path / ".git"
365
572
  staging_summary = _lstat_summary(staging_git)
366
573
  if not staging_summary["is_dir"]:
@@ -376,15 +583,152 @@ def convert_worktree_to_clone(path: Path) -> dict[str, object]:
376
583
  f"staging clone HEAD {staging_head} != original {head_sha} -- refusing to proceed"
377
584
  )
378
585
 
379
- remove_proc = run_git(canonical_repo_root, "worktree", "remove", "--force", str(path))
380
- if remove_proc.returncode != 0:
586
+ # RENAME ASIDE, THEN SWAP -- never destroy before the
587
+ # replacement is in place. The previous order was `worktree remove
588
+ # --force` (which deletes the original working tree) followed by an
589
+ # UNGUARDED move, so any failure between them -- an mv error, a read-only
590
+ # parent, SIGKILL, power loss -- left this path EMPTY with the
591
+ # replacement stranded at a staging path no message named. Witnessed by
592
+ # fault injection before this fix. A rename is instant and the worktree's
593
+ # .git pointer is ABSOLUTE, so relocating it does not break it.
594
+ # RE-CHECK DIRTY, immediately before the irreversible step. The check far
595
+ # above runs BEFORE the clone, and the clone is the long part -- minutes on
596
+ # a real repo. Anything written into the worktree during that window is
597
+ # invisible to the earlier gate, captured by the rename below, and then
598
+ # deleted by the success-path cleanup, with "converted" reported. Same
599
+ # verify-then-act-over-a-mutating-world family as the nest guard, opposite
600
+ # victim: not an interloper, the user, on the happy path.
601
+ #
602
+ # This gate stays NARROW on purpose. It decides whether to PROCEED, not what to
603
+ # DELETE, and nothing is dropped on the strength of a narrow reading: an ignored file
604
+ # that slips past here lands in the aside and is caught by aside_disposition's wide
605
+ # delete-side read, which keeps the whole aside. Refuse-side narrow, delete-side wide
606
+ # -- a `*.env` must not block a conversion, it must only stop a deletion.
607
+ status_now = run_git(path, "status", "--porcelain")
608
+ if status_now.stdout.strip():
609
+ shutil.rmtree(staging_path, ignore_errors=True)
610
+ raise ConvertCloneError(
611
+ f"{path}: working tree became dirty during the conversion, "
612
+ f"refusing to continue:\n{status_now.stdout.strip()}"
613
+ )
614
+
615
+ aside_path = path.with_name(f".convert-aside-{path.name}.{os.getpid()}")
616
+ if aside_path.exists():
381
617
  shutil.rmtree(staging_path)
382
- raise ConvertCloneError(f"git worktree remove failed: {remove_proc.stderr.strip()}")
383
- run_git(canonical_repo_root, "worktree", "prune")
618
+ raise ConvertCloneError(f"{aside_path}: aside path already exists")
619
+ try:
620
+ os.rename(path, aside_path)
621
+ except OSError as exc:
622
+ shutil.rmtree(staging_path)
623
+ raise ConvertCloneError(f"could not move the original aside: {exc}") from exc
384
624
 
385
- shutil.move(str(staging_path), str(path))
625
+ try:
626
+ swap_in_place(staging_path, path)
627
+ except ConvertCloneError:
628
+ restore_original(path, aside_path)
629
+ raise
630
+ except Exception as exc:
631
+ # Every branch that leaves a copy behind NAMES it. The first version
632
+ # let a raw OSError propagate, so the operator was told the swap
633
+ # failed and never told where the replacement went.
634
+ restore_original(path, aside_path)
635
+ raise ConvertCloneError(
636
+ f"the swap onto {path} failed: {exc}. Your original has been "
637
+ f"restored at {path}; the replacement clone is at {staging_path} "
638
+ "(neither deleted)."
639
+ ) from exc
386
640
 
387
641
  after = _lstat_summary(path / ".git")
642
+ if not after["is_dir"]:
643
+ shutil.rmtree(path, ignore_errors=True)
644
+ restore_original(path, aside_path)
645
+ raise ConvertCloneError(
646
+ f"{path}: verification failed after the swap -- the original has been restored"
647
+ )
648
+
649
+ # ASK ABOUT THE ASIDE NOW, WHILE THE ANSWERER IS STILL ALIVE -- AND ONLY ASK.
650
+ #
651
+ # The prune below deletes the registration that this copy's `.git` pointer names, so
652
+ # any git question asked of aside_path AFTER it returns rc 128 with an empty stdout.
653
+ # The narrow form of this check (`status --porcelain`, stdout only, rc discarded) read
654
+ # that as a clean tree and deleted it on the strength of an error -- and because it sat
655
+ # two lines after the prune, `shutil.rmtree` ran on EVERY conversion and the `.keep`
656
+ # branch below could not fire at all. Same family as the nest guard, opposite victim:
657
+ # the user's work, on the happy path, judged by an instrument we had just killed.
658
+ #
659
+ # Asking early is only half of it. The other half is that the instrument is wide
660
+ # (see aside_disposition): this read is the DELETE side, so an ignored file or an index
661
+ # bit that mutes the comparison has to be visible here, where the refusal gates
662
+ # upstream may stay narrow.
663
+ verdict, _residual_count, residual_detail = aside_disposition(aside_path)
664
+
665
+ # DEREGISTER, in porcelain only, and only now that the conversion is
666
+ # verified. `git worktree prune` reaps a registration whose path is
667
+ # MISSING, and the path is now occupied by the new clone, so prune alone
668
+ # is a no-op here -- the existing witness (the canonical repo's own
669
+ # `worktree list`) is what caught that. `git worktree remove` refuses a
670
+ # path that is no longer a worktree, and `git worktree repair` cannot
671
+ # undo a prune (measured: the admin dir is gone and the tree is left
672
+ # unusable), so pruning earlier -- while the original sits aside -- would
673
+ # make the restore path return a tree that is no longer a git repository.
674
+ #
675
+ # So: move the new clone aside for the length of one prune and move it
676
+ # back. Both steps are instant renames, and every failure leaves both
677
+ # copies at named paths, with the original's files still at aside_path.
678
+ # Note what that is worth after the prune has run: the FILE PILE survives
679
+ # and git can no longer answer for it, which is exactly why the
680
+ # disposition above was taken before this block.
681
+ dereg_path = path.with_name(f".convert-dereg-{path.name}.{os.getpid()}")
682
+ pruned = False
683
+ try:
684
+ os.rename(path, dereg_path)
685
+ run_git(canonical_repo_root, "worktree", "prune")
686
+ pruned = True
687
+ os.rename(dereg_path, path)
688
+ except OSError as exc:
689
+ # A message that implies recoverability the copy does not have is its own defect:
690
+ # if the prune ran, the aside is a file pile no git command will answer for.
691
+ pointer_note = (
692
+ " The prune has already run, so the copy at that path is a file pile git can "
693
+ "no longer answer for: its registration is gone and `git worktree repair` "
694
+ "cannot undo a prune. Copy it by hand; do not expect git to read it."
695
+ if pruned
696
+ else ""
697
+ )
698
+ raise ConvertCloneError(
699
+ f"deregistration failed: {exc}. The converted clone is at "
700
+ f"{dereg_path if dereg_path.exists() else path}; your original is "
701
+ f"at {aside_path}. Neither has been deleted.{pointer_note}"
702
+ ) from exc
703
+
704
+ still_registered = run_git(canonical_repo_root, "worktree", "list", "--porcelain")
705
+ if f"worktree {path}\n" in still_registered.stdout + "\n":
706
+ raise ConvertCloneError(
707
+ f"{path} is still registered as a worktree of {canonical_repo_root} "
708
+ f"after conversion; original preserved at {aside_path}"
709
+ )
710
+
711
+ # APPLY the disposition taken before the prune. Both non-DROP verdicts keep the tree,
712
+ # and the UNREADABLE one is the important half: a tree git cannot answer for is exactly
713
+ # the one we must not destroy, because its silence is indistinguishable from a clean
714
+ # tree and only one of those two readings is safe to act on.
715
+ keep = aside_path.with_name(f"{aside_path.name}.keep")
716
+ if verdict == ASIDE_DROP:
717
+ shutil.rmtree(aside_path, ignore_errors=True)
718
+ else:
719
+ os.rename(aside_path, keep)
720
+ if verdict == ASIDE_KEEP:
721
+ print(
722
+ f" NOTE: the original carried uncommitted work at cleanup time; "
723
+ f"kept at {keep} (NOT deleted):\n{residual_detail}",
724
+ file=sys.stderr,
725
+ )
726
+ else:
727
+ print(
728
+ f" NOTE: the original could not be shown to be empty of work "
729
+ f"({verdict}: {residual_detail}); kept at {keep} (NOT deleted).",
730
+ file=sys.stderr,
731
+ )
388
732
 
389
733
  return {
390
734
  "converted_at": datetime.now(UTC).isoformat(),
@@ -394,6 +738,9 @@ def convert_worktree_to_clone(path: Path) -> dict[str, object]:
394
738
  "head_sha": head_sha,
395
739
  "before_git_lstat": before,
396
740
  "after_git_lstat": after,
741
+ "unpushed_commits": unpushed_commits,
742
+ "origin_before": original_origin,
743
+ "origin_after": _origin_url(path),
397
744
  }
398
745
 
399
746
 
@@ -432,7 +779,9 @@ def inspect_repo(path: Path) -> RepoStatus:
432
779
  detached = branch_proc.returncode != 0
433
780
  branch = None if detached else branch_proc.stdout.strip()
434
781
 
435
- upstream_proc = run_git(path, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}")
782
+ upstream_proc = run_git(
783
+ path, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"
784
+ )
436
785
  upstream = upstream_proc.stdout.strip() if upstream_proc.returncode == 0 else None
437
786
 
438
787
  dirty_proc = run_git(path, "status", "--porcelain")
@@ -597,7 +946,6 @@ def classify(target: RepoTarget, status: RepoStatus, policy: RepoPolicy) -> Plan
597
946
 
598
947
  def render_table(actions: list[PlannedAction]) -> str:
599
948
  lines = [
600
- "gr2 repo-maintenance prototype",
601
949
  "SCOPE\tTARGET\tREPO\tACTION\tBRANCH\tUPSTREAM\tSTATE\tREASON",
602
950
  ]
603
951
  for item in actions: