gitgrip 2.0.0a3__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.0a3 → gitgrip-2.0.0a5}/PKG-INFO +1 -1
  2. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/gitgrip.egg-info/PKG-INFO +1 -1
  3. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/gitgrip.egg-info/SOURCES.txt +16 -0
  4. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/prototypes/repo_maintenance_prototype.py +376 -11
  5. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/pyproject.toml +1 -1
  6. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/app.py +717 -88
  7. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/clone_exec.py +52 -0
  8. gitgrip-2.0.0a5/python_cli/consent.py +462 -0
  9. gitgrip-2.0.0a5/python_cli/gitinclude.py +198 -0
  10. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/gitops.py +148 -2
  11. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/grip.py +8 -0
  12. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/grip_cli.py +74 -6
  13. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/hooks.py +294 -5
  14. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/migration.py +87 -4
  15. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/platform.py +59 -3
  16. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/pr.py +49 -0
  17. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/spec_apply.py +116 -23
  18. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/syncops.py +51 -10
  19. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_apply_convergence.py +13 -13
  20. gitgrip-2.0.0a5/tests/test_cli_shape_witnesses.py +147 -0
  21. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_conftest_git_isolation.py +1 -0
  22. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_event_outcome_policy.py +1 -1
  23. gitgrip-2.0.0a5/tests/test_gitinclude_compiler.py +862 -0
  24. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_hook_events.py +4 -1
  25. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_hook_hardening.py +4 -1
  26. gitgrip-2.0.0a5/tests/test_hooks_consent.py +1135 -0
  27. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_lane_create_fork_base_cli.py +39 -0
  28. gitgrip-2.0.0a5/tests/test_lane_create_refusal_leaves_nothing.py +354 -0
  29. gitgrip-2.0.0a5/tests/test_lane_enter_hook_warnings.py +205 -0
  30. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_lane_lifecycle_safety.py +4 -0
  31. gitgrip-2.0.0a5/tests/test_materialize_pin.py +413 -0
  32. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_open_gr_enter.py +16 -0
  33. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_pr_events.py +7 -0
  34. gitgrip-2.0.0a5/tests/test_pr_group_legibility.py +236 -0
  35. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_projection_tracked_dest.py +4 -4
  36. gitgrip-2.0.0a5/tests/test_repo_path_read_through.py +382 -0
  37. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_rmtree_or_refuse.py +12 -3
  38. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_spec_apply_event_outcomes.py +5 -2
  39. gitgrip-2.0.0a5/tests/test_spec_declaration_ref_pin.py +651 -0
  40. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_sprint21_sync_platform.py +237 -0
  41. gitgrip-2.0.0a5/tests/test_store_member_state.py +138 -0
  42. gitgrip-2.0.0a5/tests/test_submodule_member_predicate.py +440 -0
  43. gitgrip-2.0.0a5/tests/test_superproject_import.py +384 -0
  44. gitgrip-2.0.0a5/tests/test_validate_repo_path_conflict.py +169 -0
  45. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_version_static.py +2 -2
  46. gitgrip-2.0.0a5/tests/test_workspace_root_defaults.py +147 -0
  47. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/README.md +0 -0
  48. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/gitgrip.egg-info/dependency_links.txt +0 -0
  49. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/gitgrip.egg-info/entry_points.txt +0 -0
  50. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/gitgrip.egg-info/requires.txt +0 -0
  51. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/gitgrip.egg-info/top_level.txt +0 -0
  52. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/gr2/__init__.py +0 -0
  53. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/gr2/overlay/__init__.py +0 -0
  54. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/gr2/overlay/activate.py +0 -0
  55. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/gr2/overlay/agent_manifest.py +0 -0
  56. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/gr2/overlay/cli.py +0 -0
  57. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/gr2/overlay/cross_repo.py +0 -0
  58. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/gr2/overlay/drivers.py +0 -0
  59. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/gr2/overlay/introspection.py +0 -0
  60. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/gr2/overlay/language_drivers.py +0 -0
  61. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/gr2/overlay/objects.py +0 -0
  62. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/gr2/overlay/perf.py +0 -0
  63. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/gr2/overlay/refs.py +0 -0
  64. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/gr2/overlay/trust.py +0 -0
  65. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/gr2/overlay/types.py +0 -0
  66. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/gr2/overlay/units.py +0 -0
  67. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/gr2/overlay/workspace_spec.py +0 -0
  68. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/gr2/schemas/gr2-materialization-plan-v1.schema.json +0 -0
  69. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/gr2_overlay/__init__.py +0 -0
  70. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/prototypes/__init__.py +0 -0
  71. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/prototypes/cache_materialization_probe.py +0 -0
  72. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/prototypes/concurrent_event_stress.py +0 -0
  73. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/prototypes/concurrent_lease_stress.py +0 -0
  74. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/prototypes/concurrent_workspace_cap_stress.py +0 -0
  75. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/prototypes/contribution_protocol.py +0 -0
  76. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/prototypes/cross_mode_lane_stress.py +0 -0
  77. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/prototypes/jsonl_store.py +0 -0
  78. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/prototypes/lane_workspace_prototype.py +0 -0
  79. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/prototypes/layout_model_probe.py +0 -0
  80. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/prototypes/propagation_daemon.py +0 -0
  81. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/prototypes/propagation_state_machine.py +0 -0
  82. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/prototypes/python_exec_playground.py +0 -0
  83. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/prototypes/python_hook_runtime_playground.py +0 -0
  84. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/prototypes/python_migration_playground.py +0 -0
  85. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/prototypes/python_review_checkout_playground.py +0 -0
  86. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/prototypes/python_spec_apply_playground.py +0 -0
  87. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/prototypes/real_git_lane_materialization.py +0 -0
  88. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/prototypes/real_git_playground.py +0 -0
  89. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/prototypes/recall_lane_history.py +0 -0
  90. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/prototypes/repo_transport_probe.py +0 -0
  91. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/__init__.py +0 -0
  92. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/__main__.py +0 -0
  93. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/add.py +0 -0
  94. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/branch.py +0 -0
  95. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/channel_bridge.py +0 -0
  96. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/commit.py +0 -0
  97. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/config.py +0 -0
  98. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/env_exec.py +0 -0
  99. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/events.py +0 -0
  100. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/execops.py +0 -0
  101. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/failures.py +0 -0
  102. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/file_exec.py +0 -0
  103. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/git_review.py +0 -0
  104. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/launch_exec.py +0 -0
  105. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/merge_verification.py +0 -0
  106. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/open_gr_review.py +0 -0
  107. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/project_review.py +0 -0
  108. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/prune.py +0 -0
  109. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/push.py +0 -0
  110. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/review.py +0 -0
  111. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/review_dispatch.py +0 -0
  112. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/review_ephemeral.py +0 -0
  113. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/review_rebind.py +0 -0
  114. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/review_run.py +0 -0
  115. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/review_runners.py +0 -0
  116. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/staging_cleanup.py +0 -0
  117. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/target.py +0 -0
  118. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/workspace_guidance.py +0 -0
  119. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/workspace_snapshot.py +0 -0
  120. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/setup.cfg +0 -0
  121. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_add.py +0 -0
  122. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_agent_manifest_overlay_source.py +0 -0
  123. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_app_event_outcomes.py +0 -0
  124. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_branch.py +0 -0
  125. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_channel_bridge.py +0 -0
  126. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_cli_adapter_error_boundary.py +0 -0
  127. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_clone_materialization.py +0 -0
  128. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_commit.py +0 -0
  129. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_config_base_path_guard.py +0 -0
  130. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_config_overlay.py +0 -0
  131. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_contribution_protocol.py +0 -0
  132. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_ensure_lane_checkout_reference_clone.py +0 -0
  133. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_env_materialization.py +0 -0
  134. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_event_log_integrity.py +0 -0
  135. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_events.py +0 -0
  136. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_events_torn_line.py +0 -0
  137. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_exec_apostrophe_and_oserror.py +0 -0
  138. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_exec_single_string_and_materialize_plan.py +0 -0
  139. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_execops.py +0 -0
  140. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_failure_event_outcome.py +0 -0
  141. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_first_five_minutes.py +0 -0
  142. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_foreground_launch_runtime.py +0 -0
  143. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_git_review.py +0 -0
  144. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_gr1_first_run_guidance.py +0 -0
  145. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_gr2_packaging.py +0 -0
  146. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_gr2_review_receipt.py +0 -0
  147. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_grip_cli.py +0 -0
  148. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_grip_hardening.py +0 -0
  149. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_grip_object_model.py +0 -0
  150. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_grip_snapshot.py +0 -0
  151. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_jsonl_store.py +0 -0
  152. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_lane_create_refuses_own_branch.py +0 -0
  153. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_lane_lease_show_json.py +0 -0
  154. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_lane_repo_paths_and_all_skipped.py +0 -0
  155. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_lane_toml_serialization.py +0 -0
  156. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_launch_primitive.py +0 -0
  157. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_m2_overlay_hindsight.py +0 -0
  158. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_m2_overlay_mem0.py +0 -0
  159. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_m2_overlay_zep.py +0 -0
  160. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_materialization_plan_contract.py +0 -0
  161. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_merge_evidence.py +0 -0
  162. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_merge_parent_verification.py +0 -0
  163. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_migration.py +0 -0
  164. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_migration_overlay_compatibility.py +0 -0
  165. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_migration_unadopted_notice.py +0 -0
  166. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_no_bare_python_cli_imports.py +0 -0
  167. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_null_current_lane_refusal.py +0 -0
  168. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_open_project_refusal_reason_cli.py +0 -0
  169. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_overlay_acceptance.py +0 -0
  170. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_overlay_activate.py +0 -0
  171. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_overlay_cli.py +0 -0
  172. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_overlay_cli_wiring.py +0 -0
  173. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_overlay_composition_merge.py +0 -0
  174. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_overlay_cross_repo_atomic_apply.py +0 -0
  175. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_overlay_cross_repo_perf_gate.py +0 -0
  176. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_overlay_cross_repo_rollback.py +0 -0
  177. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_overlay_driver_registry.py +0 -0
  178. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_overlay_introspection.py +0 -0
  179. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_overlay_multi_stack.py +0 -0
  180. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_overlay_object_encoding.py +0 -0
  181. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_overlay_perf_gates.py +0 -0
  182. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_overlay_python_driver.py +0 -0
  183. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_overlay_tier_b_encoding.py +0 -0
  184. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_overlay_tier_b_perf_gates.py +0 -0
  185. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_overlay_tier_b_repo_state.py +0 -0
  186. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_overlay_tier_b_staging_discipline.py +0 -0
  187. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_overlay_trust_model.py +0 -0
  188. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_overlay_workspace_spec.py +0 -0
  189. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_pr_merge_method.py +0 -0
  190. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_pr_merge_partial_failure.py +0 -0
  191. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_project_file_materialization.py +0 -0
  192. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_project_review_range_reconstruction.py +0 -0
  193. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_project_review_three_repo.py +0 -0
  194. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_propagation_contribution.py +0 -0
  195. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_propagation_daemon.py +0 -0
  196. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_propagation_state_machine.py +0 -0
  197. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_prune.py +0 -0
  198. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_push.py +0 -0
  199. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_readme_verbs_exist.py +0 -0
  200. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_repo_status_linked_worktree.py +0 -0
  201. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_repo_status_schema_version.py +0 -0
  202. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_repo_status_symlinked_git_dir.py +0 -0
  203. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_review_bind_fork_base.py +0 -0
  204. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_review_bind_from_range.py +0 -0
  205. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_review_bind_verify.py +0 -0
  206. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_review_cli.py +0 -0
  207. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_review_close_gr.py +0 -0
  208. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_review_create_project_cli.py +0 -0
  209. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_review_dispatch.py +0 -0
  210. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_review_ephemeral.py +0 -0
  211. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_review_open_lane.py +0 -0
  212. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_review_rebind.py +0 -0
  213. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_review_run.py +0 -0
  214. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_review_run_import_isolation.py +0 -0
  215. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_review_run_rerun_and_refusal_receipt.py +0 -0
  216. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_review_runners.py +0 -0
  217. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_review_verify_inside.py +0 -0
  218. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_single_repo_cwd_refusal.py +0 -0
  219. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_snapshot_id_prefix_resolution.py +0 -0
  220. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_staging_cleanup.py +0 -0
  221. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_store_verb_rename.py +0 -0
  222. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_sync_event_outcomes.py +0 -0
  223. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_sync_fast_forward.py +0 -0
  224. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_sync_fetch.py +0 -0
  225. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_sync_seed_local.py +0 -0
  226. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_sync_ssh_rewrite.py +0 -0
  227. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_sync_status_probe.py +0 -0
  228. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_target.py +0 -0
  229. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_tmux_launch_runtime.py +0 -0
  230. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_unit_abort.py +0 -0
  231. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_unit_apply.py +0 -0
  232. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_unit_apply_dry_run.py +0 -0
  233. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_unit_manifest_schema.py +0 -0
  234. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_unit_propose.py +0 -0
  235. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_unit_topological_apply.py +0 -0
  236. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_workspace_convert_clone.py +0 -0
  237. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_workspace_edit_lease_cap.py +0 -0
  238. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_workspace_identity_boundary.py +0 -0
  239. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_workspace_init_from_topology.py +0 -0
  240. {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_workspace_init_local_repos.py +0 -0
  241. {gitgrip-2.0.0a3 → 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.0a3
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.0a3
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
@@ -55,12 +55,14 @@ python_cli/channel_bridge.py
55
55
  python_cli/clone_exec.py
56
56
  python_cli/commit.py
57
57
  python_cli/config.py
58
+ python_cli/consent.py
58
59
  python_cli/env_exec.py
59
60
  python_cli/events.py
60
61
  python_cli/execops.py
61
62
  python_cli/failures.py
62
63
  python_cli/file_exec.py
63
64
  python_cli/git_review.py
65
+ python_cli/gitinclude.py
64
66
  python_cli/gitops.py
65
67
  python_cli/grip.py
66
68
  python_cli/grip_cli.py
@@ -93,6 +95,7 @@ tests/test_apply_convergence.py
93
95
  tests/test_branch.py
94
96
  tests/test_channel_bridge.py
95
97
  tests/test_cli_adapter_error_boundary.py
98
+ tests/test_cli_shape_witnesses.py
96
99
  tests/test_clone_materialization.py
97
100
  tests/test_commit.py
98
101
  tests/test_config_base_path_guard.py
@@ -112,6 +115,7 @@ tests/test_failure_event_outcome.py
112
115
  tests/test_first_five_minutes.py
113
116
  tests/test_foreground_launch_runtime.py
114
117
  tests/test_git_review.py
118
+ tests/test_gitinclude_compiler.py
115
119
  tests/test_gr1_first_run_guidance.py
116
120
  tests/test_gr2_packaging.py
117
121
  tests/test_gr2_review_receipt.py
@@ -121,9 +125,12 @@ tests/test_grip_object_model.py
121
125
  tests/test_grip_snapshot.py
122
126
  tests/test_hook_events.py
123
127
  tests/test_hook_hardening.py
128
+ tests/test_hooks_consent.py
124
129
  tests/test_jsonl_store.py
125
130
  tests/test_lane_create_fork_base_cli.py
131
+ tests/test_lane_create_refusal_leaves_nothing.py
126
132
  tests/test_lane_create_refuses_own_branch.py
133
+ tests/test_lane_enter_hook_warnings.py
127
134
  tests/test_lane_lease_show_json.py
128
135
  tests/test_lane_lifecycle_safety.py
129
136
  tests/test_lane_repo_paths_and_all_skipped.py
@@ -133,6 +140,7 @@ tests/test_m2_overlay_hindsight.py
133
140
  tests/test_m2_overlay_mem0.py
134
141
  tests/test_m2_overlay_zep.py
135
142
  tests/test_materialization_plan_contract.py
143
+ tests/test_materialize_pin.py
136
144
  tests/test_merge_evidence.py
137
145
  tests/test_merge_parent_verification.py
138
146
  tests/test_migration.py
@@ -163,6 +171,7 @@ tests/test_overlay_tier_b_staging_discipline.py
163
171
  tests/test_overlay_trust_model.py
164
172
  tests/test_overlay_workspace_spec.py
165
173
  tests/test_pr_events.py
174
+ tests/test_pr_group_legibility.py
166
175
  tests/test_pr_merge_method.py
167
176
  tests/test_pr_merge_partial_failure.py
168
177
  tests/test_project_file_materialization.py
@@ -175,6 +184,7 @@ tests/test_propagation_state_machine.py
175
184
  tests/test_prune.py
176
185
  tests/test_push.py
177
186
  tests/test_readme_verbs_exist.py
187
+ tests/test_repo_path_read_through.py
178
188
  tests/test_repo_status_linked_worktree.py
179
189
  tests/test_repo_status_schema_version.py
180
190
  tests/test_repo_status_symlinked_git_dir.py
@@ -197,9 +207,13 @@ tests/test_rmtree_or_refuse.py
197
207
  tests/test_single_repo_cwd_refusal.py
198
208
  tests/test_snapshot_id_prefix_resolution.py
199
209
  tests/test_spec_apply_event_outcomes.py
210
+ tests/test_spec_declaration_ref_pin.py
200
211
  tests/test_sprint21_sync_platform.py
201
212
  tests/test_staging_cleanup.py
213
+ tests/test_store_member_state.py
202
214
  tests/test_store_verb_rename.py
215
+ tests/test_submodule_member_predicate.py
216
+ tests/test_superproject_import.py
203
217
  tests/test_sync_event_outcomes.py
204
218
  tests/test_sync_fast_forward.py
205
219
  tests/test_sync_fetch.py
@@ -214,10 +228,12 @@ tests/test_unit_apply_dry_run.py
214
228
  tests/test_unit_manifest_schema.py
215
229
  tests/test_unit_propose.py
216
230
  tests/test_unit_topological_apply.py
231
+ tests/test_validate_repo_path_conflict.py
217
232
  tests/test_version_static.py
218
233
  tests/test_workspace_convert_clone.py
219
234
  tests/test_workspace_edit_lease_cap.py
220
235
  tests/test_workspace_identity_boundary.py
221
236
  tests/test_workspace_init_from_topology.py
222
237
  tests/test_workspace_init_local_repos.py
238
+ tests/test_workspace_root_defaults.py
223
239
  tests/test_workspace_snapshot.py
@@ -17,6 +17,7 @@ import argparse
17
17
  import dataclasses
18
18
  import json
19
19
  import os
20
+ import posixpath
20
21
  import shutil
21
22
  import stat
22
23
  import subprocess
@@ -31,6 +32,13 @@ class RepoSpec:
31
32
  name: str
32
33
  path: str
33
34
  url: str
35
+ # Optional, and absent on every spec written before they existed: `ref` is
36
+ # the branch of record, `pin` the commit the root declares, `detached` the
37
+ # state rather than an invented branch. None means the spec says nothing,
38
+ # which is different from a repo whose head is attached.
39
+ ref: str | None = None
40
+ pin: str | None = None
41
+ detached: bool | None = None
34
42
 
35
43
 
36
44
  @dataclasses.dataclass(frozen=True)
@@ -55,6 +63,9 @@ class RepoTarget:
55
63
  repo_name: str
56
64
  path: Path
57
65
  url: str
66
+ ref: str | None = None
67
+ pin: str | None = None
68
+ detached: bool | None = None
58
69
 
59
70
 
60
71
  @dataclasses.dataclass(frozen=True)
@@ -125,7 +136,14 @@ def read_workspace_spec(spec_path: Path) -> WorkspaceSpec:
125
136
  raw = tomllib.load(fh)
126
137
 
127
138
  repos = [
128
- RepoSpec(name=item["name"], path=item["path"], url=item["url"])
139
+ RepoSpec(
140
+ name=item["name"],
141
+ path=item["path"],
142
+ url=item["url"],
143
+ ref=item.get("ref"),
144
+ pin=item.get("pin"),
145
+ detached=item.get("detached"),
146
+ )
129
147
  for item in raw.get("repos", [])
130
148
  ]
131
149
  units = [
@@ -161,6 +179,9 @@ def derive_targets(workspace_root: Path, spec: WorkspaceSpec) -> list[RepoTarget
161
179
  repo_name=repo.name,
162
180
  path=workspace_root / repo.path,
163
181
  url=repo.url,
182
+ ref=repo.ref,
183
+ pin=repo.pin,
184
+ detached=repo.detached,
164
185
  )
165
186
  for repo in spec.repos
166
187
  ]
@@ -177,6 +198,9 @@ def derive_targets(workspace_root: Path, spec: WorkspaceSpec) -> list[RepoTarget
177
198
  repo_name=repo_name,
178
199
  path=workspace_root / unit.path / repo_name,
179
200
  url=repo.url,
201
+ ref=repo.ref,
202
+ pin=repo.pin,
203
+ detached=repo.detached,
180
204
  )
181
205
  )
182
206
 
@@ -346,26 +370,360 @@ def is_git_dir_symlink(path: Path) -> bool:
346
370
  return stat.S_ISLNK(mode)
347
371
 
348
372
 
373
+ _HEX = frozenset("0123456789abcdef")
374
+ # A real HEAD is a full object id: 40 hex under SHA-1, 64 under SHA-256. Any
375
+ # other all-hex value is something git does not write, so it must not be read as
376
+ # a detached head.
377
+ _OBJECT_ID_LENGTHS = frozenset({40, 64})
378
+
379
+
380
+ def root_gitlink_pins(workspace_root: Path) -> dict[str, str]:
381
+ """The commit the ROOT's committed tree pins for each member path.
382
+
383
+ This is the declaration: the gitlink is what the root says its members are.
384
+ A member's own HEAD is a fact about the member, and using it as the pin
385
+ records drift as though the root had declared it -- measured by moving a
386
+ member with no root commit, where the two values differ silently.
387
+ """
388
+ out = run_git(workspace_root, "ls-tree", "-r", "HEAD")
389
+ pins: dict[str, str] = {}
390
+ for line in out.stdout.splitlines():
391
+ parts = line.split(None, 3)
392
+ if len(parts) == 4 and parts[0] == "160000" and parts[1] == "commit":
393
+ pins[parts[3]] = parts[2]
394
+ return pins
395
+
396
+
397
+ def root_submodule_declarations(workspace_root: Path) -> dict[str, dict[str, str]]:
398
+ """What the root's ``.gitmodules`` declares per member PATH.
399
+
400
+ Keyed by path rather than by submodule name, since the two are different
401
+ strings and only the path is what a workspace uses.
402
+ """
403
+ gitmodules = workspace_root / ".gitmodules"
404
+ if not gitmodules.is_file():
405
+ return {}
406
+ keyed = run_git(
407
+ workspace_root, "config", "-f", str(gitmodules), "--get-regexp", r"^submodule\."
408
+ )
409
+ if keyed.returncode != 0:
410
+ return {}
411
+ fields: dict[str, dict[str, str]] = {}
412
+ for line in keyed.stdout.splitlines():
413
+ key, _, value = line.partition(" ")
414
+ value = value.strip()
415
+ if not key.startswith("submodule.") or not value:
416
+ continue
417
+ name, _, field = key[len("submodule.") :].rpartition(".")
418
+ if not name or field not in ("path", "url", "branch"):
419
+ continue
420
+ fields.setdefault(name, {})[field] = value
421
+ return {spec["path"]: spec for spec in fields.values() if spec.get("path")}
422
+
423
+
424
+ def root_submodule_branches(workspace_root: Path) -> dict[str, str]:
425
+ """The branch each member's ``.gitmodules`` declares, keyed by PATH.
426
+
427
+ ``.gitmodules`` is the root's own declaration about a member, so it outranks
428
+ what the member's remote refs happen to name: a member declared to track
429
+ ``dev`` must not be recorded as ``main`` because ``origin/HEAD`` says so.
430
+ """
431
+ return {
432
+ path: spec["branch"]
433
+ for path, spec in root_submodule_declarations(workspace_root).items()
434
+ if spec.get("branch")
435
+ }
436
+
437
+
438
+ def resolve_member_url(url: str, workspace_root: Path) -> str:
439
+ """A member url, with a RELATIVE one resolved as git resolves it.
440
+
441
+ TWO rules, both measured by letting git do it rather than reasoned from the
442
+ docs:
443
+
444
+ 1. A url is relative ONLY when it begins with ``./`` or ``../``
445
+ (gitmodules(5)). Everything else is used as written -- a resolver that
446
+ treats "anything not obviously absolute" as relative rewrites
447
+ ``me@host:org/c.git``, ``example.com:org/c.git`` and a bare ``c.git``
448
+ onto the origin, each naming somewhere else afterwards.
449
+ 2. A relative url resolves against the superproject's REMOTE URL treated as
450
+ a directory -- and against the ROOT'S OWN DIRECTORY when there is no
451
+ remote. Both verified by materializing a member: a plain clone whose
452
+ ``.gitmodules`` said ``../c`` against an origin of ``/tmp/empty/super``
453
+ fetched from ``/tmp/empty/c``; the same url on a root with no origin
454
+ fetched from the root's parent.
455
+
456
+ Resolving against the MEMBER's directory would be wrong twice over: the
457
+ member may not exist yet, and from inside it git answers about the enclosing
458
+ repo.
459
+ """
460
+ if not url:
461
+ return ""
462
+ if not (url.startswith("./") or url.startswith("../")):
463
+ return url
464
+ base = run_git(workspace_root, "remote", "get-url", "origin").stdout.strip()
465
+ if not base:
466
+ base = str(workspace_root)
467
+ if "://" in base:
468
+ scheme, _, rest = base.partition("://")
469
+ host, _, path = rest.partition("/")
470
+ return f"{scheme}://{host}{posixpath.normpath(f'/{path}/{url}')}"
471
+ if ":" in base and not base.startswith("/"):
472
+ host, _, path = base.partition(":") # scp-like: host:path
473
+ return f"{host}:{posixpath.normpath(f'{path}/{url}')}"
474
+ return posixpath.normpath(f"{base}/{url}")
475
+
476
+
477
+ def is_own_toplevel(path: Path) -> bool:
478
+ """True when git's toplevel for ``path`` IS ``path``.
479
+
480
+ ``is_git_repo`` is not this question. A plain directory inside another repo
481
+ answers ``rev-parse --is-inside-work-tree`` TRUE and reports the ENCLOSING
482
+ repo's toplevel, so a directory beside a nested member was scanned as a repo
483
+ and given the superproject's own branch, head and pin -- measured on a root
484
+ with its member at ``libs/c``, where the walk reported ``libs``.
485
+
486
+ An EMPTY member directory in a plain clone is the same shape, which is why
487
+ this is asked on both branches of the scan: there the enclosing repo is the
488
+ superproject itself, so its url, branch and head would be recorded as the
489
+ member's.
490
+ """
491
+ out = run_git(path, "rev-parse", "--show-toplevel")
492
+ if out.returncode != 0 or not out.stdout.strip():
493
+ return False
494
+ try:
495
+ return Path(out.stdout.strip()).resolve() == path.resolve()
496
+ except OSError:
497
+ return False
498
+
499
+
500
+ def declared_repo_state(
501
+ path: Path, *, pinned: str | None = None, declared_ref: str | None = None
502
+ ) -> dict[str, object]:
503
+ """What a repo DECLARES about itself: its branch of record, the commit it
504
+ pins, and whether its head is detached.
505
+
506
+ Every value comes from local refs -- no ``ls-remote``, no network -- and the
507
+ branch of record is resolved in the order the ruling names:
508
+
509
+ 1. ``declared_ref``, from the workspace's own ``.gitmodules`` when the
510
+ caller supplies it.
511
+ 2. the branch the repo is on, when its head is attached.
512
+ 3. the remote's default from ``refs/remotes/origin/HEAD``, which git sets
513
+ when it clones -- resolved rather than invented, and used for the
514
+ detached case so the detached state is recorded AS detached instead of as
515
+ an assumed branch.
516
+ 4. nothing, which is a third answer and not the same as any of them.
517
+
518
+ ``pinned`` is the commit the ROOT pins for this path, when the root pins
519
+ one; without it the repo's own HEAD is the best answer available, and for a
520
+ superproject member the caller always has the gitlink.
521
+
522
+ Returns ``{}`` when the repo has no readable HEAD (an empty repo), because
523
+ an absent field and a wrong field are not the same answer and only one of
524
+ them is safe.
525
+ """
526
+ head = run_git(path, "rev-parse", "HEAD")
527
+ if head.returncode != 0 or not head.stdout.strip():
528
+ return {}
529
+ head_sha = head.stdout.strip()
530
+
531
+ attached = run_git(path, "symbolic-ref", "-q", "HEAD")
532
+ is_attached = attached.returncode == 0 and bool(attached.stdout.strip())
533
+
534
+ ref: str | None = None
535
+ if declared_ref:
536
+ ref = declared_ref
537
+ elif is_attached:
538
+ ref = attached.stdout.strip().removeprefix("refs/heads/")
539
+ else:
540
+ default = run_git(path, "symbolic-ref", "-q", "refs/remotes/origin/HEAD")
541
+ if default.returncode == 0 and default.stdout.strip():
542
+ ref = default.stdout.strip().removeprefix("refs/remotes/origin/")
543
+
544
+ state: dict[str, object] = {
545
+ "pin": pinned or head_sha,
546
+ "detached": not is_attached,
547
+ }
548
+ if ref:
549
+ state["ref"] = ref
550
+ return state
551
+
552
+
553
+ def _git_pointer_target(path: Path) -> Path | None:
554
+ """The gitdir a repo path's ``.git`` POINTER FILE names.
555
+
556
+ None when ``.git`` is not a regular file -- absent, a directory (an own
557
+ clone), or a symlink (``is_git_dir_symlink``'s case, checked first by the
558
+ callers so it does not double-report).
559
+
560
+ The target comes back resolved against ``path``, because git records it
561
+ relative to the worktree (``gitdir: ../.git/modules/<name>``) as often as
562
+ absolutely (``gitdir: /abs/path/.git/worktrees/<id>``).
563
+ """
564
+ git_path = path / ".git"
565
+ try:
566
+ mode = git_path.lstat().st_mode
567
+ except FileNotFoundError:
568
+ return None
569
+ if not stat.S_ISREG(mode):
570
+ return None
571
+ try:
572
+ text = git_path.read_text(encoding="utf-8", errors="replace")
573
+ except OSError:
574
+ return None
575
+ for line in text.splitlines():
576
+ if line.startswith("gitdir:"):
577
+ raw = line[len("gitdir:") :].strip()
578
+ if not raw:
579
+ return None
580
+ target = Path(raw)
581
+ return target if target.is_absolute() else (path / target)
582
+ return None
583
+
584
+
349
585
  def is_linked_worktree(path: Path) -> bool:
350
- """A repo path IS a linked worktree (``git worktree add``), not an own
351
- clone and not a symlinked ``.git`` (see ``is_git_dir_symlink``).
586
+ """A repo path IS a linked worktree (``git worktree add``): not an own
587
+ clone, not a symlinked ``.git`` (see ``is_git_dir_symlink``), and NOT a
588
+ submodule member (see ``is_submodule_member``).
352
589
 
353
590
  Deliberately does not consult ``git rev-parse --is-inside-work-tree`` --
354
591
  that answers TRUE inside a linked worktree just as it does inside a real
355
592
  clone (the same trap ``clone_exec.py``'s ``verify_clone_isolation``
356
593
  docstring names for gr2's lane-clone path), so it can't distinguish the
357
- two. A plain clone's ``.git`` is a directory; ``git worktree add``
358
- replaces it with a text file (``gitdir: <path>``). lstat, not
359
- ``Path.is_dir()``, which follows a symlink.
594
+ two.
595
+
596
+ A pointer file alone is NOT the answer, and believing it was cost a user a
597
+ dead end: a SUBMODULE member's ``.git`` is a pointer file too, so every
598
+ member of a converted superproject was reported as a worktree and sent to
599
+ ``convert-clone``, which exits 1 there. The two are told apart by the
600
+ admin dir git leaves behind -- see ``_is_worktree_admin_dir``.
601
+ """
602
+ target = _git_pointer_target(path)
603
+ if target is None:
604
+ return False
605
+ return _is_worktree_admin_dir(target)
606
+
607
+
608
+ def _is_worktree_admin_dir(gitdir: Path) -> bool:
609
+ """Is ``gitdir`` a WORKTREE admin dir, rather than a repository git dir?
610
+
611
+ Measured on two real fixtures rather than read off git's documented layout,
612
+ and corroborated against git's own structural answer in the tests
613
+ (``rev-parse --git-dir`` differs from ``--git-common-dir`` for a worktree
614
+ and is identical for a submodule). ``git worktree add`` writes both a
615
+ ``gitdir`` back-pointer to the worktree's ``.git`` file and a ``commondir``
616
+ naming the shared dir; a submodule's module dir has neither, holding
617
+ ``config``/``objects``/``refs`` instead because it is a real git dir.
618
+
619
+ Both markers are required, so a directory carrying only one of them is not
620
+ claimed as a worktree: the false direction here is the one that advises a
621
+ conversion, and ``convert-clone`` refuses anything it cannot actually
622
+ convert.
360
623
  """
361
- git_path = path / ".git"
362
624
  try:
363
- mode = git_path.lstat().st_mode
364
- except FileNotFoundError:
625
+ return (gitdir / "gitdir").is_file() and (gitdir / "commondir").is_file()
626
+ except OSError:
627
+ return False
628
+
629
+
630
+ def _module_config_worktree(gitdir: Path) -> Path | None:
631
+ """The ``core.worktree`` a git dir's own config names, or None if it sets none.
632
+
633
+ Hand-parsed rather than run through ``configparser``: the file is INI, but
634
+ the one value needed here is a PATH, and a general parser would be a wider
635
+ surface than the single key this reads.
636
+ """
637
+ try:
638
+ text = (gitdir / "config").read_text(encoding="utf-8", errors="replace")
639
+ except OSError:
640
+ return None
641
+ section = ""
642
+ for raw in text.splitlines():
643
+ line = raw.strip()
644
+ if not line or line.startswith(("#", ";")):
645
+ continue
646
+ if line.startswith("[") and line.endswith("]"):
647
+ section = line[1:-1].strip().lower()
648
+ continue
649
+ if section != "core" or "=" not in line:
650
+ continue
651
+ key, _, value = line.partition("=")
652
+ if key.strip().lower() != "worktree":
653
+ continue
654
+ value = value.strip()
655
+ if not value:
656
+ return None
657
+ target = Path(value)
658
+ return target if target.is_absolute() else (gitdir / target)
659
+ return None
660
+
661
+
662
+ def _same_path(left: Path, right: Path) -> bool:
663
+ try:
664
+ return left.resolve() == right.resolve()
665
+ except OSError:
666
+ return False
667
+
668
+
669
+ def is_submodule_member(path: Path) -> bool:
670
+ """A repo path is a SUBMODULE member: its ``.git`` file points at a module
671
+ dir whose own config says that dir's worktree is THIS path.
672
+
673
+ The test is git's own ``core.worktree``, not a path component. An earlier
674
+ form matched a directory named ``modules`` anywhere in the gitdir path, and
675
+ a ``--separate-git-dir`` clone whose git dir sits under such a directory was
676
+ then claimed as a member -- with git itself answering that there is no
677
+ superproject. Both shapes point outside a ``worktrees`` admin dir, so the
678
+ admin-dir test alone cannot separate them; what separates them is that
679
+ ``git submodule add`` writes ``worktree = <path back to the member>`` into
680
+ the module config and a ``--separate-git-dir`` clone writes no ``worktree``
681
+ key at all. Measured on both fixtures.
682
+
683
+ Claiming this shape matters because the claim is user-visible: a member is
684
+ reported as a superproject member and is never advised to convert.
685
+
686
+ A submodule created with ``absorbgitdirs`` disabled keeps a real ``.git``
687
+ directory inside the member and satisfies NEITHER this predicate nor
688
+ ``is_linked_worktree``; it reads as a plain repo, which is the safe answer
689
+ because nothing is then advised about it.
690
+ """
691
+ target = _git_pointer_target(path)
692
+ if target is None or _is_worktree_admin_dir(target):
365
693
  return False
366
- if stat.S_ISLNK(mode):
694
+ worktree = _module_config_worktree(target)
695
+ if worktree is None:
367
696
  return False
368
- return not stat.S_ISDIR(mode)
697
+ return _same_path(worktree, path)
698
+
699
+
700
+ def submodule_member_state(path: Path) -> str | None:
701
+ """``"detached"``, ``"branch"`` or ``"unknown"`` for a submodule member, and
702
+ None only when the path is not a member at all.
703
+
704
+ Read from the member's own ``HEAD``: git records a detached head as the bare
705
+ object id and an attached one as ``ref: refs/heads/<name>``. The object id
706
+ is 40 hex characters under SHA-1 and **64 under SHA-256**, and a reader that
707
+ accepted exactly 40 read a SHA-256 member as no state at all -- which
708
+ dropped it out of the member count and took the superproject line off
709
+ ``workspace init``'s output entirely. So any all-hex HEAD is detached, and
710
+ anything unrecognised is ``"unknown"`` rather than nothing: an unreadable
711
+ state must not silently remove a member from the report.
712
+ """
713
+ if not is_submodule_member(path):
714
+ return None
715
+ target = _git_pointer_target(path)
716
+ if target is None:
717
+ return None
718
+ try:
719
+ head = (target / "HEAD").read_text(encoding="utf-8", errors="replace").strip()
720
+ except OSError:
721
+ return "unknown"
722
+ if head.startswith("ref:"):
723
+ return "branch"
724
+ if len(head) in _OBJECT_ID_LENGTHS and all(char in _HEX for char in head.lower()):
725
+ return "detached"
726
+ return "unknown"
369
727
 
370
728
 
371
729
  class ConvertCloneError(Exception):
@@ -482,6 +840,13 @@ def convert_worktree_to_clone(path: Path) -> dict[str, object]:
482
840
  "linked worktree -- remove the symlink and re-clone instead"
483
841
  )
484
842
 
843
+ if is_submodule_member(path):
844
+ raise ConvertCloneError(
845
+ f"{path}: a submodule member of a superproject, not a linked "
846
+ "worktree -- nothing to convert; gr2 moves member heads in its own "
847
+ "lane clones and leaves the superproject's members in place"
848
+ )
849
+
485
850
  if not is_linked_worktree(path):
486
851
  raise ConvertCloneError(f"{path}: not a linked worktree -- nothing to convert")
487
852
 
@@ -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.0a3"
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"