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