@opengsd/gsd-path 1.5.0 → 1.6.0
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.
- package/CHANGELOG.md +138 -0
- package/DOCS.md +4 -3
- package/FULL.md +64 -4
- package/HOOKS.md +63 -2
- package/MIGRATE.md +12 -0
- package/QUICK.md +4 -2
- package/README.md +67 -15
- package/RUNTIME.md +23 -5
- package/UPDATE.md +35 -0
- package/WORKFLOW.md +58 -3
- package/package.json +1 -1
- package/scripts/_common.py +107 -1
- package/scripts/build_recovery.py +62 -30
- package/scripts/build_state.py +195 -5
- package/scripts/check_handoffs.py +92 -30
- package/scripts/check_task_briefs.py +19 -14
- package/scripts/detect_project.py +17 -2
- package/scripts/discussion_records.py +9 -1
- package/scripts/discussion_validate.py +7 -6
- package/scripts/dispatch_driver.py +157 -31
- package/scripts/git_guard.py +24 -5
- package/scripts/guard_hook.py +1216 -47
- package/scripts/install.mjs +76 -3
- package/scripts/install.py +182 -37
- package/scripts/integration.py +98 -64
- package/scripts/isolation.py +148 -11
- package/scripts/members.py +68 -25
- package/scripts/migrate_core.py +2 -0
- package/scripts/model_policy.py +5 -1
- package/scripts/pipeline_diagnose.py +17 -2
- package/scripts/pipeline_git.py +68 -30
- package/scripts/pipeline_state.py +28 -6
- package/scripts/pipeline_undo.py +17 -8
- package/scripts/promote_lookahead.py +3 -3
- package/scripts/review_findings.py +180 -44
- package/scripts/roadmap.py +9 -1
- package/scripts/runtime_store.py +1 -0
- package/scripts/state_checkpoint.py +30 -9
- package/scripts/state_promote.py +15 -4
- package/scripts/task_context.py +2 -0
- package/scripts/workflow_run.py +9 -2
- package/skills/gsd-path/DEFINE.md +6 -2
- package/skills/gsd-path/PLAN.md +3 -0
- package/skills/gsd-path/ROADMAP.md +16 -6
- package/skills/gsd-path/SHIP.md +26 -4
- package/skills/gsd-path/SKILL.md +52 -24
- package/skills/gsd-path/references/build-native.md +96 -9
- package/skills/gsd-path/references/build-recovery.md +13 -0
- package/skills/gsd-path/references/coder.md +7 -3
- package/skills/gsd-path/references/operating-rules.md +11 -3
- package/skills/gsd-path/references/planner.md +13 -1
- package/skills/gsd-path/references/reviewer-final.md +5 -5
- package/skills/gsd-path/references/reviewer-wave.md +46 -13
- package/skills/gsd-path/references/runtime-selection.md +9 -0
- package/skills/gsd-path/scripts/_common.py +107 -1
- package/skills/gsd-path/scripts/build_recovery.py +62 -30
- package/skills/gsd-path/scripts/build_state.py +195 -5
- package/skills/gsd-path/scripts/check_handoffs.py +92 -30
- package/skills/gsd-path/scripts/check_task_briefs.py +19 -14
- package/skills/gsd-path/scripts/detect_project.py +17 -2
- package/skills/gsd-path/scripts/discussion_records.py +9 -1
- package/skills/gsd-path/scripts/discussion_validate.py +7 -6
- package/skills/gsd-path/scripts/dispatch_driver.py +157 -31
- package/skills/gsd-path/scripts/git_guard.py +24 -5
- package/skills/gsd-path/scripts/integration.py +98 -64
- package/skills/gsd-path/scripts/isolation.py +148 -11
- package/skills/gsd-path/scripts/members.py +68 -25
- package/skills/gsd-path/scripts/model_policy.py +5 -1
- package/skills/gsd-path/scripts/pipeline_git.py +68 -30
- package/skills/gsd-path/scripts/pipeline_state.py +28 -6
- package/skills/gsd-path/scripts/promote_lookahead.py +3 -3
- package/skills/gsd-path/scripts/review_findings.py +180 -44
- package/skills/gsd-path/scripts/roadmap.py +9 -1
- package/skills/gsd-path/scripts/state_checkpoint.py +30 -9
- package/skills/gsd-path/scripts/state_promote.py +15 -4
- package/skills/gsd-path/scripts/task_context.py +2 -0
- package/skills/gsd-path/scripts/workflow_run.py +9 -2
- package/skills/gsd-path/templates/final-review.md +5 -5
- package/skills/gsd-path/templates/state.md +1 -0
- package/skills/gsd-path/templates/task.md +1 -0
- package/skills/gsd-path/templates/wave-review.md +18 -9
- package/skills/gsd-path-build/references/build-native.md +96 -9
- package/skills/gsd-path-build/references/build-recovery.md +13 -0
- package/skills/gsd-path-build/references/coder.md +7 -3
- package/skills/gsd-path-build/references/operating-rules.md +11 -3
- package/skills/gsd-path-build/references/reviewer-final.md +5 -5
- package/skills/gsd-path-build/references/reviewer-wave.md +46 -13
- package/skills/gsd-path-build/references/runtime-selection.md +9 -0
- package/skills/gsd-path-build/scripts/_common.py +107 -1
- package/skills/gsd-path-build/scripts/build_recovery.py +62 -30
- package/skills/gsd-path-build/scripts/build_state.py +195 -5
- package/skills/gsd-path-build/scripts/check_handoffs.py +92 -30
- package/skills/gsd-path-build/scripts/check_task_briefs.py +19 -14
- package/skills/gsd-path-build/scripts/detect_project.py +17 -2
- package/skills/gsd-path-build/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-build/scripts/discussion_validate.py +7 -6
- package/skills/gsd-path-build/scripts/dispatch_driver.py +157 -31
- package/skills/gsd-path-build/scripts/integration.py +98 -64
- package/skills/gsd-path-build/scripts/isolation.py +148 -11
- package/skills/gsd-path-build/scripts/members.py +68 -25
- package/skills/gsd-path-build/scripts/model_policy.py +5 -1
- package/skills/gsd-path-build/scripts/pipeline_git.py +68 -30
- package/skills/gsd-path-build/scripts/pipeline_state.py +28 -6
- package/skills/gsd-path-build/scripts/promote_lookahead.py +3 -3
- package/skills/gsd-path-build/scripts/review_findings.py +180 -44
- package/skills/gsd-path-build/scripts/roadmap.py +9 -1
- package/skills/gsd-path-build/scripts/state_checkpoint.py +30 -9
- package/skills/gsd-path-build/scripts/state_promote.py +15 -4
- package/skills/gsd-path-build/scripts/task_context.py +2 -0
- package/skills/gsd-path-build/scripts/workflow_run.py +9 -2
- package/skills/gsd-path-build/templates/task.md +1 -0
- package/skills/gsd-path-build/templates/wave-review.md +18 -9
- package/skills/gsd-path-decide/references/operating-rules.md +11 -3
- package/skills/gsd-path-decide/references/runtime-selection.md +9 -0
- package/skills/gsd-path-decide/scripts/_common.py +107 -1
- package/skills/gsd-path-decide/scripts/build_recovery.py +62 -30
- package/skills/gsd-path-decide/scripts/check_handoffs.py +92 -30
- package/skills/gsd-path-decide/scripts/check_task_briefs.py +19 -14
- package/skills/gsd-path-decide/scripts/detect_project.py +17 -2
- package/skills/gsd-path-decide/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-decide/scripts/discussion_validate.py +7 -6
- package/skills/gsd-path-decide/scripts/integration.py +98 -64
- package/skills/gsd-path-decide/scripts/isolation.py +148 -11
- package/skills/gsd-path-decide/scripts/members.py +68 -25
- package/skills/gsd-path-decide/scripts/model_policy.py +5 -1
- package/skills/gsd-path-decide/scripts/pipeline_git.py +68 -30
- package/skills/gsd-path-decide/scripts/pipeline_state.py +28 -6
- package/skills/gsd-path-decide/scripts/promote_lookahead.py +3 -3
- package/skills/gsd-path-decide/scripts/roadmap.py +9 -1
- package/skills/gsd-path-decide/scripts/state_checkpoint.py +30 -9
- package/skills/gsd-path-decide/scripts/state_promote.py +15 -4
- package/skills/gsd-path-define/SKILL.md +6 -2
- package/skills/gsd-path-define/references/build-recovery.md +13 -0
- package/skills/gsd-path-define/references/operating-rules.md +11 -3
- package/skills/gsd-path-define/references/runtime-selection.md +9 -0
- package/skills/gsd-path-define/scripts/_common.py +107 -1
- package/skills/gsd-path-define/scripts/build_recovery.py +62 -30
- package/skills/gsd-path-define/scripts/check_handoffs.py +92 -30
- package/skills/gsd-path-define/scripts/check_task_briefs.py +19 -14
- package/skills/gsd-path-define/scripts/detect_project.py +17 -2
- package/skills/gsd-path-define/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-define/scripts/discussion_validate.py +7 -6
- package/skills/gsd-path-define/scripts/integration.py +98 -64
- package/skills/gsd-path-define/scripts/isolation.py +148 -11
- package/skills/gsd-path-define/scripts/members.py +68 -25
- package/skills/gsd-path-define/scripts/model_policy.py +5 -1
- package/skills/gsd-path-define/scripts/pipeline_git.py +68 -30
- package/skills/gsd-path-define/scripts/pipeline_state.py +28 -6
- package/skills/gsd-path-define/scripts/promote_lookahead.py +3 -3
- package/skills/gsd-path-define/scripts/roadmap.py +9 -1
- package/skills/gsd-path-define/scripts/state_checkpoint.py +30 -9
- package/skills/gsd-path-define/scripts/state_promote.py +15 -4
- package/skills/gsd-path-define/templates/state.md +1 -0
- package/skills/gsd-path-discuss/references/operating-rules.md +11 -3
- package/skills/gsd-path-discuss/references/runtime-selection.md +9 -0
- package/skills/gsd-path-discuss/scripts/_common.py +107 -1
- package/skills/gsd-path-discuss/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-discuss/scripts/model_policy.py +5 -1
- package/skills/gsd-path-docs-audit/references/operating-rules.md +11 -3
- package/skills/gsd-path-docs-audit/references/runtime-selection.md +9 -0
- package/skills/gsd-path-docs-audit/scripts/_common.py +107 -1
- package/skills/gsd-path-docs-audit/scripts/build_recovery.py +62 -30
- package/skills/gsd-path-docs-audit/scripts/check_handoffs.py +92 -30
- package/skills/gsd-path-docs-audit/scripts/check_task_briefs.py +19 -14
- package/skills/gsd-path-docs-audit/scripts/detect_project.py +17 -2
- package/skills/gsd-path-docs-audit/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-docs-audit/scripts/discussion_validate.py +7 -6
- package/skills/gsd-path-docs-audit/scripts/integration.py +98 -64
- package/skills/gsd-path-docs-audit/scripts/isolation.py +148 -11
- package/skills/gsd-path-docs-audit/scripts/members.py +68 -25
- package/skills/gsd-path-docs-audit/scripts/model_policy.py +5 -1
- package/skills/gsd-path-docs-audit/scripts/pipeline_git.py +68 -30
- package/skills/gsd-path-docs-audit/scripts/pipeline_state.py +28 -6
- package/skills/gsd-path-docs-audit/scripts/promote_lookahead.py +3 -3
- package/skills/gsd-path-docs-audit/scripts/roadmap.py +9 -1
- package/skills/gsd-path-docs-audit/scripts/state_checkpoint.py +30 -9
- package/skills/gsd-path-docs-audit/scripts/state_promote.py +15 -4
- package/skills/gsd-path-forensics/references/operating-rules.md +11 -3
- package/skills/gsd-path-forensics/references/runtime-selection.md +9 -0
- package/skills/gsd-path-forensics/scripts/_common.py +107 -1
- package/skills/gsd-path-forensics/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-forensics/scripts/model_policy.py +5 -1
- package/skills/gsd-path-forensics/scripts/pipeline_diagnose.py +17 -2
- package/skills/gsd-path-forensics/scripts/pipeline_undo.py +17 -8
- package/skills/gsd-path-inspect/references/operating-rules.md +11 -3
- package/skills/gsd-path-inspect/references/runtime-selection.md +9 -0
- package/skills/gsd-path-inspect/scripts/_common.py +107 -1
- package/skills/gsd-path-inspect/scripts/build_recovery.py +62 -30
- package/skills/gsd-path-inspect/scripts/check_handoffs.py +92 -30
- package/skills/gsd-path-inspect/scripts/check_task_briefs.py +19 -14
- package/skills/gsd-path-inspect/scripts/detect_project.py +17 -2
- package/skills/gsd-path-inspect/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-inspect/scripts/discussion_validate.py +7 -6
- package/skills/gsd-path-inspect/scripts/integration.py +98 -64
- package/skills/gsd-path-inspect/scripts/isolation.py +148 -11
- package/skills/gsd-path-inspect/scripts/members.py +68 -25
- package/skills/gsd-path-inspect/scripts/model_policy.py +5 -1
- package/skills/gsd-path-inspect/scripts/pipeline_git.py +68 -30
- package/skills/gsd-path-inspect/scripts/pipeline_state.py +28 -6
- package/skills/gsd-path-inspect/scripts/promote_lookahead.py +3 -3
- package/skills/gsd-path-inspect/scripts/roadmap.py +9 -1
- package/skills/gsd-path-inspect/scripts/state_checkpoint.py +30 -9
- package/skills/gsd-path-inspect/scripts/state_promote.py +15 -4
- package/skills/gsd-path-inspect/scripts/workflow_run.py +9 -2
- package/skills/gsd-path-inspect/templates/state.md +1 -0
- package/skills/gsd-path-loop/references/operating-rules.md +11 -3
- package/skills/gsd-path-loop/references/runtime-selection.md +9 -0
- package/skills/gsd-path-loop/scripts/_common.py +107 -1
- package/skills/gsd-path-loop/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-loop/scripts/model_policy.py +5 -1
- package/skills/gsd-path-migrate/SKILL.md +8 -0
- package/skills/gsd-path-migrate/references/operating-rules.md +11 -3
- package/skills/gsd-path-migrate/references/runtime-selection.md +9 -0
- package/skills/gsd-path-migrate/scripts/_common.py +107 -1
- package/skills/gsd-path-migrate/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-migrate/scripts/migrate_core.py +2 -0
- package/skills/gsd-path-migrate/scripts/model_policy.py +5 -1
- package/skills/gsd-path-plan/SKILL.md +3 -0
- package/skills/gsd-path-plan/references/build-recovery.md +13 -0
- package/skills/gsd-path-plan/references/operating-rules.md +11 -3
- package/skills/gsd-path-plan/references/planner.md +13 -1
- package/skills/gsd-path-plan/references/runtime-selection.md +9 -0
- package/skills/gsd-path-plan/scripts/_common.py +107 -1
- package/skills/gsd-path-plan/scripts/build_recovery.py +62 -30
- package/skills/gsd-path-plan/scripts/check_handoffs.py +92 -30
- package/skills/gsd-path-plan/scripts/check_task_briefs.py +19 -14
- package/skills/gsd-path-plan/scripts/detect_project.py +17 -2
- package/skills/gsd-path-plan/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-plan/scripts/discussion_validate.py +7 -6
- package/skills/gsd-path-plan/scripts/integration.py +98 -64
- package/skills/gsd-path-plan/scripts/isolation.py +148 -11
- package/skills/gsd-path-plan/scripts/members.py +68 -25
- package/skills/gsd-path-plan/scripts/model_policy.py +5 -1
- package/skills/gsd-path-plan/scripts/pipeline_git.py +68 -30
- package/skills/gsd-path-plan/scripts/pipeline_state.py +28 -6
- package/skills/gsd-path-plan/scripts/promote_lookahead.py +3 -3
- package/skills/gsd-path-plan/scripts/roadmap.py +9 -1
- package/skills/gsd-path-plan/scripts/state_checkpoint.py +30 -9
- package/skills/gsd-path-plan/scripts/state_promote.py +15 -4
- package/skills/gsd-path-plan/scripts/workflow_run.py +9 -2
- package/skills/gsd-path-plan/templates/task.md +1 -0
- package/skills/gsd-path-research/references/operating-rules.md +11 -3
- package/skills/gsd-path-research/references/runtime-selection.md +9 -0
- package/skills/gsd-path-research/scripts/_common.py +107 -1
- package/skills/gsd-path-research/scripts/build_recovery.py +62 -30
- package/skills/gsd-path-research/scripts/check_handoffs.py +92 -30
- package/skills/gsd-path-research/scripts/check_task_briefs.py +19 -14
- package/skills/gsd-path-research/scripts/detect_project.py +17 -2
- package/skills/gsd-path-research/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-research/scripts/discussion_validate.py +7 -6
- package/skills/gsd-path-research/scripts/integration.py +98 -64
- package/skills/gsd-path-research/scripts/isolation.py +148 -11
- package/skills/gsd-path-research/scripts/members.py +68 -25
- package/skills/gsd-path-research/scripts/model_policy.py +5 -1
- package/skills/gsd-path-research/scripts/pipeline_git.py +68 -30
- package/skills/gsd-path-research/scripts/pipeline_state.py +28 -6
- package/skills/gsd-path-research/scripts/promote_lookahead.py +3 -3
- package/skills/gsd-path-research/scripts/roadmap.py +9 -1
- package/skills/gsd-path-research/scripts/state_checkpoint.py +30 -9
- package/skills/gsd-path-research/scripts/state_promote.py +15 -4
- package/skills/gsd-path-roadmap/SKILL.md +16 -6
- package/skills/gsd-path-roadmap/references/operating-rules.md +11 -3
- package/skills/gsd-path-roadmap/references/runtime-selection.md +9 -0
- package/skills/gsd-path-roadmap/scripts/_common.py +107 -1
- package/skills/gsd-path-roadmap/scripts/build_recovery.py +62 -30
- package/skills/gsd-path-roadmap/scripts/check_handoffs.py +92 -30
- package/skills/gsd-path-roadmap/scripts/check_task_briefs.py +19 -14
- package/skills/gsd-path-roadmap/scripts/detect_project.py +17 -2
- package/skills/gsd-path-roadmap/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-roadmap/scripts/discussion_validate.py +7 -6
- package/skills/gsd-path-roadmap/scripts/integration.py +98 -64
- package/skills/gsd-path-roadmap/scripts/isolation.py +148 -11
- package/skills/gsd-path-roadmap/scripts/members.py +68 -25
- package/skills/gsd-path-roadmap/scripts/model_policy.py +5 -1
- package/skills/gsd-path-roadmap/scripts/pipeline_git.py +68 -30
- package/skills/gsd-path-roadmap/scripts/pipeline_state.py +28 -6
- package/skills/gsd-path-roadmap/scripts/promote_lookahead.py +3 -3
- package/skills/gsd-path-roadmap/scripts/roadmap.py +9 -1
- package/skills/gsd-path-roadmap/scripts/state_checkpoint.py +30 -9
- package/skills/gsd-path-roadmap/scripts/state_promote.py +15 -4
- package/skills/gsd-path-ship/SKILL.md +26 -4
- package/skills/gsd-path-ship/references/operating-rules.md +11 -3
- package/skills/gsd-path-ship/references/reviewer-final.md +5 -5
- package/skills/gsd-path-ship/references/reviewer-wave.md +46 -13
- package/skills/gsd-path-ship/references/runtime-selection.md +9 -0
- package/skills/gsd-path-ship/scripts/_common.py +107 -1
- package/skills/gsd-path-ship/scripts/build_recovery.py +62 -30
- package/skills/gsd-path-ship/scripts/build_state.py +195 -5
- package/skills/gsd-path-ship/scripts/check_handoffs.py +92 -30
- package/skills/gsd-path-ship/scripts/check_task_briefs.py +19 -14
- package/skills/gsd-path-ship/scripts/detect_project.py +17 -2
- package/skills/gsd-path-ship/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-ship/scripts/discussion_validate.py +7 -6
- package/skills/gsd-path-ship/scripts/integration.py +98 -64
- package/skills/gsd-path-ship/scripts/isolation.py +148 -11
- package/skills/gsd-path-ship/scripts/members.py +68 -25
- package/skills/gsd-path-ship/scripts/model_policy.py +5 -1
- package/skills/gsd-path-ship/scripts/pipeline_git.py +68 -30
- package/skills/gsd-path-ship/scripts/pipeline_state.py +28 -6
- package/skills/gsd-path-ship/scripts/promote_lookahead.py +3 -3
- package/skills/gsd-path-ship/scripts/roadmap.py +9 -1
- package/skills/gsd-path-ship/scripts/state_checkpoint.py +30 -9
- package/skills/gsd-path-ship/scripts/state_promote.py +15 -4
- package/skills/gsd-path-ship/scripts/workflow_run.py +9 -2
- package/skills/gsd-path-ship/templates/final-review.md +5 -5
- package/skills/gsd-path-ship/templates/wave-review.md +18 -9
- package/skills/gsd-path-undo/references/operating-rules.md +11 -3
- package/skills/gsd-path-undo/references/runtime-selection.md +9 -0
- package/skills/gsd-path-undo/scripts/_common.py +107 -1
- package/skills/gsd-path-undo/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-undo/scripts/model_policy.py +5 -1
- package/skills/gsd-path-undo/scripts/pipeline_undo.py +17 -8
- package/skills/path/DEFINE.md +6 -2
- package/skills/path/PLAN.md +3 -0
- package/skills/path/ROADMAP.md +16 -6
- package/skills/path/SHIP.md +26 -4
- package/skills/path/SKILL.md +52 -24
- package/skills/path/references/build-native.md +96 -9
- package/skills/path/references/build-recovery.md +13 -0
- package/skills/path/references/coder.md +7 -3
- package/skills/path/references/operating-rules.md +11 -3
- package/skills/path/references/planner.md +13 -1
- package/skills/path/references/reviewer-final.md +5 -5
- package/skills/path/references/reviewer-wave.md +46 -13
- package/skills/path/references/runtime-selection.md +9 -0
- package/skills/path/scripts/_common.py +107 -1
- package/skills/path/scripts/build_recovery.py +62 -30
- package/skills/path/scripts/build_state.py +195 -5
- package/skills/path/scripts/check_handoffs.py +92 -30
- package/skills/path/scripts/check_task_briefs.py +19 -14
- package/skills/path/scripts/detect_project.py +17 -2
- package/skills/path/scripts/discussion_records.py +9 -1
- package/skills/path/scripts/discussion_validate.py +7 -6
- package/skills/path/scripts/dispatch_driver.py +157 -31
- package/skills/path/scripts/git_guard.py +24 -5
- package/skills/path/scripts/integration.py +98 -64
- package/skills/path/scripts/isolation.py +148 -11
- package/skills/path/scripts/members.py +68 -25
- package/skills/path/scripts/model_policy.py +5 -1
- package/skills/path/scripts/pipeline_git.py +68 -30
- package/skills/path/scripts/pipeline_state.py +28 -6
- package/skills/path/scripts/promote_lookahead.py +3 -3
- package/skills/path/scripts/review_findings.py +180 -44
- package/skills/path/scripts/roadmap.py +9 -1
- package/skills/path/scripts/state_checkpoint.py +30 -9
- package/skills/path/scripts/state_promote.py +15 -4
- package/skills/path/scripts/task_context.py +2 -0
- package/skills/path/scripts/workflow_run.py +9 -2
- package/skills/path/templates/final-review.md +5 -5
- package/skills/path/templates/state.md +1 -0
- package/skills/path/templates/task.md +1 -0
- package/skills/path/templates/wave-review.md +18 -9
package/scripts/guard_hook.py
CHANGED
|
@@ -407,6 +407,18 @@ GIT_REASONS = {
|
|
|
407
407
|
"clean": "git clean -f deletes untracked evidence and retained task worktrees",
|
|
408
408
|
"push": "force pushes rewrite build-branch history the pipeline resumes from",
|
|
409
409
|
"branch": "git branch -D destroys task branches the recovery protocol inspects",
|
|
410
|
+
"unresolved-target": (
|
|
411
|
+
"{action} needs a literal target the guard can resolve; name the branch, "
|
|
412
|
+
"ref, or registered worktree directly"
|
|
413
|
+
),
|
|
414
|
+
"git-environment": (
|
|
415
|
+
"env changes Git's repository or alias configuration context; run the Git "
|
|
416
|
+
"command without resetting or unsetting Git-related environment variables"
|
|
417
|
+
),
|
|
418
|
+
"update-ref-order": (
|
|
419
|
+
"git update-ref deletion after another Git command can follow a symbolic "
|
|
420
|
+
"ref that command changed; run it as its own command or add --no-deref"
|
|
421
|
+
),
|
|
410
422
|
"branch-move": (
|
|
411
423
|
"git branch -m renames a branch the pipeline resumes by name "
|
|
412
424
|
"(STATE.branch); create a new branch instead"
|
|
@@ -1385,7 +1397,12 @@ def segment_directories(tokens, working_directories, contexts=None):
|
|
|
1385
1397
|
if name not in SHELL_DIRECTORY_VARIABLES
|
|
1386
1398
|
}
|
|
1387
1399
|
expanded = expand_environment_parameters(target, tracked, tracked_only=True)
|
|
1388
|
-
|
|
1400
|
+
# An unquoted empty expansion leaves no word: the shell changes to HOME.
|
|
1401
|
+
if (
|
|
1402
|
+
(target and not expanded)
|
|
1403
|
+
or SHELL_PARAMETER_SYNTAX.search(expanded)
|
|
1404
|
+
or CMD_PARAMETER_SYNTAX.search(expanded)
|
|
1405
|
+
):
|
|
1389
1406
|
raise ValueError(
|
|
1390
1407
|
f"cd target {target} cannot be resolved by the guard; "
|
|
1391
1408
|
"run that command first and cd to the literal result"
|
|
@@ -1890,9 +1907,15 @@ def shell_assignment_changes(segment, values):
|
|
|
1890
1907
|
return updates, removes
|
|
1891
1908
|
|
|
1892
1909
|
|
|
1893
|
-
def shell_assignment_contexts(tokens, initial=None):
|
|
1910
|
+
def shell_assignment_contexts(tokens, initial=None, working_directories=None):
|
|
1894
1911
|
"""Return the persistent shell variables visible before each segment."""
|
|
1895
1912
|
values = dict(initial or {})
|
|
1913
|
+
directories = list(working_directories or [os.getcwd()])
|
|
1914
|
+
if "PWD" not in values:
|
|
1915
|
+
values["PWD"] = (
|
|
1916
|
+
os.path.abspath(native_path_text(directories[0]))
|
|
1917
|
+
if len(directories) == 1 else "$PWD"
|
|
1918
|
+
)
|
|
1896
1919
|
contexts = []
|
|
1897
1920
|
segments = list(command_segments(tokens))
|
|
1898
1921
|
flows = shell_segment_flow(tokens)
|
|
@@ -1907,15 +1930,39 @@ def shell_assignment_contexts(tokens, initial=None):
|
|
|
1907
1930
|
if conditional:
|
|
1908
1931
|
values[name] = f"${name}"
|
|
1909
1932
|
else:
|
|
1910
|
-
|
|
1933
|
+
# An unset variable expands to empty, never its inherited value.
|
|
1934
|
+
values[name] = ""
|
|
1911
1935
|
for name, value in updates.items():
|
|
1912
1936
|
values[name] = f"${name}" if conditional else value
|
|
1937
|
+
invocation = command_invocation(segment)
|
|
1938
|
+
if invocation is not None and invocation[0] in DIRECTORY_CHANGE_COMMANDS:
|
|
1939
|
+
# The same tracked expansion as segment_directories.
|
|
1940
|
+
target = expand_environment_parameters(
|
|
1941
|
+
directory_change_target(invocation[1]),
|
|
1942
|
+
{
|
|
1943
|
+
name: value
|
|
1944
|
+
for name, value in contexts[-1].items()
|
|
1945
|
+
if name not in SHELL_DIRECTORY_VARIABLES
|
|
1946
|
+
},
|
|
1947
|
+
tracked_only=True,
|
|
1948
|
+
)
|
|
1949
|
+
directories = (
|
|
1950
|
+
[target] if is_absolute_path(target)
|
|
1951
|
+
else [f"{directory}/{target}" for directory in directories]
|
|
1952
|
+
)
|
|
1953
|
+
values["OLDPWD"] = values.get("PWD", "$PWD")
|
|
1954
|
+
values["PWD"] = (
|
|
1955
|
+
os.path.abspath(native_path_text(directories[0]))
|
|
1956
|
+
if not conditional and len(directories) == 1 else "$PWD"
|
|
1957
|
+
)
|
|
1958
|
+
if conditional:
|
|
1959
|
+
values["OLDPWD"] = "$OLDPWD"
|
|
1913
1960
|
return contexts, values
|
|
1914
1961
|
|
|
1915
1962
|
|
|
1916
1963
|
def shell_segment_contexts(tokens, working_directories, initial=None):
|
|
1917
1964
|
"""Yield each segment with its working directory and prior shell variables."""
|
|
1918
|
-
contexts, _ = shell_assignment_contexts(tokens, initial)
|
|
1965
|
+
contexts, _ = shell_assignment_contexts(tokens, initial, working_directories)
|
|
1919
1966
|
segments = list(segment_directories(tokens, working_directories, contexts))
|
|
1920
1967
|
if len(segments) != len(contexts):
|
|
1921
1968
|
raise ValueError("shell command segments cannot be aligned")
|
|
@@ -2208,6 +2255,10 @@ def decode_patch_path(raw_path, strip_prefix):
|
|
|
2208
2255
|
|
|
2209
2256
|
|
|
2210
2257
|
PARAMETER_MARKERS = frozenset("$%!")
|
|
2258
|
+
SHELL_DESCRIPTOR_WORD = re.compile(r"[0-9]{2,}|\{\w+\}", re.ASCII)
|
|
2259
|
+
SHELL_OPERATOR_PARTS = re.compile(
|
|
2260
|
+
r"[<>]\(|&>>?|<<<|<<|>>|>&|<&|<>|>\||[<>]|&&|\|\||\|&|[\s\S]"
|
|
2261
|
+
)
|
|
2211
2262
|
|
|
2212
2263
|
|
|
2213
2264
|
class ShellToken(str):
|
|
@@ -2216,6 +2267,11 @@ class ShellToken(str):
|
|
|
2216
2267
|
`redirection` is true for an unquoted redirection operator and for one
|
|
2217
2268
|
ASCII file descriptor digit written against one (the 2 of 2>file, not of
|
|
2218
2269
|
2 >file). zsh passes a longer or non-ASCII digit word on as an argument.
|
|
2270
|
+
`descriptor` is true for a longer digit word or a {name} word written
|
|
2271
|
+
against a redirection operator: bash reads it as a file descriptor, zsh
|
|
2272
|
+
as an argument.
|
|
2273
|
+
`body` holds the tokens of a process substitution on its operator token,
|
|
2274
|
+
once destructive_git_reason has folded them.
|
|
2219
2275
|
`literal` is "bare" for a word written with no quote or escape, "quoted"
|
|
2220
2276
|
for a word inside one pair of plain quotes, "mixed" for plain quoted and
|
|
2221
2277
|
unquoted parts where no unquoted part can expand, and None when the shell
|
|
@@ -2225,7 +2281,9 @@ class ShellToken(str):
|
|
|
2225
2281
|
|
|
2226
2282
|
double_quoted = ()
|
|
2227
2283
|
redirection = False
|
|
2284
|
+
descriptor = False
|
|
2228
2285
|
literal = None
|
|
2286
|
+
body = None
|
|
2229
2287
|
|
|
2230
2288
|
|
|
2231
2289
|
def double_quoted_markers(command):
|
|
@@ -2406,11 +2464,12 @@ def shell_tokens(command, direct=True):
|
|
|
2406
2464
|
break
|
|
2407
2465
|
start = end - len(token)
|
|
2408
2466
|
before = command[start - 1:start] if start > 0 else ""
|
|
2467
|
+
escaped = (start - len(command[:start].rstrip("\\"))) % 2 == 1
|
|
2409
2468
|
redirection = command[start:end] == token and (
|
|
2410
2469
|
(
|
|
2411
2470
|
set(token) <= SHELL_WRITE_REDIRECTION_CHARS
|
|
2412
2471
|
and ("<" in token or ">" in token)
|
|
2413
|
-
and
|
|
2472
|
+
and not escaped
|
|
2414
2473
|
)
|
|
2415
2474
|
or (
|
|
2416
2475
|
len(token) == 1
|
|
@@ -2419,10 +2478,38 @@ def shell_tokens(command, direct=True):
|
|
|
2419
2478
|
and (not before or before in " \t|;&()<>\n\r")
|
|
2420
2479
|
)
|
|
2421
2480
|
)
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2481
|
+
descriptor = (
|
|
2482
|
+
command[start:end] == token
|
|
2483
|
+
and SHELL_DESCRIPTOR_WORD.fullmatch(token) is not None
|
|
2484
|
+
and command[end:end + 1] in ("<", ">")
|
|
2485
|
+
and (not before or before in " \t|;&()<>\n\r")
|
|
2486
|
+
)
|
|
2487
|
+
# The shell reads a control operator and a redirection operator
|
|
2488
|
+
# written against each other as two operators.
|
|
2489
|
+
parts = [token]
|
|
2490
|
+
if (
|
|
2491
|
+
command[start:end] == token
|
|
2492
|
+
and set(token) <= set("|;&()<>\n\r")
|
|
2493
|
+
and ("<" in token or ">" in token)
|
|
2494
|
+
and not escaped
|
|
2495
|
+
):
|
|
2496
|
+
parts = []
|
|
2497
|
+
for part in SHELL_OPERATOR_PARTS.findall(token):
|
|
2498
|
+
if "<" in part or ">" in part or not parts or (
|
|
2499
|
+
"<" in parts[-1] or ">" in parts[-1]
|
|
2500
|
+
):
|
|
2501
|
+
parts.append(part)
|
|
2502
|
+
else:
|
|
2503
|
+
parts[-1] += part
|
|
2504
|
+
for part in parts:
|
|
2505
|
+
part = ShellToken(part)
|
|
2506
|
+
part.redirection = redirection if len(parts) == 1 else (
|
|
2507
|
+
("<" in part or ">" in part)
|
|
2508
|
+
and set(part) <= SHELL_WRITE_REDIRECTION_CHARS
|
|
2509
|
+
)
|
|
2510
|
+
part.descriptor = descriptor
|
|
2511
|
+
part.literal = literal
|
|
2512
|
+
tokens.append(part)
|
|
2426
2513
|
except ValueError as error:
|
|
2427
2514
|
raise ValueError(
|
|
2428
2515
|
f"shell command cannot be tokenized ({error}); balance the quotes "
|
|
@@ -2438,14 +2525,46 @@ def shell_tokens(command, direct=True):
|
|
|
2438
2525
|
marked = []
|
|
2439
2526
|
for token in tokens:
|
|
2440
2527
|
count = sum(character in PARAMETER_MARKERS for character in token)
|
|
2441
|
-
redirection, literal =
|
|
2528
|
+
redirection, descriptor, literal = (
|
|
2529
|
+
token.redirection, token.descriptor, token.literal
|
|
2530
|
+
)
|
|
2442
2531
|
token = ShellToken(token)
|
|
2443
|
-
token.redirection, token.literal =
|
|
2532
|
+
token.redirection, token.descriptor, token.literal = (
|
|
2533
|
+
redirection, descriptor, literal
|
|
2534
|
+
)
|
|
2444
2535
|
token.double_quoted, flags = tuple(flags[:count]), flags[count:]
|
|
2445
2536
|
marked.append(token)
|
|
2446
2537
|
return marked
|
|
2447
2538
|
|
|
2448
2539
|
|
|
2540
|
+
def shell_parameter_quoting_uncertain(command):
|
|
2541
|
+
"""Detect literal parameters whose quoting is lost by shell_tokens."""
|
|
2542
|
+
command = strip_heredoc_bodies(LINE_CONTINUATION.sub(r"\1 ", command))
|
|
2543
|
+
# These forms expand in cmd, but are literal directory text in POSIX shells.
|
|
2544
|
+
if CMD_PARAMETER_SYNTAX.search(command):
|
|
2545
|
+
return True
|
|
2546
|
+
quote = None
|
|
2547
|
+
index = 0
|
|
2548
|
+
while index < len(command):
|
|
2549
|
+
character = command[index]
|
|
2550
|
+
if quote == "'":
|
|
2551
|
+
if NAMED_SHELL_PARAMETER_SYNTAX.match(command, index):
|
|
2552
|
+
return True
|
|
2553
|
+
if character == "'":
|
|
2554
|
+
quote = None
|
|
2555
|
+
elif character == "\\":
|
|
2556
|
+
if NAMED_SHELL_PARAMETER_SYNTAX.match(command, index + 1):
|
|
2557
|
+
return True
|
|
2558
|
+
index += 1
|
|
2559
|
+
elif character in "'\"":
|
|
2560
|
+
if quote is None:
|
|
2561
|
+
quote = character
|
|
2562
|
+
elif character == quote:
|
|
2563
|
+
quote = None
|
|
2564
|
+
index += 1
|
|
2565
|
+
return False
|
|
2566
|
+
|
|
2567
|
+
|
|
2449
2568
|
def command_segments(tokens):
|
|
2450
2569
|
"""Yield simple commands with separators and leading control words removed."""
|
|
2451
2570
|
segment = []
|
|
@@ -2932,21 +3051,31 @@ def has_short_option(arguments, option):
|
|
|
2932
3051
|
)
|
|
2933
3052
|
|
|
2934
3053
|
|
|
2935
|
-
def git_alias(command, git_options):
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
|
|
3054
|
+
def git_alias(command, git_options, working_directories=None):
|
|
3055
|
+
aliases = set()
|
|
3056
|
+
for directory in working_directories or [os.getcwd()]:
|
|
3057
|
+
try:
|
|
3058
|
+
result = subprocess.run(
|
|
3059
|
+
["git", *git_options, "config", "--get", f"alias.{command}"],
|
|
3060
|
+
cwd=directory,
|
|
3061
|
+
encoding="utf-8", errors="replace",
|
|
3062
|
+
capture_output=True,
|
|
3063
|
+
check=False,
|
|
3064
|
+
)
|
|
3065
|
+
except OSError:
|
|
3066
|
+
raise ValueError("git alias configuration cannot be validated") from None
|
|
3067
|
+
if result.returncode == 1:
|
|
3068
|
+
aliases.add(None)
|
|
3069
|
+
continue
|
|
3070
|
+
if result.returncode != 0:
|
|
3071
|
+
raise ValueError("git alias configuration cannot be validated")
|
|
3072
|
+
alias = result.stdout.strip()
|
|
3073
|
+
if not alias or alias.startswith("!"):
|
|
3074
|
+
raise ValueError("git alias configuration cannot be validated")
|
|
3075
|
+
aliases.add(alias)
|
|
3076
|
+
if len(aliases) > 1:
|
|
3077
|
+
raise ValueError("git alias configuration is ambiguous across working directories")
|
|
3078
|
+
return next(iter(aliases), None)
|
|
2950
3079
|
|
|
2951
3080
|
|
|
2952
3081
|
def update_ref_deletes(arguments):
|
|
@@ -2966,28 +3095,1012 @@ def update_ref_deletes(arguments):
|
|
|
2966
3095
|
operands[1] == "" or not operands[1].strip("0")
|
|
2967
3096
|
)
|
|
2968
3097
|
return (
|
|
2969
|
-
"
|
|
3098
|
+
has_short_option(arguments, "d") # also a cluster such as -dm
|
|
2970
3099
|
or "--delete" in arguments
|
|
2971
|
-
or any(argument
|
|
3100
|
+
or any(git_option_in(argument, {"--stdin"}) for argument in arguments)
|
|
2972
3101
|
or zero_value
|
|
2973
3102
|
)
|
|
2974
3103
|
|
|
2975
3104
|
|
|
3105
|
+
PATH_OWNED_REF_PREFIXES = (
|
|
3106
|
+
"gsd-path/",
|
|
3107
|
+
"gsd-path-task/",
|
|
3108
|
+
"gsd-path-verify/",
|
|
3109
|
+
"gsd-path-integrate/",
|
|
3110
|
+
)
|
|
3111
|
+
# Git ignores an alias that has the name of one of its own commands, so these
|
|
3112
|
+
# need no alias inspection. Each has been a Git command since Git 2.31, the
|
|
3113
|
+
# oldest Git that has the rev-parse --path-format option the pipeline uses.
|
|
3114
|
+
# Deprecated commands stay out: newer Git lets an alias replace them
|
|
3115
|
+
# (whatchanged, pack-redundant). A name that is absent only fails closed.
|
|
3116
|
+
GIT_BUILT_IN_COMMANDS = frozenset({
|
|
3117
|
+
"add", "am", "annotate", "apply", "archive", "bisect", "blame", "branch",
|
|
3118
|
+
"bugreport", "bundle", "cat-file", "check-attr", "check-ignore",
|
|
3119
|
+
"check-mailmap", "check-ref-format", "checkout", "checkout-index", "cherry",
|
|
3120
|
+
"cherry-pick", "clean", "clone", "column", "commit", "commit-graph",
|
|
3121
|
+
"commit-tree", "config", "count-objects", "credential", "credential-store",
|
|
3122
|
+
"describe", "diff", "diff-files", "diff-index", "diff-tree", "difftool",
|
|
3123
|
+
"fast-export", "fast-import", "fetch", "fetch-pack", "fmt-merge-msg",
|
|
3124
|
+
"for-each-ref", "for-each-repo", "format-patch", "fsck", "gc",
|
|
3125
|
+
"get-tar-commit-id", "grep", "hash-object", "help", "index-pack", "init",
|
|
3126
|
+
"interpret-trailers", "log", "ls-files", "ls-remote", "ls-tree", "mailinfo",
|
|
3127
|
+
"mailsplit", "maintenance", "merge", "merge-base", "merge-file",
|
|
3128
|
+
"merge-index", "merge-tree", "mergetool", "mktag", "mktree",
|
|
3129
|
+
"multi-pack-index", "mv", "name-rev", "notes", "pack-objects", "pack-refs",
|
|
3130
|
+
"patch-id", "prune", "prune-packed", "pull", "push", "range-diff",
|
|
3131
|
+
"read-tree", "rebase", "receive-pack", "reflog", "remote", "repack",
|
|
3132
|
+
"replace", "rerere", "reset", "restore", "rev-list", "rev-parse", "revert",
|
|
3133
|
+
"rm", "send-pack", "shortlog", "show", "show-branch", "show-index",
|
|
3134
|
+
"show-ref", "sparse-checkout", "stage", "stash", "status", "stripspace",
|
|
3135
|
+
"submodule", "switch", "symbolic-ref", "tag", "unpack-file",
|
|
3136
|
+
"unpack-objects", "update-index", "update-ref", "update-server-info",
|
|
3137
|
+
"upload-archive", "upload-pack", "var", "verify-commit", "verify-pack",
|
|
3138
|
+
"verify-tag", "version", "worktree", "write-tree",
|
|
3139
|
+
})
|
|
3140
|
+
|
|
3141
|
+
|
|
3142
|
+
def _ownership_scalar(raw, path, key):
|
|
3143
|
+
value = raw.strip()
|
|
3144
|
+
if not value:
|
|
3145
|
+
return None
|
|
3146
|
+
if value.startswith('"'):
|
|
3147
|
+
quoted = re.match(r'^("(?:\\.|[^"\\])*\")(?:[ \t]+#.*)?$', value)
|
|
3148
|
+
if quoted is None:
|
|
3149
|
+
raise ValueError(f"{path.name} has an unreadable {key} value")
|
|
3150
|
+
try:
|
|
3151
|
+
value = json.loads(quoted.group(1))
|
|
3152
|
+
except json.JSONDecodeError:
|
|
3153
|
+
raise ValueError(f"{path.name} has an unreadable {key} value") from None
|
|
3154
|
+
if not isinstance(value, str):
|
|
3155
|
+
raise ValueError(f"{path.name} has a non-string {key} value")
|
|
3156
|
+
return value or None
|
|
3157
|
+
if value.startswith("'"):
|
|
3158
|
+
quoted = re.fullmatch(r"'((?:[^']|'')*)'(?:[ \t]+#.*)?", value)
|
|
3159
|
+
if quoted is None:
|
|
3160
|
+
raise ValueError(f"{path.name} has an unreadable {key} value")
|
|
3161
|
+
value = quoted.group(1).replace("''", "'")
|
|
3162
|
+
return value or None
|
|
3163
|
+
value = re.sub(r"[ \t]+#.*$", "", value).strip()
|
|
3164
|
+
if not value or value.casefold() in {"null", "~", "none"}:
|
|
3165
|
+
return None
|
|
3166
|
+
if value.casefold() in {"true", "false", "yes", "no", "on", "off"} or re.fullmatch(
|
|
3167
|
+
r"[-+]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][-+]?[0-9]+)?", value
|
|
3168
|
+
):
|
|
3169
|
+
raise ValueError(f"{path.name} has a non-string {key} value")
|
|
3170
|
+
if value[0] in "[{|>&*!" or ": " in value:
|
|
3171
|
+
raise ValueError(f"{path.name} has a non-scalar {key} value")
|
|
3172
|
+
return value
|
|
3173
|
+
|
|
3174
|
+
|
|
3175
|
+
def ownership_frontmatter(path, keys, *, missing_ok=False):
|
|
3176
|
+
try:
|
|
3177
|
+
metadata = path.lstat()
|
|
3178
|
+
except FileNotFoundError:
|
|
3179
|
+
if missing_ok and not os.path.lexists(path):
|
|
3180
|
+
return {}
|
|
3181
|
+
raise ValueError(f"{path.name} ownership metadata cannot be read") from None
|
|
3182
|
+
except OSError as error:
|
|
3183
|
+
raise ValueError(f"{path.name} ownership metadata cannot be read: {error}") from None
|
|
3184
|
+
if not stat.S_ISREG(metadata.st_mode):
|
|
3185
|
+
raise ValueError(f"{path.name} ownership metadata is not a regular file")
|
|
3186
|
+
try:
|
|
3187
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
3188
|
+
except (OSError, UnicodeDecodeError) as error:
|
|
3189
|
+
raise ValueError(f"{path.name} ownership metadata cannot be read: {error}") from None
|
|
3190
|
+
if not lines or lines[0] != "---":
|
|
3191
|
+
raise ValueError(f"{path.name} has no readable frontmatter")
|
|
3192
|
+
try:
|
|
3193
|
+
end = lines.index("---", 1)
|
|
3194
|
+
except ValueError:
|
|
3195
|
+
raise ValueError(f"{path.name} frontmatter is not terminated") from None
|
|
3196
|
+
|
|
3197
|
+
values = {}
|
|
3198
|
+
pending_scalar = None
|
|
3199
|
+
for line in lines[1:end]:
|
|
3200
|
+
if not line.strip() or line.lstrip().startswith("#"):
|
|
3201
|
+
continue
|
|
3202
|
+
if line[0].isspace():
|
|
3203
|
+
if pending_scalar is not None:
|
|
3204
|
+
raise ValueError(
|
|
3205
|
+
f"{path.name} has a non-scalar {pending_scalar} value"
|
|
3206
|
+
)
|
|
3207
|
+
continue
|
|
3208
|
+
pending_scalar = None
|
|
3209
|
+
match = re.fullmatch(r"(?P<key>[A-Za-z_][A-Za-z0-9_-]*):(?P<value>.*)", line)
|
|
3210
|
+
if match is None:
|
|
3211
|
+
raise ValueError(f"{path.name} has unreadable frontmatter")
|
|
3212
|
+
key = match.group("key")
|
|
3213
|
+
if key not in keys:
|
|
3214
|
+
continue
|
|
3215
|
+
if key in values:
|
|
3216
|
+
raise ValueError(f"{path.name} repeats ownership field {key}")
|
|
3217
|
+
value = _ownership_scalar(match.group("value"), path, key)
|
|
3218
|
+
values[key] = value
|
|
3219
|
+
if value is None and not match.group("value").strip():
|
|
3220
|
+
pending_scalar = key
|
|
3221
|
+
return values
|
|
3222
|
+
|
|
3223
|
+
|
|
3224
|
+
def git_base_directories(git_options, working_directories):
|
|
3225
|
+
"""Resolve target paths from each shell cwd after Git's ordered ``-C`` options."""
|
|
3226
|
+
result = []
|
|
3227
|
+
for directory in working_directories or [os.getcwd()]:
|
|
3228
|
+
current = Path(directory or os.getcwd()).resolve()
|
|
3229
|
+
index = 0
|
|
3230
|
+
while index < len(git_options):
|
|
3231
|
+
option = git_options[index]
|
|
3232
|
+
value = git_options[index + 1]
|
|
3233
|
+
if option == "-C":
|
|
3234
|
+
target = Path(value)
|
|
3235
|
+
current = (target if target.is_absolute() else current / target).resolve()
|
|
3236
|
+
index += 2
|
|
3237
|
+
result.append(current)
|
|
3238
|
+
return result
|
|
3239
|
+
|
|
3240
|
+
|
|
3241
|
+
def _git_output(git_options, directory, *arguments):
|
|
3242
|
+
try:
|
|
3243
|
+
result = subprocess.run(
|
|
3244
|
+
["git", *git_options, *arguments],
|
|
3245
|
+
cwd=directory,
|
|
3246
|
+
capture_output=True,
|
|
3247
|
+
encoding="utf-8",
|
|
3248
|
+
errors="replace",
|
|
3249
|
+
check=False,
|
|
3250
|
+
)
|
|
3251
|
+
except OSError:
|
|
3252
|
+
raise ValueError("git target repository cannot be resolved") from None
|
|
3253
|
+
if result.returncode != 0:
|
|
3254
|
+
raise ValueError("git target repository cannot be resolved")
|
|
3255
|
+
return result.stdout
|
|
3256
|
+
|
|
3257
|
+
|
|
3258
|
+
def registered_worktree_records(output, separator="\0"):
|
|
3259
|
+
records = {}
|
|
3260
|
+
for record in output.split(separator * 2):
|
|
3261
|
+
fields = [field for field in record.split(separator) if field]
|
|
3262
|
+
if not fields:
|
|
3263
|
+
continue
|
|
3264
|
+
paths = [field[len("worktree "):] for field in fields if field.startswith("worktree ")]
|
|
3265
|
+
branches = [field[len("branch "):] for field in fields if field.startswith("branch ")]
|
|
3266
|
+
if len(paths) != 1 or len(branches) > 1:
|
|
3267
|
+
raise ValueError("registered worktree metadata cannot be resolved")
|
|
3268
|
+
path = Path(paths[0])
|
|
3269
|
+
if not path.is_absolute():
|
|
3270
|
+
raise ValueError("registered worktree path is not absolute")
|
|
3271
|
+
resolved = path.resolve()
|
|
3272
|
+
if resolved in records:
|
|
3273
|
+
raise ValueError("registered worktree metadata is ambiguous")
|
|
3274
|
+
records[resolved] = branches[0] if branches else None
|
|
3275
|
+
if not records:
|
|
3276
|
+
raise ValueError("registered worktrees cannot be resolved")
|
|
3277
|
+
return records
|
|
3278
|
+
|
|
3279
|
+
|
|
3280
|
+
def git_target_contexts(git_options, working_directories):
|
|
3281
|
+
contexts = []
|
|
3282
|
+
for directory, effective_directory in zip(
|
|
3283
|
+
working_directories or [os.getcwd()],
|
|
3284
|
+
git_base_directories(git_options, working_directories),
|
|
3285
|
+
):
|
|
3286
|
+
root = Path(
|
|
3287
|
+
_git_output(git_options, directory, "rev-parse", "--show-toplevel").strip()
|
|
3288
|
+
).resolve()
|
|
3289
|
+
listing = ("worktree", "list", "--porcelain")
|
|
3290
|
+
try:
|
|
3291
|
+
output, separator = _git_output(git_options, directory, *listing, "-z"), "\0"
|
|
3292
|
+
except ValueError:
|
|
3293
|
+
# Git before 2.36 has no -z here; pipeline_git.worktree_list_porcelain
|
|
3294
|
+
# retries the same way. A line then ends each field.
|
|
3295
|
+
output, separator = _git_output(git_options, directory, *listing), "\n"
|
|
3296
|
+
records = registered_worktree_records(output, separator)
|
|
3297
|
+
if root not in records:
|
|
3298
|
+
raise ValueError("git target repository worktree cannot be resolved")
|
|
3299
|
+
common = Path(
|
|
3300
|
+
_git_output(git_options, directory, "rev-parse", "--git-common-dir").strip()
|
|
3301
|
+
)
|
|
3302
|
+
if not common.is_absolute():
|
|
3303
|
+
common = effective_directory / common
|
|
3304
|
+
contexts.append(
|
|
3305
|
+
{
|
|
3306
|
+
"root": root,
|
|
3307
|
+
"directory": directory,
|
|
3308
|
+
"effective_directory": effective_directory,
|
|
3309
|
+
"common": common.resolve(),
|
|
3310
|
+
"git_options": list(git_options),
|
|
3311
|
+
"worktrees": records,
|
|
3312
|
+
}
|
|
3313
|
+
)
|
|
3314
|
+
return contexts
|
|
3315
|
+
|
|
3316
|
+
|
|
3317
|
+
def path_owned_records(roots):
|
|
3318
|
+
branches, worktrees = set(), set()
|
|
3319
|
+
for root in roots:
|
|
3320
|
+
project = root / ".project"
|
|
3321
|
+
if not os.path.lexists(project):
|
|
3322
|
+
continue
|
|
3323
|
+
if is_link_like(project) or not project.is_dir():
|
|
3324
|
+
raise ValueError(".project ownership metadata cannot be resolved")
|
|
3325
|
+
state = ownership_frontmatter(project / "STATE.md", {"branch"}, missing_ok=True)
|
|
3326
|
+
if state.get("branch"):
|
|
3327
|
+
branches.add(state["branch"])
|
|
3328
|
+
task_files = []
|
|
3329
|
+
|
|
3330
|
+
def raise_walk_error(error):
|
|
3331
|
+
raise error
|
|
3332
|
+
|
|
3333
|
+
try:
|
|
3334
|
+
for directory, child_directories, filenames in os.walk(
|
|
3335
|
+
project, onerror=raise_walk_error, followlinks=False
|
|
3336
|
+
):
|
|
3337
|
+
if any(is_link_like(Path(directory) / name) for name in child_directories):
|
|
3338
|
+
raise ValueError("task ownership metadata contains a linked directory")
|
|
3339
|
+
if Path(directory).name == "tasks":
|
|
3340
|
+
task_files.extend(
|
|
3341
|
+
Path(directory) / name
|
|
3342
|
+
for name in filenames
|
|
3343
|
+
if name.endswith(".md")
|
|
3344
|
+
)
|
|
3345
|
+
except OSError as error:
|
|
3346
|
+
raise ValueError(f"task ownership metadata cannot be listed: {error}") from None
|
|
3347
|
+
task_files.sort()
|
|
3348
|
+
for task in task_files:
|
|
3349
|
+
fields = ownership_frontmatter(task, {"task_branch", "worktree"})
|
|
3350
|
+
if fields.get("task_branch"):
|
|
3351
|
+
branches.add(fields["task_branch"])
|
|
3352
|
+
recorded = fields.get("worktree")
|
|
3353
|
+
if recorded:
|
|
3354
|
+
path = Path(recorded).expanduser()
|
|
3355
|
+
if not path.is_absolute():
|
|
3356
|
+
path = root / path
|
|
3357
|
+
try:
|
|
3358
|
+
worktrees.add(path.resolve())
|
|
3359
|
+
except (OSError, RuntimeError):
|
|
3360
|
+
raise ValueError("task worktree ownership path cannot be resolved") from None
|
|
3361
|
+
return branches, worktrees
|
|
3362
|
+
|
|
3363
|
+
|
|
3364
|
+
def ref_is_path_owned(name, owned_branches):
|
|
3365
|
+
lowered = name.casefold()
|
|
3366
|
+
# git stash drop and clear are denied too; refs/stash holds each stash.
|
|
3367
|
+
if lowered.startswith("refs/gsd-path/") or lowered == "refs/stash":
|
|
3368
|
+
return True
|
|
3369
|
+
if lowered.startswith("refs/remotes/"):
|
|
3370
|
+
# The remote-tracking ref of a Path branch can be the last ref to its
|
|
3371
|
+
# commits. A remote name can contain a slash, so try each split.
|
|
3372
|
+
parts = name.split("/")[2:]
|
|
3373
|
+
return any(
|
|
3374
|
+
ref_is_path_owned("refs/heads/" + "/".join(parts[index:]), owned_branches)
|
|
3375
|
+
for index in range(1, len(parts))
|
|
3376
|
+
)
|
|
3377
|
+
short = name
|
|
3378
|
+
if short.startswith("refs/heads/"):
|
|
3379
|
+
short = short[len("refs/heads/"):]
|
|
3380
|
+
elif short.startswith("heads/"):
|
|
3381
|
+
short = short[len("heads/"):]
|
|
3382
|
+
if short.casefold().startswith(PATH_OWNED_REF_PREFIXES):
|
|
3383
|
+
return True
|
|
3384
|
+
|
|
3385
|
+
def canonical(value):
|
|
3386
|
+
if value.startswith("refs/heads/"):
|
|
3387
|
+
return value[len("refs/heads/"):]
|
|
3388
|
+
if value.startswith("heads/"):
|
|
3389
|
+
return value[len("heads/"):]
|
|
3390
|
+
return value
|
|
3391
|
+
|
|
3392
|
+
# Loose refs that differ only in case are one file on macOS and Windows.
|
|
3393
|
+
return any(
|
|
3394
|
+
canonical(name).casefold() == canonical(branch).casefold()
|
|
3395
|
+
for branch in owned_branches
|
|
3396
|
+
)
|
|
3397
|
+
|
|
3398
|
+
|
|
3399
|
+
def _literal_target(value):
|
|
3400
|
+
return bool(value) and not any(character in value for character in "*?[]{}~")
|
|
3401
|
+
|
|
3402
|
+
|
|
3403
|
+
def _positional_targets(arguments):
|
|
3404
|
+
targets = []
|
|
3405
|
+
after_separator = False
|
|
3406
|
+
for argument in arguments:
|
|
3407
|
+
if after_separator:
|
|
3408
|
+
targets.append(argument)
|
|
3409
|
+
elif argument == "--":
|
|
3410
|
+
after_separator = True
|
|
3411
|
+
elif not argument.startswith("-"):
|
|
3412
|
+
targets.append(argument)
|
|
3413
|
+
return targets
|
|
3414
|
+
|
|
3415
|
+
|
|
3416
|
+
PLAIN_REDIRECTION_OPERATORS = frozenset({">", ">>", "<", ">&", "&>", "&>>"})
|
|
3417
|
+
|
|
3418
|
+
|
|
3419
|
+
def _opens_process_substitution(token):
|
|
3420
|
+
return token in ("<(", ">(") and getattr(token, "literal", None) != "quoted"
|
|
3421
|
+
|
|
3422
|
+
|
|
3423
|
+
def _fold_process_substitutions(tokens):
|
|
3424
|
+
"""The tokens with each process substitution body moved onto its operator."""
|
|
3425
|
+
folded = []
|
|
3426
|
+
body = None
|
|
3427
|
+
depth = 0
|
|
3428
|
+
for token in tokens:
|
|
3429
|
+
opens = _opens_process_substitution(token) and (
|
|
3430
|
+
getattr(token, "body", None) is None
|
|
3431
|
+
)
|
|
3432
|
+
if depth == 0:
|
|
3433
|
+
if opens:
|
|
3434
|
+
depth, body = 1, []
|
|
3435
|
+
operator = ShellToken(token)
|
|
3436
|
+
operator.literal = getattr(token, "literal", None)
|
|
3437
|
+
operator.body = body
|
|
3438
|
+
token = operator
|
|
3439
|
+
folded.append(token)
|
|
3440
|
+
continue
|
|
3441
|
+
if opens:
|
|
3442
|
+
depth += 1
|
|
3443
|
+
elif token and set(token) <= set("|;&()\n\r") and (
|
|
3444
|
+
getattr(token, "literal", None) != "quoted"
|
|
3445
|
+
):
|
|
3446
|
+
for index, character in enumerate(token):
|
|
3447
|
+
depth += (character == "(") - (character == ")")
|
|
3448
|
+
if depth == 0:
|
|
3449
|
+
if token[index + 1:]:
|
|
3450
|
+
folded.append(ShellToken(token[index + 1:]))
|
|
3451
|
+
if token[:index]:
|
|
3452
|
+
body.append(token[:index])
|
|
3453
|
+
break
|
|
3454
|
+
if depth == 0:
|
|
3455
|
+
continue
|
|
3456
|
+
body.append(token)
|
|
3457
|
+
return folded
|
|
3458
|
+
|
|
3459
|
+
|
|
3460
|
+
def _runs_git_or_wrapper(segment, assignments):
|
|
3461
|
+
words, _exact = _without_redirections(segment)
|
|
3462
|
+
return (
|
|
3463
|
+
wrapped_command_tokens(words if len(words) > 1 else segment) is not None
|
|
3464
|
+
or git_command(words, assignments) is not None
|
|
3465
|
+
)
|
|
3466
|
+
|
|
3467
|
+
|
|
3468
|
+
def _holds_git_command(tokens, assignments):
|
|
3469
|
+
"""Whether the tokens run Git or a wrapper, at each process substitution depth."""
|
|
3470
|
+
for segment in command_segments(tokens):
|
|
3471
|
+
words = []
|
|
3472
|
+
for token in [*segment, None]:
|
|
3473
|
+
if token is not None and not _opens_process_substitution(token):
|
|
3474
|
+
if not getattr(token, "descriptor", False):
|
|
3475
|
+
words.append(token)
|
|
3476
|
+
continue
|
|
3477
|
+
if words and _runs_git_or_wrapper(words, assignments):
|
|
3478
|
+
return True
|
|
3479
|
+
words = []
|
|
3480
|
+
if getattr(token, "body", None) and _holds_git_command(
|
|
3481
|
+
token.body, assignments
|
|
3482
|
+
):
|
|
3483
|
+
return True
|
|
3484
|
+
return False
|
|
3485
|
+
|
|
3486
|
+
|
|
3487
|
+
def _without_descriptor(token):
|
|
3488
|
+
word = ShellToken(token)
|
|
3489
|
+
word.redirection = getattr(token, "redirection", False)
|
|
3490
|
+
word.literal = getattr(token, "literal", None)
|
|
3491
|
+
return word
|
|
3492
|
+
|
|
3493
|
+
|
|
3494
|
+
def _without_redirections(words):
|
|
3495
|
+
"""The words of a simple command without the redirections the shell parsed.
|
|
3496
|
+
|
|
3497
|
+
Each redirection operator goes with the word after it and one file
|
|
3498
|
+
descriptor digit written against it. The second result is false when one
|
|
3499
|
+
of them is not plain: an operator outside PLAIN_REDIRECTION_OPERATORS or
|
|
3500
|
+
a target that is not one literal word. A process substitution operator is
|
|
3501
|
+
removed too and is never plain.
|
|
3502
|
+
"""
|
|
3503
|
+
kept = []
|
|
3504
|
+
exact = True
|
|
3505
|
+
index = 0
|
|
3506
|
+
while index < len(words):
|
|
3507
|
+
word = words[index]
|
|
3508
|
+
if _opens_process_substitution(word):
|
|
3509
|
+
exact = False
|
|
3510
|
+
index += 1
|
|
3511
|
+
continue
|
|
3512
|
+
if not getattr(word, "redirection", False):
|
|
3513
|
+
kept.append(word)
|
|
3514
|
+
index += 1
|
|
3515
|
+
continue
|
|
3516
|
+
if word.isdigit():
|
|
3517
|
+
index += 1
|
|
3518
|
+
word = words[index] if index < len(words) else ""
|
|
3519
|
+
target = words[index + 1] if index + 1 < len(words) else ""
|
|
3520
|
+
exact = exact and not (
|
|
3521
|
+
word not in PLAIN_REDIRECTION_OPERATORS
|
|
3522
|
+
or getattr(target, "redirection", False)
|
|
3523
|
+
or getattr(target, "literal", None) not in ("bare", "quoted")
|
|
3524
|
+
or not _literal_target(target)
|
|
3525
|
+
or any(character in target for character in "$`<>()&|#")
|
|
3526
|
+
)
|
|
3527
|
+
index += 2
|
|
3528
|
+
return kept, exact
|
|
3529
|
+
|
|
3530
|
+
|
|
3531
|
+
def _frontmatter_roots(contexts):
|
|
3532
|
+
return {
|
|
3533
|
+
path
|
|
3534
|
+
for context in contexts
|
|
3535
|
+
for path in context["worktrees"]
|
|
3536
|
+
}
|
|
3537
|
+
|
|
3538
|
+
|
|
3539
|
+
def _git_refs(context):
|
|
3540
|
+
output = _git_output(
|
|
3541
|
+
context["git_options"],
|
|
3542
|
+
context["directory"],
|
|
3543
|
+
"for-each-ref",
|
|
3544
|
+
"--format=%(refname)",
|
|
3545
|
+
)
|
|
3546
|
+
return {line for line in output.splitlines() if line}
|
|
3547
|
+
|
|
3548
|
+
|
|
3549
|
+
def _symbolic_ref_target(context, target):
|
|
3550
|
+
try:
|
|
3551
|
+
result = subprocess.run(
|
|
3552
|
+
["git", *context["git_options"], "symbolic-ref", "--quiet", target],
|
|
3553
|
+
cwd=context["directory"],
|
|
3554
|
+
capture_output=True,
|
|
3555
|
+
encoding="utf-8",
|
|
3556
|
+
errors="replace",
|
|
3557
|
+
check=False,
|
|
3558
|
+
)
|
|
3559
|
+
except OSError:
|
|
3560
|
+
raise ValueError("git ref target cannot be resolved") from None
|
|
3561
|
+
if result.returncode == 1:
|
|
3562
|
+
return None
|
|
3563
|
+
if result.returncode != 0:
|
|
3564
|
+
raise ValueError("git ref target cannot be resolved")
|
|
3565
|
+
return result.stdout.strip()
|
|
3566
|
+
|
|
3567
|
+
|
|
3568
|
+
def _update_ref_names(target, refs, context, no_deref):
|
|
3569
|
+
"""The ref names a deletion of this operand touches, or None when unresolved.
|
|
3570
|
+
|
|
3571
|
+
Git reads an update-ref operand as a full name below the git directory,
|
|
3572
|
+
never as a branch short name. It follows each symbolic ref to the ref it
|
|
3573
|
+
deletes; with --no-deref it deletes the named symbolic ref itself. A name
|
|
3574
|
+
outside refs/ (HEAD, a symbolic ref at the top of the git directory) is
|
|
3575
|
+
unresolved: an ordinary command such as git switch changes its target.
|
|
3576
|
+
"""
|
|
3577
|
+
if not target.startswith("refs/"):
|
|
3578
|
+
return None
|
|
3579
|
+
names = [target]
|
|
3580
|
+
while True:
|
|
3581
|
+
symbolic = _symbolic_ref_target(context, names[-1])
|
|
3582
|
+
if symbolic is None:
|
|
3583
|
+
return names if names[-1] in refs else None
|
|
3584
|
+
if no_deref:
|
|
3585
|
+
return names
|
|
3586
|
+
if symbolic in names:
|
|
3587
|
+
return None
|
|
3588
|
+
names.append(symbolic)
|
|
3589
|
+
|
|
3590
|
+
|
|
3591
|
+
def _workspace_roots(contexts):
|
|
3592
|
+
roots = [(Path.home() / ".gsd-path").resolve()]
|
|
3593
|
+
configured = os.environ.get("GSD_PATH_WORKTREE_ROOT")
|
|
3594
|
+
if configured:
|
|
3595
|
+
configured_root = Path(configured).expanduser()
|
|
3596
|
+
if not configured_root.is_absolute():
|
|
3597
|
+
raise ValueError("GSD_PATH_WORKTREE_ROOT must be absolute")
|
|
3598
|
+
roots.append(configured_root.resolve())
|
|
3599
|
+
for common in {context["common"] for context in contexts}:
|
|
3600
|
+
receipts = common / "gsd-path" / "workspaces"
|
|
3601
|
+
if not os.path.lexists(receipts):
|
|
3602
|
+
continue
|
|
3603
|
+
if is_link_like(receipts) or not receipts.is_dir():
|
|
3604
|
+
raise ValueError("pinned workspace receipts cannot be resolved")
|
|
3605
|
+
try:
|
|
3606
|
+
with os.scandir(receipts) as entries:
|
|
3607
|
+
receipt_files = sorted(
|
|
3608
|
+
(Path(entry.path) for entry in entries if entry.name.endswith(".json")),
|
|
3609
|
+
key=lambda path: path.name,
|
|
3610
|
+
)
|
|
3611
|
+
except OSError as error:
|
|
3612
|
+
raise ValueError(f"pinned workspace receipts cannot be listed: {error}") from None
|
|
3613
|
+
for receipt in receipt_files:
|
|
3614
|
+
try:
|
|
3615
|
+
metadata = receipt.lstat()
|
|
3616
|
+
if not stat.S_ISREG(metadata.st_mode):
|
|
3617
|
+
raise ValueError("receipt is not a regular file")
|
|
3618
|
+
data = json.loads(receipt.read_text(encoding="utf-8"))
|
|
3619
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError, ValueError):
|
|
3620
|
+
raise ValueError(
|
|
3621
|
+
f"pinned workspace receipt cannot be read: {receipt.name}"
|
|
3622
|
+
) from None
|
|
3623
|
+
if (
|
|
3624
|
+
not isinstance(data, dict)
|
|
3625
|
+
or not isinstance(data.get("primary"), str)
|
|
3626
|
+
or not isinstance(data.get("root"), str)
|
|
3627
|
+
or not Path(data["primary"]).is_absolute()
|
|
3628
|
+
or not Path(data["root"]).is_absolute()
|
|
3629
|
+
):
|
|
3630
|
+
raise ValueError(
|
|
3631
|
+
f"pinned workspace receipt is malformed: {receipt.name}"
|
|
3632
|
+
)
|
|
3633
|
+
roots.append(Path(data["root"]).resolve())
|
|
3634
|
+
return roots
|
|
3635
|
+
|
|
3636
|
+
|
|
3637
|
+
def owned_target_reason(command, arguments, git_options, working_directories):
|
|
3638
|
+
action = {
|
|
3639
|
+
"branch": "git branch -D",
|
|
3640
|
+
"update-ref": "git update-ref deletion",
|
|
3641
|
+
"worktree": "git worktree remove --force",
|
|
3642
|
+
}[command]
|
|
3643
|
+
unresolved = GIT_REASONS["unresolved-target"].format(action=action)
|
|
3644
|
+
# The shell expands a leading ~ and a glob in an option value such as the
|
|
3645
|
+
# -C directory; the guard has only the literal text.
|
|
3646
|
+
if any(
|
|
3647
|
+
value[:1] == "~" or any(character in value for character in "*?[]{}")
|
|
3648
|
+
for value in git_options[1::2]
|
|
3649
|
+
):
|
|
3650
|
+
return unresolved
|
|
3651
|
+
contexts = git_target_contexts(git_options, working_directories)
|
|
3652
|
+
branches, task_worktrees = path_owned_records(_frontmatter_roots(contexts))
|
|
3653
|
+
|
|
3654
|
+
if command == "branch":
|
|
3655
|
+
# Each word that is not an option is read as a branch name. Git does
|
|
3656
|
+
# the same after --color; after an option with a separate value
|
|
3657
|
+
# (--sort key) this only adds a name to check.
|
|
3658
|
+
targets = _positional_targets(arguments)
|
|
3659
|
+
if not targets or not all(_literal_target(target) for target in targets):
|
|
3660
|
+
return unresolved
|
|
3661
|
+
remote = has_short_option(arguments, "r") or any(
|
|
3662
|
+
git_option_in(argument, {"--remotes"}) for argument in arguments
|
|
3663
|
+
)
|
|
3664
|
+
for target in targets:
|
|
3665
|
+
# Git reads the name in exactly one namespace, with no other form.
|
|
3666
|
+
ref = f"refs/{'remotes' if remote else 'heads'}/{target}"
|
|
3667
|
+
if ref_is_path_owned(ref, branches) or ref_is_path_owned(
|
|
3668
|
+
f"refs/heads/{target}", branches
|
|
3669
|
+
):
|
|
3670
|
+
return GIT_REASONS["branch"]
|
|
3671
|
+
if any(ref not in _git_refs(context) for context in contexts):
|
|
3672
|
+
return unresolved
|
|
3673
|
+
return None
|
|
3674
|
+
|
|
3675
|
+
if command == "update-ref":
|
|
3676
|
+
operands, options = [], set()
|
|
3677
|
+
index = 0
|
|
3678
|
+
while index < len(arguments):
|
|
3679
|
+
argument = arguments[index]
|
|
3680
|
+
if argument == "-m": # its value is not an option, also when it is -d
|
|
3681
|
+
index += 2
|
|
3682
|
+
continue
|
|
3683
|
+
if argument.startswith("-"):
|
|
3684
|
+
options.add(argument)
|
|
3685
|
+
index += 1
|
|
3686
|
+
continue
|
|
3687
|
+
operands.append(argument)
|
|
3688
|
+
index += 1
|
|
3689
|
+
deletion_flag = "-d" in options
|
|
3690
|
+
expected_counts = {1, 2} if deletion_flag else {2, 3}
|
|
3691
|
+
if len(operands) not in expected_counts or not _literal_target(operands[0]):
|
|
3692
|
+
return unresolved
|
|
3693
|
+
if not deletion_flag and not (operands[1] == "" or not operands[1].strip("0")):
|
|
3694
|
+
return unresolved
|
|
3695
|
+
if deletion_flag and len(operands) == 2 and not _literal_target(operands[1]):
|
|
3696
|
+
return unresolved
|
|
3697
|
+
if not deletion_flag and len(operands) == 3 and not _literal_target(operands[2]):
|
|
3698
|
+
return unresolved
|
|
3699
|
+
# Dereferencing a symbolic ref must not erase ownership of its name.
|
|
3700
|
+
if ref_is_path_owned(operands[0], branches):
|
|
3701
|
+
return GIT_REASONS["update-ref"]
|
|
3702
|
+
for context in contexts:
|
|
3703
|
+
names = _update_ref_names(
|
|
3704
|
+
operands[0], _git_refs(context), context, "--no-deref" in options
|
|
3705
|
+
)
|
|
3706
|
+
if names is None:
|
|
3707
|
+
return unresolved
|
|
3708
|
+
if any(ref_is_path_owned(name, branches) for name in names):
|
|
3709
|
+
return GIT_REASONS["update-ref"]
|
|
3710
|
+
return None
|
|
3711
|
+
|
|
3712
|
+
targets = _positional_targets(arguments[1:])
|
|
3713
|
+
if len(targets) != 1 or not _literal_target(targets[0]):
|
|
3714
|
+
return unresolved
|
|
3715
|
+
recorded_paths = set(task_worktrees)
|
|
3716
|
+
managed_roots = _workspace_roots(contexts)
|
|
3717
|
+
for context in contexts:
|
|
3718
|
+
target = Path(targets[0])
|
|
3719
|
+
if not target.is_absolute():
|
|
3720
|
+
target = context["effective_directory"] / target
|
|
3721
|
+
resolved_target = None
|
|
3722
|
+
try:
|
|
3723
|
+
resolved_target = target.resolve(strict=True)
|
|
3724
|
+
except (OSError, RuntimeError):
|
|
3725
|
+
pass
|
|
3726
|
+
registered = context["worktrees"]
|
|
3727
|
+
# Git tries a unique path suffix first, then the path, and compares
|
|
3728
|
+
# without case on macOS and Windows. Both readings must agree.
|
|
3729
|
+
matches = {resolved_target} & set(registered)
|
|
3730
|
+
suffix = tuple(
|
|
3731
|
+
part.casefold() for part in Path(targets[0]).parts if part not in {"", "."}
|
|
3732
|
+
)
|
|
3733
|
+
if suffix and not Path(targets[0]).is_absolute() and ".." not in suffix:
|
|
3734
|
+
by_suffix = [
|
|
3735
|
+
path for path in registered
|
|
3736
|
+
if tuple(part.casefold() for part in path.parts[-len(suffix):]) == suffix
|
|
3737
|
+
]
|
|
3738
|
+
if len(by_suffix) == 1:
|
|
3739
|
+
matches.add(by_suffix[0])
|
|
3740
|
+
if len(matches) != 1:
|
|
3741
|
+
return unresolved
|
|
3742
|
+
resolved_target = matches.pop()
|
|
3743
|
+
if not resolved_target.is_dir():
|
|
3744
|
+
return unresolved
|
|
3745
|
+
branch = registered[resolved_target]
|
|
3746
|
+
primary = next(iter(registered))
|
|
3747
|
+
if (
|
|
3748
|
+
resolved_target in recorded_paths
|
|
3749
|
+
or (branch is not None and ref_is_path_owned(branch, branches))
|
|
3750
|
+
or any(
|
|
3751
|
+
resolved_target == root or resolved_target.is_relative_to(root)
|
|
3752
|
+
for root in (*managed_roots, primary.parent / f"{primary.name}.gsd-path")
|
|
3753
|
+
)
|
|
3754
|
+
or (
|
|
3755
|
+
resolved_target.parent == primary.parent
|
|
3756
|
+
and resolved_target.name.startswith(f".{primary.name}-gsd-path-integrate-")
|
|
3757
|
+
)
|
|
3758
|
+
):
|
|
3759
|
+
return GIT_REASONS["worktree"]
|
|
3760
|
+
return None
|
|
3761
|
+
|
|
3762
|
+
|
|
3763
|
+
def _path_has_symlink_component(value):
|
|
3764
|
+
path = Path(value)
|
|
3765
|
+
if not path.is_absolute():
|
|
3766
|
+
path = Path.cwd() / path
|
|
3767
|
+
current = Path(path.anchor)
|
|
3768
|
+
for component in path.parts[1:]:
|
|
3769
|
+
if component in {"", "."}:
|
|
3770
|
+
continue
|
|
3771
|
+
if component == "..":
|
|
3772
|
+
current = current.parent
|
|
3773
|
+
continue
|
|
3774
|
+
current = current / component
|
|
3775
|
+
try:
|
|
3776
|
+
if os.path.lexists(current) and is_link_like(current):
|
|
3777
|
+
return True
|
|
3778
|
+
except OSError:
|
|
3779
|
+
return True
|
|
3780
|
+
return False
|
|
3781
|
+
|
|
3782
|
+
|
|
3783
|
+
def env_wrapper_changes_git_context(segment):
|
|
3784
|
+
"""Whether an env wrapper makes ownership or alias lookup context uncertain."""
|
|
3785
|
+
index = 0
|
|
3786
|
+
while index < len(segment) and SHELL_ASSIGNMENT_PATTERN.match(segment[index]):
|
|
3787
|
+
index += 1
|
|
3788
|
+
if index >= len(segment):
|
|
3789
|
+
return False
|
|
3790
|
+
executable = segment[index].replace("\\", "/").rsplit("/", 1)[-1].casefold()
|
|
3791
|
+
if executable != "env":
|
|
3792
|
+
return False
|
|
3793
|
+
|
|
3794
|
+
def affects_git_context(name):
|
|
3795
|
+
folded = name.upper()
|
|
3796
|
+
return folded.startswith("GIT_") or folded in {"HOME", "XDG_CONFIG_HOME"}
|
|
3797
|
+
|
|
3798
|
+
index += 1
|
|
3799
|
+
while index < len(segment):
|
|
3800
|
+
token = segment[index]
|
|
3801
|
+
if token == "--":
|
|
3802
|
+
return False
|
|
3803
|
+
if token in {"-i", "--ignore-environment"}:
|
|
3804
|
+
return True
|
|
3805
|
+
if token.startswith("--unset="):
|
|
3806
|
+
if affects_git_context(token.split("=", 1)[1]):
|
|
3807
|
+
return True
|
|
3808
|
+
index += 1
|
|
3809
|
+
continue
|
|
3810
|
+
if token in {"-u", "--unset"}:
|
|
3811
|
+
if index + 1 >= len(segment):
|
|
3812
|
+
return False
|
|
3813
|
+
if affects_git_context(segment[index + 1]):
|
|
3814
|
+
return True
|
|
3815
|
+
index += 2
|
|
3816
|
+
continue
|
|
3817
|
+
if SHELL_ASSIGNMENT_PATTERN.match(token):
|
|
3818
|
+
if affects_git_context(token.partition("=")[0]):
|
|
3819
|
+
return True
|
|
3820
|
+
index += 1
|
|
3821
|
+
continue
|
|
3822
|
+
option = token.split("=", 1)[0]
|
|
3823
|
+
if option in ENV_OPTIONS_WITH_VALUES:
|
|
3824
|
+
index += 1 if "=" in token else 2
|
|
3825
|
+
continue
|
|
3826
|
+
if option in ENV_OPTIONS_WITHOUT_VALUES:
|
|
3827
|
+
index += 1
|
|
3828
|
+
continue
|
|
3829
|
+
return False
|
|
3830
|
+
return False
|
|
3831
|
+
|
|
3832
|
+
|
|
3833
|
+
def ownership_cwd_is_uncertain(tokens, working_directories=None):
|
|
3834
|
+
"""Whether a scoped delete follows a cd whose shell control flow is unclear."""
|
|
3835
|
+
cwd_wrappers = COMMAND_WRAPPERS | {
|
|
3836
|
+
"eval", "source", ".",
|
|
3837
|
+
# These change what a later cd does, or run a cd later.
|
|
3838
|
+
"alias", "trap", "enable", "disable",
|
|
3839
|
+
}
|
|
3840
|
+
segments = []
|
|
3841
|
+
current = []
|
|
3842
|
+
separator = None
|
|
3843
|
+
depth = 0
|
|
3844
|
+
comment = False
|
|
3845
|
+
for token in tokens:
|
|
3846
|
+
# Most shells run nothing from a # word to the end of the line, but
|
|
3847
|
+
# the guard reads that text as commands. A cd there can run or not.
|
|
3848
|
+
if comment and "\n" in token:
|
|
3849
|
+
if not set(token) <= set("|;&()\n\r"):
|
|
3850
|
+
return True
|
|
3851
|
+
comment = False
|
|
3852
|
+
elif comment and token.casefold() in DIRECTORY_CHANGE_COMMANDS:
|
|
3853
|
+
return True
|
|
3854
|
+
if token and set(token) <= set("|;&()\n\r"):
|
|
3855
|
+
if "()" in token:
|
|
3856
|
+
return True # a function definition: its body runs only on a call
|
|
3857
|
+
if current:
|
|
3858
|
+
segments.append((current, separator, depth))
|
|
3859
|
+
current = []
|
|
3860
|
+
if "(" in token:
|
|
3861
|
+
depth += token.count("(")
|
|
3862
|
+
if ")" in token:
|
|
3863
|
+
depth = max(0, depth - token.count(")"))
|
|
3864
|
+
separator = token.strip()
|
|
3865
|
+
else:
|
|
3866
|
+
comment = comment or (
|
|
3867
|
+
token.startswith("#") and getattr(token, "literal", None) != "quoted"
|
|
3868
|
+
)
|
|
3869
|
+
current.append(token)
|
|
3870
|
+
if current:
|
|
3871
|
+
segments.append((current, separator, depth))
|
|
3872
|
+
|
|
3873
|
+
uncertain = False
|
|
3874
|
+
conditional = False
|
|
3875
|
+
current_directories = list(working_directories or [os.getcwd()])
|
|
3876
|
+
initial_symlink = any(
|
|
3877
|
+
_path_has_symlink_component(directory) for directory in current_directories
|
|
3878
|
+
)
|
|
3879
|
+
for index, (segment, previous_separator, group_depth) in enumerate(segments):
|
|
3880
|
+
plain, _exact = _without_redirections(segment)
|
|
3881
|
+
redirected = len(plain) != len(segment)
|
|
3882
|
+
segment = plain
|
|
3883
|
+
if not segment:
|
|
3884
|
+
continue
|
|
3885
|
+
if env_wrapper_changes_git_context(segment):
|
|
3886
|
+
return True
|
|
3887
|
+
if segment[0] == "function":
|
|
3888
|
+
return True
|
|
3889
|
+
if any(token.casefold() in SHELL_CONTROL_WORDS for token in segment):
|
|
3890
|
+
conditional = True
|
|
3891
|
+
while segment and segment[0].casefold() in SHELL_CONTROL_WORDS:
|
|
3892
|
+
segment = segment[1:]
|
|
3893
|
+
invocation = command_invocation(segment)
|
|
3894
|
+
if invocation is None:
|
|
3895
|
+
continue
|
|
3896
|
+
command, arguments = invocation
|
|
3897
|
+
next_separator = (
|
|
3898
|
+
segments[index + 1][1] if index + 1 < len(segments) else None
|
|
3899
|
+
)
|
|
3900
|
+
if command in DIRECTORY_CHANGE_COMMANDS:
|
|
3901
|
+
target = directory_change_target(arguments)
|
|
3902
|
+
absolute = is_absolute_path(target)
|
|
3903
|
+
dotted = target.replace("\\", "/")
|
|
3904
|
+
next_directories = (
|
|
3905
|
+
[target]
|
|
3906
|
+
if absolute
|
|
3907
|
+
else [f"{directory}/{target}" for directory in current_directories]
|
|
3908
|
+
)
|
|
3909
|
+
if (
|
|
3910
|
+
# Only a plain `cd <directory>` has the same effect in each shell.
|
|
3911
|
+
segment[0] != "cd"
|
|
3912
|
+
or redirected
|
|
3913
|
+
or arguments not in ([target], ["--", target])
|
|
3914
|
+
# A shell with CDPATH looks for a plain relative name in other
|
|
3915
|
+
# directories first; ./name and ../name are not looked up there.
|
|
3916
|
+
or not (
|
|
3917
|
+
absolute
|
|
3918
|
+
or dotted in {".", ".."}
|
|
3919
|
+
or dotted.startswith(("./", "../"))
|
|
3920
|
+
)
|
|
3921
|
+
or any(character in target for character in "*?[]{}$")
|
|
3922
|
+
or group_depth > 0
|
|
3923
|
+
or previous_separator in {"&&", "||", "&"}
|
|
3924
|
+
or next_separator in {"||", "&"}
|
|
3925
|
+
or (previous_separator is not None and "|" in previous_separator)
|
|
3926
|
+
or (next_separator is not None and "|" in next_separator)
|
|
3927
|
+
or conditional
|
|
3928
|
+
or initial_symlink
|
|
3929
|
+
or any(_path_has_symlink_component(path) for path in next_directories)
|
|
3930
|
+
):
|
|
3931
|
+
uncertain = True
|
|
3932
|
+
current_directories = next_directories
|
|
3933
|
+
initial_symlink = False
|
|
3934
|
+
if command in cwd_wrappers and index + 1 < len(segments):
|
|
3935
|
+
uncertain = True
|
|
3936
|
+
if uncertain and index + 1 < len(segments):
|
|
3937
|
+
return True
|
|
3938
|
+
if any(token.casefold() in {"fi", "done", "esac"} for token in segment):
|
|
3939
|
+
conditional = False
|
|
3940
|
+
return False
|
|
3941
|
+
|
|
3942
|
+
|
|
3943
|
+
def assignments_change_git_context(assignments):
|
|
3944
|
+
return any(
|
|
3945
|
+
name.upper().startswith("GIT_")
|
|
3946
|
+
or name.upper() in {"HOME", "XDG_CONFIG_HOME"}
|
|
3947
|
+
for name in assignments
|
|
3948
|
+
)
|
|
3949
|
+
|
|
3950
|
+
GIT_CONTEXT_NAME = re.compile(r"GIT_\w+|HOME|XDG_CONFIG_HOME")
|
|
3951
|
+
# export, declare, typeset, local, readonly and unset of these names are
|
|
3952
|
+
# denied where the guard reads assignments.
|
|
3953
|
+
VARIABLE_SETTING_COMMANDS = frozenset(
|
|
3954
|
+
{"read", "getopts", "mapfile", "readarray", "integer", "float", "vared"}
|
|
3955
|
+
)
|
|
3956
|
+
LOOP_VARIABLE_WORDS = frozenset({"for", "select"})
|
|
3957
|
+
|
|
3958
|
+
|
|
3959
|
+
def command_sets_git_context_name(tokens):
|
|
3960
|
+
"""Whether a command that the guard does not track sets a Git context variable."""
|
|
3961
|
+
words = []
|
|
3962
|
+
for token in [*tokens, ";"]:
|
|
3963
|
+
if not (token and set(token) <= set("|;&()\n\r")):
|
|
3964
|
+
words.append(token)
|
|
3965
|
+
continue
|
|
3966
|
+
words, _exact = _without_redirections(words)
|
|
3967
|
+
while words and (
|
|
3968
|
+
words[0].casefold() in SHELL_CONTROL_WORDS - LOOP_VARIABLE_WORDS
|
|
3969
|
+
or words[0] in COMMAND_WRAPPERS
|
|
3970
|
+
or SHELL_ASSIGNMENT_PATTERN.match(words[0])
|
|
3971
|
+
):
|
|
3972
|
+
del words[0]
|
|
3973
|
+
command, operands, words = "".join(words[:1]), words[1:], []
|
|
3974
|
+
if command in LOOP_VARIABLE_WORDS:
|
|
3975
|
+
operands = operands[:1]
|
|
3976
|
+
elif command in {"printf", "print"}:
|
|
3977
|
+
operands = [
|
|
3978
|
+
name for option, name in zip(operands, operands[1:]) if option == "-v"
|
|
3979
|
+
] + [option[2:] for option in operands if option.startswith("-v")]
|
|
3980
|
+
elif command not in VARIABLE_SETTING_COMMANDS:
|
|
3981
|
+
continue
|
|
3982
|
+
if any(GIT_CONTEXT_NAME.fullmatch(operand) for operand in operands):
|
|
3983
|
+
return True
|
|
3984
|
+
return False
|
|
3985
|
+
|
|
3986
|
+
|
|
2976
3987
|
def destructive_git_reason(
|
|
2977
|
-
tokens,
|
|
3988
|
+
tokens,
|
|
3989
|
+
resolved_aliases=frozenset(),
|
|
3990
|
+
initial_assignments=None,
|
|
3991
|
+
working_directories=None,
|
|
3992
|
+
cwd_reliable=True,
|
|
3993
|
+
git_context_reliable=True,
|
|
3994
|
+
earlier_git=False,
|
|
3995
|
+
fold=True,
|
|
2978
3996
|
):
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
3997
|
+
# A process substitution has two readings: the tokens as they stand, and
|
|
3998
|
+
# the tokens with each substitution body folded onto its operator.
|
|
3999
|
+
folded = _fold_process_substitutions(tokens) if fold else tokens
|
|
4000
|
+
if len(folded) != len(tokens):
|
|
4001
|
+
reason = destructive_git_reason(
|
|
4002
|
+
tokens,
|
|
4003
|
+
resolved_aliases,
|
|
4004
|
+
initial_assignments=initial_assignments,
|
|
4005
|
+
working_directories=working_directories,
|
|
4006
|
+
cwd_reliable=cwd_reliable,
|
|
4007
|
+
git_context_reliable=git_context_reliable,
|
|
4008
|
+
earlier_git=earlier_git,
|
|
4009
|
+
fold=False,
|
|
4010
|
+
)
|
|
4011
|
+
if reason is not None:
|
|
4012
|
+
return reason
|
|
4013
|
+
tokens = folded
|
|
4014
|
+
cwd_reliable = cwd_reliable and not ownership_cwd_is_uncertain(
|
|
4015
|
+
tokens, working_directories
|
|
4016
|
+
)
|
|
4017
|
+
git_context_reliable = (
|
|
4018
|
+
git_context_reliable and not command_sets_git_context_name(tokens)
|
|
4019
|
+
)
|
|
4020
|
+
this_git = False
|
|
4021
|
+
for segment, directories, assignments, _prefix in shell_segment_contexts(
|
|
4022
|
+
tokens, working_directories or [os.getcwd()], initial_assignments
|
|
4023
|
+
):
|
|
4024
|
+
# Whether a Git command or a wrapped command ran before this segment.
|
|
4025
|
+
earlier_git = earlier_git or this_git
|
|
4026
|
+
# A dollar expansion can introduce literal percent/bang text; the second
|
|
4027
|
+
# expansion pass must not silently reinterpret it as a different path.
|
|
4028
|
+
cwd_reliable = cwd_reliable and not any(
|
|
4029
|
+
CMD_PARAMETER_SYNTAX.search(NAMED_SHELL_PARAMETER_SYNTAX.sub(
|
|
4030
|
+
lambda match: environment_parameter_value(match, assignments), token
|
|
4031
|
+
))
|
|
4032
|
+
for token in segment
|
|
4033
|
+
)
|
|
4034
|
+
# A descriptor word is a file descriptor in bash and an argument in
|
|
4035
|
+
# zsh; a process substitution runs a second command. Each reading is
|
|
4036
|
+
# checked, and a scoped deletion in one of them is denied.
|
|
4037
|
+
descriptor = any(getattr(token, "descriptor", False) for token in segment)
|
|
4038
|
+
if descriptor:
|
|
4039
|
+
readings = [
|
|
4040
|
+
[token for token in segment if not getattr(token, "descriptor", False)],
|
|
4041
|
+
[
|
|
4042
|
+
_without_descriptor(token)
|
|
4043
|
+
if getattr(token, "descriptor", False) else token
|
|
4044
|
+
for token in segment
|
|
4045
|
+
],
|
|
4046
|
+
]
|
|
4047
|
+
else:
|
|
4048
|
+
readings = [token.body for token in segment if getattr(token, "body", None)]
|
|
4049
|
+
for reading in readings:
|
|
4050
|
+
reason = destructive_git_reason(
|
|
4051
|
+
reading,
|
|
4052
|
+
resolved_aliases,
|
|
4053
|
+
initial_assignments=assignments,
|
|
4054
|
+
working_directories=directories,
|
|
4055
|
+
cwd_reliable=False,
|
|
4056
|
+
git_context_reliable=git_context_reliable,
|
|
4057
|
+
earlier_git=earlier_git,
|
|
4058
|
+
)
|
|
4059
|
+
if reason is not None:
|
|
4060
|
+
return reason
|
|
4061
|
+
nested_git = (
|
|
4062
|
+
descriptor or any(_opens_process_substitution(token) for token in segment)
|
|
4063
|
+
) and _holds_git_command(segment, assignments)
|
|
4064
|
+
if descriptor:
|
|
4065
|
+
this_git = nested_git
|
|
4066
|
+
continue
|
|
4067
|
+
# A redirection can stand at each position of a simple command.
|
|
4068
|
+
words, exact = _without_redirections(segment)
|
|
4069
|
+
segment_git_context_reliable = (
|
|
4070
|
+
git_context_reliable
|
|
4071
|
+
and not env_wrapper_changes_git_context(words)
|
|
4072
|
+
and not assignments_change_git_context(assignments)
|
|
4073
|
+
)
|
|
4074
|
+
wrapped = wrapped_command_tokens(words if len(words) > 1 else segment)
|
|
2983
4075
|
if wrapped is not None:
|
|
2984
|
-
reason = destructive_git_reason(
|
|
4076
|
+
reason = destructive_git_reason(
|
|
4077
|
+
wrapped,
|
|
4078
|
+
resolved_aliases,
|
|
4079
|
+
initial_assignments=assignments,
|
|
4080
|
+
working_directories=directories,
|
|
4081
|
+
cwd_reliable=cwd_reliable and exact,
|
|
4082
|
+
git_context_reliable=segment_git_context_reliable,
|
|
4083
|
+
earlier_git=earlier_git,
|
|
4084
|
+
)
|
|
2985
4085
|
if reason is not None:
|
|
2986
4086
|
return reason
|
|
2987
|
-
invocation = git_command(
|
|
4087
|
+
invocation = git_command(words, assignments)
|
|
4088
|
+
this_git = nested_git or wrapped is not None or invocation is not None
|
|
2988
4089
|
if invocation is None:
|
|
2989
4090
|
continue
|
|
2990
4091
|
command, arguments, git_options = invocation
|
|
4092
|
+
# Git matches its own commands by exact case, but alias names in any case.
|
|
4093
|
+
built_in = (
|
|
4094
|
+
command_invocation(words)[1][-len(arguments) - 1] in GIT_BUILT_IN_COMMANDS
|
|
4095
|
+
)
|
|
4096
|
+
if not segment_git_context_reliable and not built_in:
|
|
4097
|
+
return GIT_REASONS["git-environment"]
|
|
4098
|
+
# A scoped deletion needs literal words: the shell can change a variable
|
|
4099
|
+
# in ways the guard does not track (read, +=, for, printf -v).
|
|
4100
|
+
resolvable = cwd_reliable and exact and not any(
|
|
4101
|
+
SHELL_PARAMETER_SYNTAX.search(token) for token in segment
|
|
4102
|
+
)
|
|
4103
|
+
directories = [native_path_text(directory) for directory in directories]
|
|
2991
4104
|
if command == "config":
|
|
2992
4105
|
alias_indexes = [
|
|
2993
4106
|
index
|
|
@@ -2999,17 +4112,32 @@ def destructive_git_reason(
|
|
|
2999
4112
|
"git config alias.* defines an alias the guard cannot inspect; "
|
|
3000
4113
|
"run the underlying git command directly"
|
|
3001
4114
|
)
|
|
3002
|
-
|
|
4115
|
+
# A directory that an earlier segment creates does not exist yet.
|
|
4116
|
+
alias = None
|
|
4117
|
+
if not built_in or all(os.path.isdir(directory) for directory in directories):
|
|
4118
|
+
alias = git_alias(command, git_options, directories)
|
|
3003
4119
|
if alias is not None:
|
|
3004
4120
|
if command in resolved_aliases:
|
|
3005
4121
|
raise ValueError(
|
|
3006
4122
|
f"git alias {command} refers to itself; run the underlying "
|
|
3007
4123
|
"git command directly"
|
|
3008
4124
|
)
|
|
4125
|
+
# Git passes each word of an alias on as an argument; it runs no shell.
|
|
4126
|
+
alias_words = shell_tokens(alias, direct=False)
|
|
4127
|
+
for word in alias_words:
|
|
4128
|
+
word.redirection = False
|
|
3009
4129
|
reason = destructive_git_reason(
|
|
3010
|
-
["git", *git_options, *
|
|
4130
|
+
["git", *git_options, *alias_words, *arguments],
|
|
3011
4131
|
resolved_aliases | {command},
|
|
3012
|
-
assignments,
|
|
4132
|
+
initial_assignments=assignments,
|
|
4133
|
+
working_directories=directories,
|
|
4134
|
+
# Non-shell Git aliases do not expand environment parameters.
|
|
4135
|
+
cwd_reliable=resolvable and not (
|
|
4136
|
+
NAMED_SHELL_PARAMETER_SYNTAX.search(alias)
|
|
4137
|
+
or CMD_PARAMETER_SYNTAX.search(alias)
|
|
4138
|
+
),
|
|
4139
|
+
git_context_reliable=segment_git_context_reliable,
|
|
4140
|
+
earlier_git=earlier_git,
|
|
3013
4141
|
)
|
|
3014
4142
|
if reason is not None:
|
|
3015
4143
|
return reason
|
|
@@ -3037,18 +4165,29 @@ def destructive_git_reason(
|
|
|
3037
4165
|
if force_option or force_refspec:
|
|
3038
4166
|
return GIT_REASONS[command]
|
|
3039
4167
|
if command == "branch":
|
|
4168
|
+
# Git accepts each unique prefix of a long option (--del, --forc).
|
|
3040
4169
|
deletes = (
|
|
3041
|
-
"--delete" in arguments
|
|
4170
|
+
any(git_option_in(argument, {"--delete"}) for argument in arguments)
|
|
3042
4171
|
or has_short_option(arguments, "d")
|
|
3043
4172
|
or has_short_option(arguments, "D")
|
|
3044
4173
|
)
|
|
3045
4174
|
forces = (
|
|
3046
|
-
"--force" in arguments
|
|
4175
|
+
any(git_option_in(argument, {"--force"}) for argument in arguments)
|
|
3047
4176
|
or has_short_option(arguments, "f")
|
|
3048
4177
|
or has_short_option(arguments, "D")
|
|
3049
4178
|
)
|
|
3050
4179
|
if deletes and forces:
|
|
3051
|
-
|
|
4180
|
+
if not segment_git_context_reliable:
|
|
4181
|
+
return GIT_REASONS["git-environment"]
|
|
4182
|
+
if not resolvable:
|
|
4183
|
+
return GIT_REASONS["unresolved-target"].format(
|
|
4184
|
+
action="git branch -D"
|
|
4185
|
+
)
|
|
4186
|
+
reason = owned_target_reason(
|
|
4187
|
+
command, arguments, git_options, directories
|
|
4188
|
+
)
|
|
4189
|
+
if reason is not None:
|
|
4190
|
+
return reason
|
|
3052
4191
|
if (
|
|
3053
4192
|
"--move" in arguments
|
|
3054
4193
|
or has_short_option(arguments, "m")
|
|
@@ -3057,10 +4196,39 @@ def destructive_git_reason(
|
|
|
3057
4196
|
return GIT_REASONS["branch-move"]
|
|
3058
4197
|
if command == "update-ref":
|
|
3059
4198
|
if update_ref_deletes(arguments):
|
|
3060
|
-
|
|
4199
|
+
if not segment_git_context_reliable:
|
|
4200
|
+
return GIT_REASONS["git-environment"]
|
|
4201
|
+
if not resolvable:
|
|
4202
|
+
return GIT_REASONS["unresolved-target"].format(
|
|
4203
|
+
action="git update-ref deletion"
|
|
4204
|
+
)
|
|
4205
|
+
# The guard reads each symbolic ref now; an earlier Git command
|
|
4206
|
+
# of this command line can point it at a Path-owned ref later.
|
|
4207
|
+
# (-m can take the text --no-deref as its value.)
|
|
4208
|
+
if earlier_git and (
|
|
4209
|
+
"--no-deref" not in arguments or "-m" in arguments
|
|
4210
|
+
):
|
|
4211
|
+
return GIT_REASONS["update-ref-order"]
|
|
4212
|
+
reason = owned_target_reason(
|
|
4213
|
+
command, arguments, git_options, directories
|
|
4214
|
+
)
|
|
4215
|
+
if reason is not None:
|
|
4216
|
+
return reason
|
|
3061
4217
|
if command == "worktree" and arguments[:1] == ["remove"]:
|
|
3062
|
-
if
|
|
3063
|
-
|
|
4218
|
+
if any(
|
|
4219
|
+
git_option_in(argument, {"--force"}) for argument in arguments
|
|
4220
|
+
) or has_short_option(arguments[1:], "f"):
|
|
4221
|
+
if not segment_git_context_reliable:
|
|
4222
|
+
return GIT_REASONS["git-environment"]
|
|
4223
|
+
if not resolvable:
|
|
4224
|
+
return GIT_REASONS["unresolved-target"].format(
|
|
4225
|
+
action="git worktree remove --force"
|
|
4226
|
+
)
|
|
4227
|
+
reason = owned_target_reason(
|
|
4228
|
+
"worktree", arguments, git_options, directories
|
|
4229
|
+
)
|
|
4230
|
+
if reason is not None:
|
|
4231
|
+
return reason
|
|
3064
4232
|
if command == "stash" and arguments[:1] in (["drop"], ["clear"]):
|
|
3065
4233
|
return GIT_REASONS[command]
|
|
3066
4234
|
if command in {"checkout", "restore"} and any(
|
|
@@ -3469,9 +4637,10 @@ def command_denial(command, working_directories, allow_destructive=True):
|
|
|
3469
4637
|
if archive_write_attempt(resolved_segment, (segment, assignments)):
|
|
3470
4638
|
return ARCHIVE_REASON
|
|
3471
4639
|
return ARCHIVE_UNPROVEN_READ_REASON
|
|
3472
|
-
reason = destructive_git_reason(
|
|
3473
|
-
tokens, working_directories
|
|
3474
|
-
|
|
4640
|
+
reason = destructive_git_reason(
|
|
4641
|
+
tokens, working_directories=working_directories,
|
|
4642
|
+
cwd_reliable=not shell_parameter_quoting_uncertain(outer),
|
|
4643
|
+
) or protected_shell_write_reason(tokens, working_directories)
|
|
3475
4644
|
except ValueError as error:
|
|
3476
4645
|
raise ValueError(describe_substitutions(str(error), substitutions)) from None
|
|
3477
4646
|
return reason and describe_substitutions(reason, substitutions)
|