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