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.
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/PKG-INFO +1 -1
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/gitgrip.egg-info/PKG-INFO +1 -1
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/gitgrip.egg-info/SOURCES.txt +16 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/prototypes/repo_maintenance_prototype.py +376 -11
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/pyproject.toml +1 -1
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/app.py +717 -88
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/clone_exec.py +52 -0
- gitgrip-2.0.0a5/python_cli/consent.py +462 -0
- gitgrip-2.0.0a5/python_cli/gitinclude.py +198 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/gitops.py +148 -2
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/grip.py +8 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/grip_cli.py +74 -6
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/hooks.py +294 -5
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/migration.py +87 -4
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/platform.py +59 -3
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/pr.py +49 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/spec_apply.py +116 -23
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/syncops.py +51 -10
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_apply_convergence.py +13 -13
- gitgrip-2.0.0a5/tests/test_cli_shape_witnesses.py +147 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_conftest_git_isolation.py +1 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_event_outcome_policy.py +1 -1
- gitgrip-2.0.0a5/tests/test_gitinclude_compiler.py +862 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_hook_events.py +4 -1
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_hook_hardening.py +4 -1
- gitgrip-2.0.0a5/tests/test_hooks_consent.py +1135 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_lane_create_fork_base_cli.py +39 -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.0a3 → 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.0a3 → gitgrip-2.0.0a5}/tests/test_open_gr_enter.py +16 -0
- {gitgrip-2.0.0a3 → 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.0a3 → gitgrip-2.0.0a5}/tests/test_projection_tracked_dest.py +4 -4
- gitgrip-2.0.0a5/tests/test_repo_path_read_through.py +382 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_rmtree_or_refuse.py +12 -3
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_spec_apply_event_outcomes.py +5 -2
- gitgrip-2.0.0a5/tests/test_spec_declaration_ref_pin.py +651 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_sprint21_sync_platform.py +237 -0
- gitgrip-2.0.0a5/tests/test_store_member_state.py +138 -0
- gitgrip-2.0.0a5/tests/test_submodule_member_predicate.py +440 -0
- 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.0a3 → gitgrip-2.0.0a5}/tests/test_version_static.py +2 -2
- gitgrip-2.0.0a5/tests/test_workspace_root_defaults.py +147 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/README.md +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/gitgrip.egg-info/dependency_links.txt +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/gitgrip.egg-info/entry_points.txt +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/gitgrip.egg-info/requires.txt +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/gitgrip.egg-info/top_level.txt +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/gr2/__init__.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/gr2/overlay/__init__.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/gr2/overlay/activate.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/gr2/overlay/agent_manifest.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/gr2/overlay/cli.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/gr2/overlay/cross_repo.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/gr2/overlay/drivers.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/gr2/overlay/introspection.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/gr2/overlay/language_drivers.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/gr2/overlay/objects.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/gr2/overlay/perf.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/gr2/overlay/refs.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/gr2/overlay/trust.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/gr2/overlay/types.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/gr2/overlay/units.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/gr2/overlay/workspace_spec.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/gr2/schemas/gr2-materialization-plan-v1.schema.json +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/gr2_overlay/__init__.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/prototypes/__init__.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/prototypes/cache_materialization_probe.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/prototypes/concurrent_event_stress.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/prototypes/concurrent_lease_stress.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/prototypes/concurrent_workspace_cap_stress.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/prototypes/contribution_protocol.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/prototypes/cross_mode_lane_stress.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/prototypes/jsonl_store.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/prototypes/lane_workspace_prototype.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/prototypes/layout_model_probe.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/prototypes/propagation_daemon.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/prototypes/propagation_state_machine.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/prototypes/python_exec_playground.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/prototypes/python_hook_runtime_playground.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/prototypes/python_migration_playground.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/prototypes/python_review_checkout_playground.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/prototypes/python_spec_apply_playground.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/prototypes/real_git_lane_materialization.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/prototypes/real_git_playground.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/prototypes/recall_lane_history.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/prototypes/repo_transport_probe.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/__init__.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/__main__.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/add.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/branch.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/channel_bridge.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/commit.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/config.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/env_exec.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/events.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/execops.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/failures.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/file_exec.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/git_review.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/launch_exec.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/merge_verification.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/open_gr_review.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/project_review.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/prune.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/push.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/review.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/review_dispatch.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/review_ephemeral.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/review_rebind.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/review_run.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/review_runners.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/staging_cleanup.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/target.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/workspace_guidance.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/python_cli/workspace_snapshot.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/setup.cfg +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_add.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_agent_manifest_overlay_source.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_app_event_outcomes.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_branch.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_channel_bridge.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_cli_adapter_error_boundary.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_clone_materialization.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_commit.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_config_base_path_guard.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_config_overlay.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_contribution_protocol.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_ensure_lane_checkout_reference_clone.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_env_materialization.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_event_log_integrity.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_events.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_events_torn_line.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_exec_apostrophe_and_oserror.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_exec_single_string_and_materialize_plan.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_execops.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_failure_event_outcome.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_first_five_minutes.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_foreground_launch_runtime.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_git_review.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_gr1_first_run_guidance.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_gr2_packaging.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_gr2_review_receipt.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_grip_cli.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_grip_hardening.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_grip_object_model.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_grip_snapshot.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_jsonl_store.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_lane_create_refuses_own_branch.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_lane_lease_show_json.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_lane_repo_paths_and_all_skipped.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_lane_toml_serialization.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_launch_primitive.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_m2_overlay_hindsight.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_m2_overlay_mem0.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_m2_overlay_zep.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_materialization_plan_contract.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_merge_evidence.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_merge_parent_verification.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_migration.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_migration_overlay_compatibility.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_migration_unadopted_notice.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_no_bare_python_cli_imports.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_null_current_lane_refusal.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_open_project_refusal_reason_cli.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_overlay_acceptance.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_overlay_activate.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_overlay_cli.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_overlay_cli_wiring.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_overlay_composition_merge.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_overlay_cross_repo_atomic_apply.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_overlay_cross_repo_perf_gate.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_overlay_cross_repo_rollback.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_overlay_driver_registry.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_overlay_introspection.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_overlay_multi_stack.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_overlay_object_encoding.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_overlay_perf_gates.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_overlay_python_driver.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_overlay_tier_b_encoding.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_overlay_tier_b_perf_gates.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_overlay_tier_b_repo_state.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_overlay_tier_b_staging_discipline.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_overlay_trust_model.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_overlay_workspace_spec.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_pr_merge_method.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_pr_merge_partial_failure.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_project_file_materialization.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_project_review_range_reconstruction.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_project_review_three_repo.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_propagation_contribution.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_propagation_daemon.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_propagation_state_machine.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_prune.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_push.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_readme_verbs_exist.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_repo_status_linked_worktree.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_repo_status_schema_version.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_repo_status_symlinked_git_dir.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_review_bind_fork_base.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_review_bind_from_range.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_review_bind_verify.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_review_cli.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_review_close_gr.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_review_create_project_cli.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_review_dispatch.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_review_ephemeral.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_review_open_lane.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_review_rebind.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_review_run.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_review_run_import_isolation.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_review_run_rerun_and_refusal_receipt.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_review_runners.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_review_verify_inside.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_single_repo_cwd_refusal.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_snapshot_id_prefix_resolution.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_staging_cleanup.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_store_verb_rename.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_sync_event_outcomes.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_sync_fast_forward.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_sync_fetch.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_sync_seed_local.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_sync_ssh_rewrite.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_sync_status_probe.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_target.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_tmux_launch_runtime.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_unit_abort.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_unit_apply.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_unit_apply_dry_run.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_unit_manifest_schema.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_unit_propose.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_unit_topological_apply.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_workspace_convert_clone.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_workspace_edit_lease_cap.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_workspace_identity_boundary.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_workspace_init_from_topology.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_workspace_init_local_repos.py +0 -0
- {gitgrip-2.0.0a3 → gitgrip-2.0.0a5}/tests/test_workspace_snapshot.py +0 -0
|
@@ -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(
|
|
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``)
|
|
351
|
-
clone
|
|
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.
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
|
|
364
|
-
except
|
|
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
|
-
|
|
694
|
+
worktree = _module_config_worktree(target)
|
|
695
|
+
if worktree is None:
|
|
367
696
|
return False
|
|
368
|
-
return
|
|
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.
|
|
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"
|