@opengsd/gsd-path 1.0.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/AGENTS.md +397 -0
- package/DOCS.md +341 -0
- package/FULL.md +475 -0
- package/GUIDE.md +23 -0
- package/HOOKS.md +273 -0
- package/LICENSE +21 -0
- package/MIGRATE.md +100 -0
- package/QUICK.md +146 -0
- package/README.md +280 -0
- package/RUNTIME.md +229 -0
- package/UPDATE.md +196 -0
- package/WORKFLOW.md +683 -0
- package/package.json +80 -0
- package/platforms/claude/dispatch.md +63 -0
- package/platforms/copilot/dispatch.md +61 -0
- package/platforms/cursor/agent.md +17 -0
- package/platforms/cursor/dispatch.md +63 -0
- package/platforms/grok/dispatch.md +61 -0
- package/platforms/kimi/dispatch.md +64 -0
- package/platforms/kiro/dispatch.md +58 -0
- package/platforms/opencode/dispatch.md +62 -0
- package/platforms/qwen/dispatch.md +60 -0
- package/platforms/shared-agents/dispatch.md +131 -0
- package/plugin.json +14 -0
- package/scripts/_common.py +180 -0
- package/scripts/archive_milestone.py +2265 -0
- package/scripts/bootstrap_repository.py +710 -0
- package/scripts/build_state.py +854 -0
- package/scripts/check_docs_audit.py +463 -0
- package/scripts/check_handoffs.py +1855 -0
- package/scripts/check_task_briefs.py +376 -0
- package/scripts/check_update.py +93 -0
- package/scripts/core_hook_gate.py +34 -0
- package/scripts/core_hook_settings.py +119 -0
- package/scripts/detect_project.py +1517 -0
- package/scripts/discussion_records.py +619 -0
- package/scripts/discussion_validate.py +1321 -0
- package/scripts/dispatch_driver.py +1812 -0
- package/scripts/git_guard.py +759 -0
- package/scripts/guard_hook.py +2124 -0
- package/scripts/install.mjs +3239 -0
- package/scripts/install.py +3008 -0
- package/scripts/integration.py +1433 -0
- package/scripts/isolation.py +3538 -0
- package/scripts/lean_verification.py +256 -0
- package/scripts/loop_run.py +837 -0
- package/scripts/migrate_core.py +169 -0
- package/scripts/pipeline_diagnose.py +715 -0
- package/scripts/pipeline_git.py +940 -0
- package/scripts/pipeline_state.py +2291 -0
- package/scripts/pipeline_undo.py +1039 -0
- package/scripts/promote_lookahead.py +415 -0
- package/scripts/review_findings.py +714 -0
- package/scripts/review_panel.py +553 -0
- package/scripts/skill-resources.json +340 -0
- package/scripts/state_checkpoint.py +1039 -0
- package/scripts/state_promote.py +743 -0
- package/scripts/status_runtime.py +122 -0
- package/scripts/sync_skill_resources.py +259 -0
- package/scripts/token_budget.py +146 -0
- package/scripts/wizard.mjs +179 -0
- package/scripts/workflow_run.py +117 -0
- package/skills/gsd-path/BUILD.md +700 -0
- package/skills/gsd-path/DECIDE.md +115 -0
- package/skills/gsd-path/DEFINE.md +297 -0
- package/skills/gsd-path/DOCS-AUDIT.md +190 -0
- package/skills/gsd-path/INSPECT.md +167 -0
- package/skills/gsd-path/PLAN.md +411 -0
- package/skills/gsd-path/RESEARCH.md +159 -0
- package/skills/gsd-path/ROADMAP.md +215 -0
- package/skills/gsd-path/SHIP.md +412 -0
- package/skills/gsd-path/SKILL.md +471 -0
- package/skills/gsd-path/agents/openai.yaml +6 -0
- package/skills/gsd-path/references/codebase-mapper.md +52 -0
- package/skills/gsd-path/references/coder.md +90 -0
- package/skills/gsd-path/references/decider.md +36 -0
- package/skills/gsd-path/references/dispatch.md +131 -0
- package/skills/gsd-path/references/docs-auditor.md +60 -0
- package/skills/gsd-path/references/planner.md +104 -0
- package/skills/gsd-path/references/researcher.md +42 -0
- package/skills/gsd-path/references/reviewer.md +188 -0
- package/skills/gsd-path/references/roadmapper.md +48 -0
- package/skills/gsd-path/scripts/_common.py +180 -0
- package/skills/gsd-path/scripts/archive_milestone.py +2265 -0
- package/skills/gsd-path/scripts/bootstrap_repository.py +710 -0
- package/skills/gsd-path/scripts/build_state.py +854 -0
- package/skills/gsd-path/scripts/check_docs_audit.py +463 -0
- package/skills/gsd-path/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path/scripts/check_update.py +93 -0
- package/skills/gsd-path/scripts/detect_project.py +1517 -0
- package/skills/gsd-path/scripts/discussion_records.py +619 -0
- package/skills/gsd-path/scripts/discussion_validate.py +1321 -0
- package/skills/gsd-path/scripts/dispatch_driver.py +1812 -0
- package/skills/gsd-path/scripts/integration.py +1433 -0
- package/skills/gsd-path/scripts/isolation.py +3538 -0
- package/skills/gsd-path/scripts/lean_verification.py +256 -0
- package/skills/gsd-path/scripts/loop_run.py +837 -0
- package/skills/gsd-path/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path/scripts/promote_lookahead.py +415 -0
- package/skills/gsd-path/scripts/review_findings.py +714 -0
- package/skills/gsd-path/scripts/review_panel.py +553 -0
- package/skills/gsd-path/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path/scripts/state_promote.py +743 -0
- package/skills/gsd-path/scripts/token_budget.py +146 -0
- package/skills/gsd-path/scripts/workflow_run.py +117 -0
- package/skills/gsd-path/templates/answers.md +29 -0
- package/skills/gsd-path/templates/archive-manifest.md +37 -0
- package/skills/gsd-path/templates/charter.md +50 -0
- package/skills/gsd-path/templates/codebase.md +46 -0
- package/skills/gsd-path/templates/dialogue.md +21 -0
- package/skills/gsd-path/templates/docs-audit.md +69 -0
- package/skills/gsd-path/templates/evidence.md +24 -0
- package/skills/gsd-path/templates/final-review.md +27 -0
- package/skills/gsd-path/templates/gap-review.md +20 -0
- package/skills/gsd-path/templates/intent.md +98 -0
- package/skills/gsd-path/templates/loop.md +59 -0
- package/skills/gsd-path/templates/patch-findings.md +16 -0
- package/skills/gsd-path/templates/plan-panel.md +25 -0
- package/skills/gsd-path/templates/plan.md +89 -0
- package/skills/gsd-path/templates/repository.md +16 -0
- package/skills/gsd-path/templates/research-handoff.md +25 -0
- package/skills/gsd-path/templates/roadmap.md +38 -0
- package/skills/gsd-path/templates/skeptic.md +36 -0
- package/skills/gsd-path/templates/state.md +27 -0
- package/skills/gsd-path/templates/synthesis.md +36 -0
- package/skills/gsd-path/templates/task.md +74 -0
- package/skills/gsd-path/templates/wave-panel.md +25 -0
- package/skills/gsd-path/templates/wave-review.md +64 -0
- package/skills/gsd-path-build/SKILL.md +700 -0
- package/skills/gsd-path-build/agents/openai.yaml +6 -0
- package/skills/gsd-path-build/references/coder.md +90 -0
- package/skills/gsd-path-build/references/dispatch.md +131 -0
- package/skills/gsd-path-build/references/reviewer.md +188 -0
- package/skills/gsd-path-build/scripts/_common.py +180 -0
- package/skills/gsd-path-build/scripts/archive_milestone.py +2265 -0
- package/skills/gsd-path-build/scripts/build_state.py +854 -0
- package/skills/gsd-path-build/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-build/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-build/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-build/scripts/discussion_validate.py +1321 -0
- package/skills/gsd-path-build/scripts/dispatch_driver.py +1812 -0
- package/skills/gsd-path-build/scripts/integration.py +1433 -0
- package/skills/gsd-path-build/scripts/isolation.py +3538 -0
- package/skills/gsd-path-build/scripts/loop_run.py +837 -0
- package/skills/gsd-path-build/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-build/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-build/scripts/review_findings.py +714 -0
- package/skills/gsd-path-build/scripts/review_panel.py +553 -0
- package/skills/gsd-path-build/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-build/scripts/state_promote.py +743 -0
- package/skills/gsd-path-build/scripts/token_budget.py +146 -0
- package/skills/gsd-path-build/scripts/workflow_run.py +117 -0
- package/skills/gsd-path-build/templates/skeptic.md +36 -0
- package/skills/gsd-path-build/templates/task.md +74 -0
- package/skills/gsd-path-build/templates/wave-panel.md +25 -0
- package/skills/gsd-path-build/templates/wave-review.md +64 -0
- package/skills/gsd-path-decide/SKILL.md +115 -0
- package/skills/gsd-path-decide/agents/openai.yaml +6 -0
- package/skills/gsd-path-decide/references/decider.md +36 -0
- package/skills/gsd-path-decide/references/dispatch.md +131 -0
- package/skills/gsd-path-decide/scripts/_common.py +180 -0
- package/skills/gsd-path-decide/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-decide/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-decide/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-decide/scripts/isolation.py +3538 -0
- package/skills/gsd-path-decide/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-decide/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-decide/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-decide/scripts/state_promote.py +743 -0
- package/skills/gsd-path-decide/templates/synthesis.md +36 -0
- package/skills/gsd-path-define/SKILL.md +297 -0
- package/skills/gsd-path-define/agents/openai.yaml +6 -0
- package/skills/gsd-path-define/scripts/_common.py +180 -0
- package/skills/gsd-path-define/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-define/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-define/scripts/detect_project.py +1517 -0
- package/skills/gsd-path-define/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-define/scripts/isolation.py +3538 -0
- package/skills/gsd-path-define/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-define/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-define/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-define/scripts/state_promote.py +743 -0
- package/skills/gsd-path-define/templates/charter.md +50 -0
- package/skills/gsd-path-define/templates/intent.md +98 -0
- package/skills/gsd-path-define/templates/state.md +27 -0
- package/skills/gsd-path-discuss/SKILL.md +162 -0
- package/skills/gsd-path-discuss/agents/openai.yaml +6 -0
- package/skills/gsd-path-discuss/scripts/_common.py +180 -0
- package/skills/gsd-path-discuss/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-discuss/templates/answers.md +29 -0
- package/skills/gsd-path-discuss/templates/dialogue.md +21 -0
- package/skills/gsd-path-docs-audit/SKILL.md +190 -0
- package/skills/gsd-path-docs-audit/agents/openai.yaml +6 -0
- package/skills/gsd-path-docs-audit/references/dispatch.md +131 -0
- package/skills/gsd-path-docs-audit/references/docs-auditor.md +60 -0
- package/skills/gsd-path-docs-audit/scripts/_common.py +180 -0
- package/skills/gsd-path-docs-audit/scripts/check_docs_audit.py +463 -0
- package/skills/gsd-path-docs-audit/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-docs-audit/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-docs-audit/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-docs-audit/scripts/isolation.py +3538 -0
- package/skills/gsd-path-docs-audit/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-docs-audit/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-docs-audit/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-docs-audit/scripts/state_promote.py +743 -0
- package/skills/gsd-path-docs-audit/templates/docs-audit.md +69 -0
- package/skills/gsd-path-forensics/SKILL.md +39 -0
- package/skills/gsd-path-forensics/agents/openai.yaml +6 -0
- package/skills/gsd-path-forensics/scripts/_common.py +180 -0
- package/skills/gsd-path-forensics/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-forensics/scripts/pipeline_diagnose.py +715 -0
- package/skills/gsd-path-forensics/scripts/pipeline_undo.py +1039 -0
- package/skills/gsd-path-inspect/SKILL.md +167 -0
- package/skills/gsd-path-inspect/agents/openai.yaml +6 -0
- package/skills/gsd-path-inspect/references/codebase-mapper.md +52 -0
- package/skills/gsd-path-inspect/references/dispatch.md +131 -0
- package/skills/gsd-path-inspect/references/docs-auditor.md +60 -0
- package/skills/gsd-path-inspect/scripts/_common.py +180 -0
- package/skills/gsd-path-inspect/scripts/check_docs_audit.py +463 -0
- package/skills/gsd-path-inspect/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-inspect/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-inspect/scripts/detect_project.py +1517 -0
- package/skills/gsd-path-inspect/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-inspect/scripts/isolation.py +3538 -0
- package/skills/gsd-path-inspect/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-inspect/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-inspect/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-inspect/scripts/state_promote.py +743 -0
- package/skills/gsd-path-inspect/templates/codebase.md +46 -0
- package/skills/gsd-path-inspect/templates/docs-audit.md +69 -0
- package/skills/gsd-path-inspect/templates/state.md +27 -0
- package/skills/gsd-path-loop/SKILL.md +98 -0
- package/skills/gsd-path-loop/agents/openai.yaml +6 -0
- package/skills/gsd-path-loop/examples/ci-repair.LOOP.md +61 -0
- package/skills/gsd-path-loop/scripts/_common.py +180 -0
- package/skills/gsd-path-loop/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-loop/scripts/loop_run.py +837 -0
- package/skills/gsd-path-loop/templates/loop.md +59 -0
- package/skills/gsd-path-migrate/SKILL.md +134 -0
- package/skills/gsd-path-migrate/agents/openai.yaml +6 -0
- package/skills/gsd-path-migrate/scripts/core_hook_gate.py +34 -0
- package/skills/gsd-path-migrate/scripts/core_hook_settings.py +119 -0
- package/skills/gsd-path-migrate/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-migrate/scripts/migrate_core.py +169 -0
- package/skills/gsd-path-plan/SKILL.md +411 -0
- package/skills/gsd-path-plan/agents/openai.yaml +6 -0
- package/skills/gsd-path-plan/references/dispatch.md +131 -0
- package/skills/gsd-path-plan/references/planner.md +104 -0
- package/skills/gsd-path-plan/scripts/_common.py +180 -0
- package/skills/gsd-path-plan/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-plan/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-plan/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-plan/scripts/isolation.py +3538 -0
- package/skills/gsd-path-plan/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-plan/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-plan/scripts/review_panel.py +553 -0
- package/skills/gsd-path-plan/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-plan/scripts/state_promote.py +743 -0
- package/skills/gsd-path-plan/scripts/workflow_run.py +117 -0
- package/skills/gsd-path-plan/templates/patch-findings.md +16 -0
- package/skills/gsd-path-plan/templates/plan-panel.md +25 -0
- package/skills/gsd-path-plan/templates/plan.md +89 -0
- package/skills/gsd-path-plan/templates/task.md +74 -0
- package/skills/gsd-path-research/SKILL.md +159 -0
- package/skills/gsd-path-research/agents/openai.yaml +6 -0
- package/skills/gsd-path-research/references/dispatch.md +131 -0
- package/skills/gsd-path-research/references/researcher.md +42 -0
- package/skills/gsd-path-research/scripts/_common.py +180 -0
- package/skills/gsd-path-research/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-research/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-research/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-research/scripts/isolation.py +3538 -0
- package/skills/gsd-path-research/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-research/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-research/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-research/scripts/state_promote.py +743 -0
- package/skills/gsd-path-research/templates/evidence.md +24 -0
- package/skills/gsd-path-research/templates/research-handoff.md +25 -0
- package/skills/gsd-path-roadmap/SKILL.md +215 -0
- package/skills/gsd-path-roadmap/agents/openai.yaml +6 -0
- package/skills/gsd-path-roadmap/references/dispatch.md +131 -0
- package/skills/gsd-path-roadmap/references/roadmapper.md +48 -0
- package/skills/gsd-path-roadmap/scripts/_common.py +180 -0
- package/skills/gsd-path-roadmap/scripts/archive_milestone.py +2265 -0
- package/skills/gsd-path-roadmap/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-roadmap/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-roadmap/scripts/detect_project.py +1517 -0
- package/skills/gsd-path-roadmap/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-roadmap/scripts/discussion_validate.py +1321 -0
- package/skills/gsd-path-roadmap/scripts/integration.py +1433 -0
- package/skills/gsd-path-roadmap/scripts/isolation.py +3538 -0
- package/skills/gsd-path-roadmap/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-roadmap/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-roadmap/scripts/promote_lookahead.py +415 -0
- package/skills/gsd-path-roadmap/scripts/review_panel.py +553 -0
- package/skills/gsd-path-roadmap/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-roadmap/scripts/state_promote.py +743 -0
- package/skills/gsd-path-roadmap/templates/roadmap.md +38 -0
- package/skills/gsd-path-ship/SKILL.md +412 -0
- package/skills/gsd-path-ship/agents/openai.yaml +6 -0
- package/skills/gsd-path-ship/references/dispatch.md +131 -0
- package/skills/gsd-path-ship/references/reviewer.md +188 -0
- package/skills/gsd-path-ship/scripts/_common.py +180 -0
- package/skills/gsd-path-ship/scripts/archive_milestone.py +2265 -0
- package/skills/gsd-path-ship/scripts/build_state.py +854 -0
- package/skills/gsd-path-ship/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-ship/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-ship/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-ship/scripts/discussion_validate.py +1321 -0
- package/skills/gsd-path-ship/scripts/integration.py +1433 -0
- package/skills/gsd-path-ship/scripts/isolation.py +3538 -0
- package/skills/gsd-path-ship/scripts/lean_verification.py +256 -0
- package/skills/gsd-path-ship/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-ship/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-ship/scripts/review_panel.py +553 -0
- package/skills/gsd-path-ship/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-ship/scripts/state_promote.py +743 -0
- package/skills/gsd-path-ship/scripts/workflow_run.py +117 -0
- package/skills/gsd-path-ship/templates/archive-manifest.md +37 -0
- package/skills/gsd-path-ship/templates/final-review.md +27 -0
- package/skills/gsd-path-ship/templates/gap-review.md +20 -0
- package/skills/gsd-path-ship/templates/patch-findings.md +16 -0
- package/skills/gsd-path-ship/templates/wave-review.md +64 -0
- package/skills/gsd-path-undo/SKILL.md +45 -0
- package/skills/gsd-path-undo/agents/openai.yaml +6 -0
- package/skills/gsd-path-undo/scripts/_common.py +180 -0
- package/skills/gsd-path-undo/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-undo/scripts/pipeline_undo.py +1039 -0
- package/skills/path/BUILD.md +700 -0
- package/skills/path/DECIDE.md +115 -0
- package/skills/path/DEFINE.md +297 -0
- package/skills/path/DOCS-AUDIT.md +190 -0
- package/skills/path/INSPECT.md +167 -0
- package/skills/path/PLAN.md +411 -0
- package/skills/path/RESEARCH.md +159 -0
- package/skills/path/ROADMAP.md +215 -0
- package/skills/path/SHIP.md +412 -0
- package/skills/path/SKILL.md +471 -0
- package/skills/path/agents/openai.yaml +6 -0
- package/skills/path/references/codebase-mapper.md +52 -0
- package/skills/path/references/coder.md +90 -0
- package/skills/path/references/decider.md +36 -0
- package/skills/path/references/dispatch.md +131 -0
- package/skills/path/references/docs-auditor.md +60 -0
- package/skills/path/references/planner.md +104 -0
- package/skills/path/references/researcher.md +42 -0
- package/skills/path/references/reviewer.md +188 -0
- package/skills/path/references/roadmapper.md +48 -0
- package/skills/path/scripts/_common.py +180 -0
- package/skills/path/scripts/archive_milestone.py +2265 -0
- package/skills/path/scripts/bootstrap_repository.py +710 -0
- package/skills/path/scripts/build_state.py +854 -0
- package/skills/path/scripts/check_docs_audit.py +463 -0
- package/skills/path/scripts/check_handoffs.py +1855 -0
- package/skills/path/scripts/check_task_briefs.py +376 -0
- package/skills/path/scripts/check_update.py +93 -0
- package/skills/path/scripts/detect_project.py +1517 -0
- package/skills/path/scripts/discussion_records.py +619 -0
- package/skills/path/scripts/discussion_validate.py +1321 -0
- package/skills/path/scripts/dispatch_driver.py +1812 -0
- package/skills/path/scripts/integration.py +1433 -0
- package/skills/path/scripts/isolation.py +3538 -0
- package/skills/path/scripts/lean_verification.py +256 -0
- package/skills/path/scripts/loop_run.py +837 -0
- package/skills/path/scripts/pipeline_git.py +940 -0
- package/skills/path/scripts/pipeline_state.py +2291 -0
- package/skills/path/scripts/promote_lookahead.py +415 -0
- package/skills/path/scripts/review_findings.py +714 -0
- package/skills/path/scripts/review_panel.py +553 -0
- package/skills/path/scripts/state_checkpoint.py +1039 -0
- package/skills/path/scripts/state_promote.py +743 -0
- package/skills/path/scripts/token_budget.py +146 -0
- package/skills/path/scripts/workflow_run.py +117 -0
- package/skills/path/templates/answers.md +29 -0
- package/skills/path/templates/archive-manifest.md +37 -0
- package/skills/path/templates/charter.md +50 -0
- package/skills/path/templates/codebase.md +46 -0
- package/skills/path/templates/dialogue.md +21 -0
- package/skills/path/templates/docs-audit.md +69 -0
- package/skills/path/templates/evidence.md +24 -0
- package/skills/path/templates/final-review.md +27 -0
- package/skills/path/templates/gap-review.md +20 -0
- package/skills/path/templates/intent.md +98 -0
- package/skills/path/templates/loop.md +59 -0
- package/skills/path/templates/patch-findings.md +16 -0
- package/skills/path/templates/plan-panel.md +25 -0
- package/skills/path/templates/plan.md +89 -0
- package/skills/path/templates/repository.md +16 -0
- package/skills/path/templates/research-handoff.md +25 -0
- package/skills/path/templates/roadmap.md +38 -0
- package/skills/path/templates/skeptic.md +36 -0
- package/skills/path/templates/state.md +27 -0
- package/skills/path/templates/synthesis.md +36 -0
- package/skills/path/templates/task.md +74 -0
- package/skills/path/templates/wave-panel.md +25 -0
- package/skills/path/templates/wave-review.md +64 -0
|
@@ -0,0 +1,940 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# gsd-path project runtime
|
|
3
|
+
"""Deterministic GSD Path branch names and pipeline commit messages.
|
|
4
|
+
|
|
5
|
+
Bound work lives on `gsd-path/M00N` for that milestone. The required remote
|
|
6
|
+
default `main` stays a separate trunk. Ship merges the bound branch onto main;
|
|
7
|
+
the next milestone binds a new unused `gsd-path/M00N` and retires the
|
|
8
|
+
integrated previous branch, locally and on origin.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import stat
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Optional, Sequence
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
import _common
|
|
25
|
+
except ImportError: # pragma: no cover - package import used by tests
|
|
26
|
+
from scripts import _common
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
ARCHIVE_NAME_RE = re.compile(r"^(\d{3,})-([a-z0-9][a-z0-9-]*)$")
|
|
30
|
+
BOUND_BRANCH_RE = _common.BOUND_BRANCH_RE
|
|
31
|
+
MILESTONE_ID_RE = re.compile(r"^M(\d{3,})$")
|
|
32
|
+
|
|
33
|
+
LEGACY_SHIP_PREFIX = "ship: "
|
|
34
|
+
LEGACY_INTEGRATE_PREFIX = "integrate: "
|
|
35
|
+
LEGACY_BIND_NEXT_JOURNAL_SCHEMA = "gsd-path/bind-next-journal/v1"
|
|
36
|
+
BIND_NEXT_JOURNAL_SCHEMA = "gsd-path/bind-next-journal/v2"
|
|
37
|
+
BIND_NEXT_JOURNAL_DIR = "gsd-path-bind-next"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class PipelineGitError(ValueError):
|
|
41
|
+
"""Raised when a milestone id, archive name, or branch cannot be derived."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def milestone_id(number: int) -> str:
|
|
45
|
+
if number < 1:
|
|
46
|
+
raise PipelineGitError(f"milestone number must be >= 1, got {number}")
|
|
47
|
+
return f"M{number:03d}"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def milestone_number(token: str) -> int:
|
|
51
|
+
bound = BOUND_BRANCH_RE.fullmatch(token)
|
|
52
|
+
if bound:
|
|
53
|
+
number = int(bound.group(1))
|
|
54
|
+
if number < 1:
|
|
55
|
+
raise PipelineGitError(f"milestone number must be >= 1, got {number}")
|
|
56
|
+
return number
|
|
57
|
+
milestone = MILESTONE_ID_RE.fullmatch(token)
|
|
58
|
+
if milestone:
|
|
59
|
+
number = int(milestone.group(1))
|
|
60
|
+
if number < 1:
|
|
61
|
+
raise PipelineGitError(f"milestone number must be >= 1, got {number}")
|
|
62
|
+
return number
|
|
63
|
+
archive = ARCHIVE_NAME_RE.fullmatch(token)
|
|
64
|
+
if archive:
|
|
65
|
+
number = int(archive.group(1))
|
|
66
|
+
if number < 1:
|
|
67
|
+
raise PipelineGitError(f"milestone number must be >= 1, got {number}")
|
|
68
|
+
return number
|
|
69
|
+
raise PipelineGitError(f"not a milestone id, bound branch, or archive name: {token}")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def bound_branch_name(number: int) -> str:
|
|
73
|
+
return f"gsd-path/{milestone_id(number)}"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def is_bound_branch(name: str) -> bool:
|
|
77
|
+
match = BOUND_BRANCH_RE.fullmatch(name)
|
|
78
|
+
return match is not None and int(match.group(1)) >= 1
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def archive_slug(archive_name: str) -> str:
|
|
82
|
+
match = ARCHIVE_NAME_RE.fullmatch(archive_name)
|
|
83
|
+
if not match:
|
|
84
|
+
raise PipelineGitError(f"invalid archive name: {archive_name}")
|
|
85
|
+
milestone_number(archive_name)
|
|
86
|
+
return match.group(2)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def ship_subject(archive_name: str) -> str:
|
|
90
|
+
number = milestone_number(archive_name)
|
|
91
|
+
return f"ship: {milestone_id(number)} — {archive_slug(archive_name)}"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def integrate_subject(archive_name: str, default_branch: str) -> str:
|
|
95
|
+
if not default_branch or default_branch.startswith("origin/"):
|
|
96
|
+
raise PipelineGitError(f"default branch is invalid: {default_branch}")
|
|
97
|
+
number = milestone_number(archive_name)
|
|
98
|
+
return (
|
|
99
|
+
f"integrate: {milestone_id(number)} — "
|
|
100
|
+
f"merge {bound_branch_name(number)} into {default_branch}"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def legacy_ship_subject(archive_name: str) -> str:
|
|
105
|
+
return f"{LEGACY_SHIP_PREFIX}{archive_name}"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def legacy_integrate_subject(archive_name: str) -> str:
|
|
109
|
+
return f"{LEGACY_INTEGRATE_PREFIX}{archive_name}"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def is_ship_subject(subject: str, archive_name: str) -> bool:
|
|
113
|
+
return subject in {ship_subject(archive_name), legacy_ship_subject(archive_name)}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def is_integrate_subject(subject: str, archive_name: str, default_branch: str) -> bool:
|
|
117
|
+
return subject in {
|
|
118
|
+
integrate_subject(archive_name, default_branch),
|
|
119
|
+
legacy_integrate_subject(archive_name),
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def ship_commit_body(archive_path: str, reviewed_head: str) -> str:
|
|
124
|
+
return f"Archive: {archive_path}\nReviewed-HEAD: {reviewed_head}\n"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def integrate_commit_body(
|
|
128
|
+
archive_path: str,
|
|
129
|
+
ship_commit: str,
|
|
130
|
+
default_branch: str,
|
|
131
|
+
bound_branch: str,
|
|
132
|
+
) -> str:
|
|
133
|
+
return (
|
|
134
|
+
f"Archive: {archive_path}\n"
|
|
135
|
+
f"Ship: {ship_commit}\n"
|
|
136
|
+
f"Default: {default_branch}\n"
|
|
137
|
+
f"Branch: {bound_branch}\n"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def task_commit_subject(task_id: str, title: str) -> str:
|
|
142
|
+
cleaned = title.strip()
|
|
143
|
+
if not cleaned:
|
|
144
|
+
raise PipelineGitError("title is empty")
|
|
145
|
+
return f"{task_id}: {cleaned}"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def attest_commit_subject(task_id: str, title: str) -> str:
|
|
149
|
+
cleaned = title.strip()
|
|
150
|
+
if not cleaned:
|
|
151
|
+
raise PipelineGitError("title is empty")
|
|
152
|
+
return f"attest: {task_id} — {cleaned}"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def attest_commit_body(
|
|
156
|
+
task_file: str,
|
|
157
|
+
base: str,
|
|
158
|
+
head: str,
|
|
159
|
+
paths: Sequence[str],
|
|
160
|
+
verify_command: str,
|
|
161
|
+
ruling: str,
|
|
162
|
+
*,
|
|
163
|
+
legacy: bool = False,
|
|
164
|
+
) -> str:
|
|
165
|
+
lines = [f"Task: {task_file}", f"Base: {base}", f"Head: {head}", "Files:"]
|
|
166
|
+
lines.extend(f"- {path}" for path in sorted(paths))
|
|
167
|
+
if legacy:
|
|
168
|
+
lines.append(f"Verify: {' '.join(verify_command.split())}")
|
|
169
|
+
else:
|
|
170
|
+
lines.append("Attestation: gsd-path/attestation/v2")
|
|
171
|
+
lines.append(f"Verify-JSON: {json.dumps(verify_command)}")
|
|
172
|
+
lines.append(f"Ruling: {' '.join(ruling.split())}")
|
|
173
|
+
return "\n".join(lines) + "\n"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def task_commit_body(task_file: str, paths: Sequence[str], base: str) -> str:
|
|
177
|
+
ordered = sorted(paths)
|
|
178
|
+
lines = [f"Task: {task_file}", f"Base: {base}", "Files:"]
|
|
179
|
+
if ordered:
|
|
180
|
+
lines.extend(f"- {path}" for path in ordered)
|
|
181
|
+
else:
|
|
182
|
+
lines.append("- (none)")
|
|
183
|
+
return "\n".join(lines) + "\n"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def default_branch_name(remote_default: str) -> str:
|
|
187
|
+
if remote_default.startswith("origin/"):
|
|
188
|
+
return remote_default.removeprefix("origin/")
|
|
189
|
+
return remote_default
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _run_git(
|
|
193
|
+
repo: Path,
|
|
194
|
+
*args: str,
|
|
195
|
+
check: bool = True,
|
|
196
|
+
) -> subprocess.CompletedProcess[str]:
|
|
197
|
+
result = subprocess.run(
|
|
198
|
+
["git", "-C", str(repo), *args],
|
|
199
|
+
capture_output=True,
|
|
200
|
+
text=True,
|
|
201
|
+
)
|
|
202
|
+
if check and result.returncode != 0:
|
|
203
|
+
detail = result.stderr.strip() or result.stdout.strip()
|
|
204
|
+
raise PipelineGitError(f"git {' '.join(args)} failed: {detail}")
|
|
205
|
+
return result
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _ref_exists(repo: Path, ref: str) -> bool:
|
|
209
|
+
result = _run_git(
|
|
210
|
+
repo,
|
|
211
|
+
"show-ref",
|
|
212
|
+
"--verify",
|
|
213
|
+
"--quiet",
|
|
214
|
+
ref,
|
|
215
|
+
check=False,
|
|
216
|
+
)
|
|
217
|
+
if result.returncode not in {0, 1}:
|
|
218
|
+
raise PipelineGitError(f"could not inspect bound branch: {ref}")
|
|
219
|
+
return result.returncode == 0
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _origin_remote_exists(repo: Path) -> bool:
|
|
223
|
+
result = _run_git(repo, "remote", "get-url", "origin", check=False)
|
|
224
|
+
return result.returncode == 0
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _require_worktree_root(repo: Path) -> Path:
|
|
228
|
+
if repo.is_symlink() or not repo.is_dir():
|
|
229
|
+
raise PipelineGitError(f"repo must be a real worktree directory: {repo}")
|
|
230
|
+
resolved = repo.resolve()
|
|
231
|
+
top = _run_git(resolved, "rev-parse", "--show-toplevel").stdout.strip()
|
|
232
|
+
if Path(top).resolve() != resolved:
|
|
233
|
+
raise PipelineGitError(f"repo must be the worktree root: {resolved}")
|
|
234
|
+
return resolved
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _symbolic_branch(repo: Path) -> str:
|
|
238
|
+
result = _run_git(
|
|
239
|
+
repo,
|
|
240
|
+
"symbolic-ref",
|
|
241
|
+
"--quiet",
|
|
242
|
+
"--short",
|
|
243
|
+
"HEAD",
|
|
244
|
+
check=False,
|
|
245
|
+
)
|
|
246
|
+
if result.returncode != 0:
|
|
247
|
+
raise PipelineGitError("primary worktree must be on a named branch")
|
|
248
|
+
return result.stdout.strip()
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _branch_worktrees(repo: Path) -> dict[str, Path]:
|
|
252
|
+
"""Return every checked-out local branch from Git's worktree registry."""
|
|
253
|
+
output = _run_git(repo, "worktree", "list", "--porcelain", "-z").stdout
|
|
254
|
+
worktrees: dict[str, Path] = {}
|
|
255
|
+
current_path: Optional[Path] = None
|
|
256
|
+
for field in output.split("\0"):
|
|
257
|
+
if field.startswith("worktree "):
|
|
258
|
+
current_path = Path(field.removeprefix("worktree ")).resolve()
|
|
259
|
+
elif field.startswith("branch ") and current_path is not None:
|
|
260
|
+
ref = field.removeprefix("branch ")
|
|
261
|
+
prefix = "refs/heads/"
|
|
262
|
+
if ref.startswith(prefix):
|
|
263
|
+
worktrees[ref.removeprefix(prefix)] = current_path
|
|
264
|
+
return worktrees
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _remote_ref_sha(repo: Path, ref: str) -> Optional[str]:
|
|
268
|
+
if not _origin_remote_exists(repo):
|
|
269
|
+
raise PipelineGitError("origin remote is not configured")
|
|
270
|
+
result = _run_git(
|
|
271
|
+
repo,
|
|
272
|
+
"ls-remote",
|
|
273
|
+
"--exit-code",
|
|
274
|
+
"--heads",
|
|
275
|
+
"origin",
|
|
276
|
+
ref,
|
|
277
|
+
check=False,
|
|
278
|
+
)
|
|
279
|
+
if result.returncode == 0:
|
|
280
|
+
rows = [line.split("\t", 1) for line in result.stdout.splitlines() if line]
|
|
281
|
+
if len(rows) != 1 or len(rows[0]) != 2 or rows[0][1] != ref:
|
|
282
|
+
raise PipelineGitError(f"origin returned ambiguous ref data for {ref}")
|
|
283
|
+
return rows[0][0]
|
|
284
|
+
if result.returncode == 2:
|
|
285
|
+
return None
|
|
286
|
+
raise PipelineGitError(f"could not inspect {ref} on origin: git ls-remote failed")
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _remote_branch_exists(repo: Path, branch: str) -> bool:
|
|
290
|
+
return _remote_ref_sha(repo, f"refs/heads/{branch}") is not None
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _pull_request_tag_fields(repo: Path, ref: str) -> dict[str, str]:
|
|
294
|
+
tag_type = _run_git(repo, "cat-file", "-t", ref, check=False)
|
|
295
|
+
if tag_type.returncode != 0 or tag_type.stdout.strip() != "tag":
|
|
296
|
+
return {}
|
|
297
|
+
contents = _run_git(repo, "for-each-ref", "--format=%(contents)", ref).stdout
|
|
298
|
+
fields: dict[str, str] = {}
|
|
299
|
+
for line in contents.splitlines():
|
|
300
|
+
match = re.fullmatch(r"(Mode|Pull-Request|Ship|Landing): (.+)", line)
|
|
301
|
+
if match:
|
|
302
|
+
if match.group(1) in fields:
|
|
303
|
+
raise PipelineGitError("pull-request milestone tag repeats metadata")
|
|
304
|
+
fields[match.group(1)] = match.group(2)
|
|
305
|
+
return fields
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _validated_shipped_state(repo: Path) -> dict[str, object]:
|
|
309
|
+
validator = Path(__file__).with_name("pipeline_state.py")
|
|
310
|
+
result = subprocess.run(
|
|
311
|
+
[
|
|
312
|
+
sys.executable,
|
|
313
|
+
str(validator),
|
|
314
|
+
"validate",
|
|
315
|
+
"--repo",
|
|
316
|
+
str(repo),
|
|
317
|
+
],
|
|
318
|
+
capture_output=True,
|
|
319
|
+
text=True,
|
|
320
|
+
)
|
|
321
|
+
if result.returncode != 0:
|
|
322
|
+
detail = result.stderr.strip() or result.stdout.strip()
|
|
323
|
+
raise PipelineGitError(f"could not validate shipped STATE.md: {detail}")
|
|
324
|
+
try:
|
|
325
|
+
payload = json.loads(result.stdout)
|
|
326
|
+
except json.JSONDecodeError as error:
|
|
327
|
+
raise PipelineGitError("pipeline state validator returned invalid JSON") from error
|
|
328
|
+
state = payload.get("state") if isinstance(payload, dict) else None
|
|
329
|
+
if not isinstance(state, dict):
|
|
330
|
+
raise PipelineGitError("pipeline state validator omitted state")
|
|
331
|
+
return state
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _require_pull_request_integration_proof(
|
|
335
|
+
repo: Path,
|
|
336
|
+
ship: str,
|
|
337
|
+
base: str,
|
|
338
|
+
previous_branch: str,
|
|
339
|
+
) -> str:
|
|
340
|
+
state = _validated_shipped_state(repo)
|
|
341
|
+
if state.get("phase") != "shipped" or state.get("status") != "done":
|
|
342
|
+
raise PipelineGitError("bind-next requires shipped/done state")
|
|
343
|
+
if state.get("branch") != previous_branch:
|
|
344
|
+
raise PipelineGitError("shipped state does not name the previous branch")
|
|
345
|
+
if state.get("integration") != "pull-request":
|
|
346
|
+
raise PipelineGitError("shipped integration mode is not pull-request")
|
|
347
|
+
archive = state.get("archive")
|
|
348
|
+
if not isinstance(archive, str):
|
|
349
|
+
raise PipelineGitError("shipped state does not name an archive")
|
|
350
|
+
archive_pattern = r"\.project/archive/(\d{3,}-[a-z0-9][a-z0-9-]*)/?"
|
|
351
|
+
match = re.fullmatch(archive_pattern, archive)
|
|
352
|
+
if match is None or milestone_number(match.group(1)) != milestone_number(
|
|
353
|
+
previous_branch
|
|
354
|
+
):
|
|
355
|
+
raise PipelineGitError("shipped archive does not match the previous branch")
|
|
356
|
+
ref = f"refs/tags/milestone/{match.group(1)}"
|
|
357
|
+
fields = _pull_request_tag_fields(repo, ref)
|
|
358
|
+
if set(fields) != {"Mode", "Pull-Request", "Ship", "Landing"}:
|
|
359
|
+
raise PipelineGitError("pull-request integration proof is incomplete")
|
|
360
|
+
if fields["Mode"] != "pull-request" or fields["Ship"] != ship:
|
|
361
|
+
raise PipelineGitError("pull-request integration proof does not match shipped state")
|
|
362
|
+
pull_request = fields["Pull-Request"]
|
|
363
|
+
if re.fullmatch(
|
|
364
|
+
r"https://github\.com/[^/]+/[^/]+/pull/[1-9][0-9]*",
|
|
365
|
+
pull_request,
|
|
366
|
+
) is None:
|
|
367
|
+
raise PipelineGitError("pull-request integration proof has an invalid PR URL")
|
|
368
|
+
landing = fields["Landing"]
|
|
369
|
+
if re.fullmatch(r"[0-9a-f]{40}", landing) is None:
|
|
370
|
+
raise PipelineGitError("pull-request integration proof has an invalid landing")
|
|
371
|
+
local_object = _run_git(repo, "rev-parse", ref).stdout.strip()
|
|
372
|
+
local_target = _run_git(repo, "rev-parse", f"{ref}^{{commit}}").stdout.strip()
|
|
373
|
+
if local_target != landing:
|
|
374
|
+
raise PipelineGitError("pull-request integration tag does not point at landing")
|
|
375
|
+
remote = _run_git(
|
|
376
|
+
repo,
|
|
377
|
+
"ls-remote",
|
|
378
|
+
"--exit-code",
|
|
379
|
+
"--tags",
|
|
380
|
+
"origin",
|
|
381
|
+
ref,
|
|
382
|
+
f"{ref}^{{}}",
|
|
383
|
+
check=False,
|
|
384
|
+
)
|
|
385
|
+
if remote.returncode != 0:
|
|
386
|
+
raise PipelineGitError("pull-request integration tag is not published")
|
|
387
|
+
rows = dict(
|
|
388
|
+
line.split("\t", 1)[::-1]
|
|
389
|
+
for line in remote.stdout.splitlines()
|
|
390
|
+
if "\t" in line
|
|
391
|
+
)
|
|
392
|
+
if rows != {ref: local_object, f"{ref}^{{}}": landing}:
|
|
393
|
+
raise PipelineGitError("published pull-request integration tag differs")
|
|
394
|
+
parents = _run_git(
|
|
395
|
+
repo,
|
|
396
|
+
"rev-list",
|
|
397
|
+
"--parents",
|
|
398
|
+
"-n",
|
|
399
|
+
"1",
|
|
400
|
+
landing,
|
|
401
|
+
).stdout.split()
|
|
402
|
+
if len(parents) != 3 or parents[2] != ship:
|
|
403
|
+
raise PipelineGitError("pull-request integration proof has invalid merge topology")
|
|
404
|
+
first_parent = _run_git(repo, "rev-list", "--first-parent", base).stdout.splitlines()
|
|
405
|
+
if landing not in first_parent:
|
|
406
|
+
raise PipelineGitError(
|
|
407
|
+
"pull-request integration landing is not on main first-parent history"
|
|
408
|
+
)
|
|
409
|
+
return landing
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _git_path(repo: Path, name: str) -> Path:
|
|
413
|
+
value = _run_git(repo, "rev-parse", "--git-path", name).stdout.strip()
|
|
414
|
+
path = Path(value)
|
|
415
|
+
if not path.is_absolute():
|
|
416
|
+
path = repo / path
|
|
417
|
+
return path.resolve()
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def bind_next_journal_path(repo: Path, branch: str) -> Path:
|
|
421
|
+
"""Return the durable ownership journal path for one target branch."""
|
|
422
|
+
if not is_bound_branch(branch):
|
|
423
|
+
raise PipelineGitError(f"invalid bound branch: {branch}")
|
|
424
|
+
return _git_path(repo, BIND_NEXT_JOURNAL_DIR) / f"M{milestone_number(branch):03d}.json"
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _read_bind_next_journal(path: Path) -> dict[str, object]:
|
|
428
|
+
if path.is_symlink() or not path.is_file():
|
|
429
|
+
raise PipelineGitError(f"bind-next journal must be a real file: {path}")
|
|
430
|
+
try:
|
|
431
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
432
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
433
|
+
raise PipelineGitError(f"bind-next journal is unreadable: {path}: {error}") from error
|
|
434
|
+
if not isinstance(value, dict):
|
|
435
|
+
raise PipelineGitError(f"bind-next journal must contain an object: {path}")
|
|
436
|
+
return value
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _write_bind_next_journal(path: Path, value: dict[str, object]) -> None:
|
|
440
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
441
|
+
temporary = path.parent / f".{path.name}.tmp"
|
|
442
|
+
if temporary.exists() or temporary.is_symlink():
|
|
443
|
+
raise PipelineGitError(f"bind-next journal temporary path exists: {temporary}")
|
|
444
|
+
descriptor: Optional[int] = None
|
|
445
|
+
try:
|
|
446
|
+
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
447
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
448
|
+
descriptor = None
|
|
449
|
+
json.dump(value, handle, indent=2, sort_keys=True)
|
|
450
|
+
handle.write("\n")
|
|
451
|
+
handle.flush()
|
|
452
|
+
os.fsync(handle.fileno())
|
|
453
|
+
os.replace(temporary, path)
|
|
454
|
+
directory = os.open(path.parent, os.O_RDONLY)
|
|
455
|
+
try:
|
|
456
|
+
os.fsync(directory)
|
|
457
|
+
finally:
|
|
458
|
+
os.close(directory)
|
|
459
|
+
finally:
|
|
460
|
+
if descriptor is not None:
|
|
461
|
+
os.close(descriptor)
|
|
462
|
+
if temporary.exists() or temporary.is_symlink():
|
|
463
|
+
temporary.unlink()
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _initial_binding_snapshot(repo: Path) -> Optional[tuple]:
|
|
467
|
+
"""Admit only clean work or the validated, untracked initializer state."""
|
|
468
|
+
dirty = _run_git(repo, "status", "--porcelain=v1", "-z", "--untracked-files=all").stdout
|
|
469
|
+
if not dirty:
|
|
470
|
+
return None
|
|
471
|
+
if dirty != "?? .project/STATE.md\0":
|
|
472
|
+
raise PipelineGitError("primary worktree is not clean")
|
|
473
|
+
try:
|
|
474
|
+
import pipeline_state
|
|
475
|
+
except ImportError: # pragma: no cover - package import used by tests
|
|
476
|
+
from scripts import pipeline_state
|
|
477
|
+
|
|
478
|
+
project = repo / ".project"
|
|
479
|
+
path = project / "STATE.md"
|
|
480
|
+
try:
|
|
481
|
+
directory = project.lstat()
|
|
482
|
+
state_file = path.lstat()
|
|
483
|
+
if (not stat.S_ISDIR(directory.st_mode)
|
|
484
|
+
or not stat.S_ISREG(state_file.st_mode)
|
|
485
|
+
or state_file.st_nlink != 1
|
|
486
|
+
or set(project.iterdir()) != {path}):
|
|
487
|
+
raise PipelineGitError("initial state must be the sole regular, unlinked .project file")
|
|
488
|
+
state = pipeline_state.validate_state(repo)["state"]
|
|
489
|
+
if (state["phase"] not in {"inspect", "define"}
|
|
490
|
+
or state["status"] != "active"
|
|
491
|
+
or any(state[key] is not None for key in ("branch", "milestone", "archive"))):
|
|
492
|
+
raise PipelineGitError("untracked state is not an unbound initial state")
|
|
493
|
+
return (directory.st_dev, directory.st_ino, state_file.st_dev,
|
|
494
|
+
state_file.st_ino, path.read_bytes())
|
|
495
|
+
except (OSError, pipeline_state.PipelineStateError) as error:
|
|
496
|
+
raise PipelineGitError(f"invalid initial state: {error}") from error
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def bind_initial_milestone_branch(
|
|
500
|
+
repo: Path,
|
|
501
|
+
branch: str,
|
|
502
|
+
remote_default: str,
|
|
503
|
+
base: str,
|
|
504
|
+
) -> dict[str, str]:
|
|
505
|
+
"""Bind the first milestone at an exact fetched remote-default SHA."""
|
|
506
|
+
resolved = _require_worktree_root(repo)
|
|
507
|
+
if not is_bound_branch(branch):
|
|
508
|
+
raise PipelineGitError(f"invalid bound branch: {branch}")
|
|
509
|
+
if remote_default != "origin/main":
|
|
510
|
+
raise PipelineGitError(f"remote default must be origin/main, got {remote_default}")
|
|
511
|
+
|
|
512
|
+
default_sha = _run_git(
|
|
513
|
+
resolved,
|
|
514
|
+
"rev-parse",
|
|
515
|
+
"--verify",
|
|
516
|
+
f"{remote_default}^{{commit}}",
|
|
517
|
+
).stdout.strip()
|
|
518
|
+
base_sha = _run_git(
|
|
519
|
+
resolved,
|
|
520
|
+
"rev-parse",
|
|
521
|
+
"--verify",
|
|
522
|
+
f"{base}^{{commit}}",
|
|
523
|
+
).stdout.strip()
|
|
524
|
+
if base != base_sha:
|
|
525
|
+
raise PipelineGitError(f"base must be the full commit SHA: {base}")
|
|
526
|
+
if base_sha != default_sha:
|
|
527
|
+
raise PipelineGitError(
|
|
528
|
+
f"validated base is stale: {base_sha} != {remote_default} {default_sha}"
|
|
529
|
+
)
|
|
530
|
+
remote_sha = _remote_ref_sha(resolved, "refs/heads/main")
|
|
531
|
+
if remote_sha != default_sha:
|
|
532
|
+
raise PipelineGitError(
|
|
533
|
+
f"fetched {remote_default} is stale relative to origin"
|
|
534
|
+
)
|
|
535
|
+
initial_state = _initial_binding_snapshot(resolved)
|
|
536
|
+
|
|
537
|
+
current = _symbolic_branch(resolved)
|
|
538
|
+
head = _run_git(resolved, "rev-parse", "HEAD").stdout.strip()
|
|
539
|
+
if head != base_sha:
|
|
540
|
+
raise PipelineGitError(
|
|
541
|
+
f"primary worktree is not at {remote_default}: {head} != {default_sha}"
|
|
542
|
+
)
|
|
543
|
+
worktrees = _branch_worktrees(resolved)
|
|
544
|
+
if current not in worktrees or worktrees[current] != resolved:
|
|
545
|
+
raise PipelineGitError("repo is not the registered path for its current worktree")
|
|
546
|
+
owner = worktrees.get(branch)
|
|
547
|
+
if owner is not None and owner != resolved:
|
|
548
|
+
raise PipelineGitError(f"bound branch is checked out by another worktree: {owner}")
|
|
549
|
+
if _remote_branch_exists(resolved, branch):
|
|
550
|
+
raise PipelineGitError(f"bound branch already exists: refs/heads/{branch} on origin")
|
|
551
|
+
|
|
552
|
+
if current == branch:
|
|
553
|
+
status = "already-bound"
|
|
554
|
+
else:
|
|
555
|
+
if is_bound_branch(current):
|
|
556
|
+
raise PipelineGitError(
|
|
557
|
+
f"current branch is already bound to another milestone: {current}"
|
|
558
|
+
)
|
|
559
|
+
local_ref = f"refs/heads/{branch}"
|
|
560
|
+
if _ref_exists(resolved, local_ref):
|
|
561
|
+
raise PipelineGitError(f"bound branch already exists: {local_ref}")
|
|
562
|
+
if _initial_binding_snapshot(resolved) != initial_state:
|
|
563
|
+
raise PipelineGitError("initial state changed before branch binding")
|
|
564
|
+
_run_git(resolved, "switch", "--no-track", "-c", branch, base_sha)
|
|
565
|
+
status = "bound"
|
|
566
|
+
|
|
567
|
+
if _initial_binding_snapshot(resolved) != initial_state:
|
|
568
|
+
raise PipelineGitError("initial state changed during branch binding")
|
|
569
|
+
|
|
570
|
+
return {
|
|
571
|
+
"schema": "gsd-path/bind-initial/v1",
|
|
572
|
+
"status": status,
|
|
573
|
+
"base": default_sha,
|
|
574
|
+
"branch": branch,
|
|
575
|
+
"previous_branch": current,
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def retire_previous_branch(
|
|
580
|
+
repo: Path,
|
|
581
|
+
previous_branch: str,
|
|
582
|
+
expected_sha: str,
|
|
583
|
+
*,
|
|
584
|
+
allow_remote_absent: bool = False,
|
|
585
|
+
) -> None:
|
|
586
|
+
"""Delete an integrated previous bound branch, locally and on origin.
|
|
587
|
+
|
|
588
|
+
Retirement is hygiene, never reachability: callers prove the branch tip is
|
|
589
|
+
an ancestor of the validated remote default first, and the milestone tag
|
|
590
|
+
preserves the integration merge. The live remote branch is deleted first
|
|
591
|
+
with an expected-SHA lease; local cleanup follows only after that proof.
|
|
592
|
+
"""
|
|
593
|
+
local_ref = f"refs/heads/{previous_branch}"
|
|
594
|
+
if _ref_exists(repo, local_ref):
|
|
595
|
+
local_sha = _run_git(repo, "rev-parse", f"{local_ref}^{{commit}}").stdout.strip()
|
|
596
|
+
if local_sha != expected_sha:
|
|
597
|
+
raise PipelineGitError(
|
|
598
|
+
f"previous branch moved after ship: {local_sha} != {expected_sha}"
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
remote_ref = f"refs/heads/{previous_branch}"
|
|
602
|
+
remote_sha = _remote_ref_sha(repo, remote_ref)
|
|
603
|
+
if remote_sha is None:
|
|
604
|
+
if not allow_remote_absent:
|
|
605
|
+
raise PipelineGitError(f"origin/{previous_branch} is missing before retirement")
|
|
606
|
+
else:
|
|
607
|
+
if remote_sha != expected_sha:
|
|
608
|
+
raise PipelineGitError(
|
|
609
|
+
f"origin/{previous_branch} moved after ship: {remote_sha} != {expected_sha}"
|
|
610
|
+
)
|
|
611
|
+
push = _run_git(
|
|
612
|
+
repo,
|
|
613
|
+
"push",
|
|
614
|
+
f"--force-with-lease={remote_ref}:{expected_sha}",
|
|
615
|
+
"origin",
|
|
616
|
+
"--delete",
|
|
617
|
+
previous_branch,
|
|
618
|
+
check=False,
|
|
619
|
+
)
|
|
620
|
+
if push.returncode != 0:
|
|
621
|
+
detail = (push.stderr or push.stdout).strip()
|
|
622
|
+
raise PipelineGitError(
|
|
623
|
+
f"could not delete origin/{previous_branch} with expected-SHA lease: {detail}"
|
|
624
|
+
)
|
|
625
|
+
if _remote_ref_sha(repo, remote_ref) is not None:
|
|
626
|
+
raise PipelineGitError(f"origin/{previous_branch} still exists after retirement")
|
|
627
|
+
|
|
628
|
+
tracking_ref = f"refs/remotes/origin/{previous_branch}"
|
|
629
|
+
if _ref_exists(repo, tracking_ref):
|
|
630
|
+
_run_git(repo, "update-ref", "-d", tracking_ref)
|
|
631
|
+
if _ref_exists(repo, local_ref):
|
|
632
|
+
_run_git(repo, "branch", "-d", previous_branch)
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def bind_next_milestone_branch(
|
|
636
|
+
repo: Path,
|
|
637
|
+
branch: str,
|
|
638
|
+
previous_branch: str,
|
|
639
|
+
ship: str,
|
|
640
|
+
remote_default: str,
|
|
641
|
+
base: str,
|
|
642
|
+
landing: str,
|
|
643
|
+
allow_remote_absent: bool = False,
|
|
644
|
+
) -> dict[str, str]:
|
|
645
|
+
"""Move a clean primary worktree onto a new bound branch after integration."""
|
|
646
|
+
repo = _require_worktree_root(repo)
|
|
647
|
+
if not is_bound_branch(branch):
|
|
648
|
+
raise PipelineGitError(f"invalid bound branch: {branch}")
|
|
649
|
+
if not is_bound_branch(previous_branch):
|
|
650
|
+
raise PipelineGitError(f"invalid previous bound branch: {previous_branch}")
|
|
651
|
+
if milestone_number(branch) <= milestone_number(previous_branch):
|
|
652
|
+
raise PipelineGitError(f"next branch {branch} must follow {previous_branch}")
|
|
653
|
+
if remote_default != "origin/main":
|
|
654
|
+
raise PipelineGitError(f"remote default must be origin/main, got {remote_default}")
|
|
655
|
+
|
|
656
|
+
default_sha = _run_git(
|
|
657
|
+
repo,
|
|
658
|
+
"rev-parse",
|
|
659
|
+
"--verify",
|
|
660
|
+
f"{remote_default}^{{commit}}",
|
|
661
|
+
).stdout.strip()
|
|
662
|
+
base_sha = _run_git(
|
|
663
|
+
repo,
|
|
664
|
+
"rev-parse",
|
|
665
|
+
"--verify",
|
|
666
|
+
f"{base}^{{commit}}",
|
|
667
|
+
).stdout.strip()
|
|
668
|
+
if base != base_sha:
|
|
669
|
+
raise PipelineGitError(f"base must be the full commit SHA: {base}")
|
|
670
|
+
if base_sha != default_sha:
|
|
671
|
+
raise PipelineGitError(
|
|
672
|
+
f"validated base is stale: {base_sha} != {remote_default} {default_sha}"
|
|
673
|
+
)
|
|
674
|
+
live_default_sha = _remote_ref_sha(repo, "refs/heads/main")
|
|
675
|
+
if live_default_sha != default_sha:
|
|
676
|
+
raise PipelineGitError(f"fetched {remote_default} is stale relative to origin")
|
|
677
|
+
ship_sha = _run_git(
|
|
678
|
+
repo,
|
|
679
|
+
"rev-parse",
|
|
680
|
+
"--verify",
|
|
681
|
+
f"{ship}^{{commit}}",
|
|
682
|
+
).stdout.strip()
|
|
683
|
+
if ship != ship_sha:
|
|
684
|
+
raise PipelineGitError(f"ship must be the full commit SHA: {ship}")
|
|
685
|
+
landing_sha = _run_git(
|
|
686
|
+
repo,
|
|
687
|
+
"rev-parse",
|
|
688
|
+
"--verify",
|
|
689
|
+
f"{landing}^{{commit}}",
|
|
690
|
+
).stdout.strip()
|
|
691
|
+
if landing != landing_sha:
|
|
692
|
+
raise PipelineGitError(f"landing must be the full commit SHA: {landing}")
|
|
693
|
+
previous_ref = f"refs/heads/{previous_branch}"
|
|
694
|
+
if _ref_exists(repo, previous_ref):
|
|
695
|
+
previous_sha = _run_git(
|
|
696
|
+
repo,
|
|
697
|
+
"rev-parse",
|
|
698
|
+
"--verify",
|
|
699
|
+
f"{previous_ref}^{{commit}}",
|
|
700
|
+
).stdout.strip()
|
|
701
|
+
if previous_sha != ship_sha:
|
|
702
|
+
raise PipelineGitError(
|
|
703
|
+
f"previous branch moved after ship: {previous_sha} != {ship_sha}"
|
|
704
|
+
)
|
|
705
|
+
integrated = _run_git(
|
|
706
|
+
repo,
|
|
707
|
+
"merge-base",
|
|
708
|
+
"--is-ancestor",
|
|
709
|
+
ship_sha,
|
|
710
|
+
landing_sha,
|
|
711
|
+
check=False,
|
|
712
|
+
)
|
|
713
|
+
if integrated.returncode == 1:
|
|
714
|
+
raise PipelineGitError("milestone landing does not contain the ship commit")
|
|
715
|
+
if integrated.returncode != 0:
|
|
716
|
+
detail = integrated.stderr.strip() or integrated.stdout.strip()
|
|
717
|
+
raise PipelineGitError(f"could not verify milestone landing: {detail}")
|
|
718
|
+
landing_integrated = _run_git(
|
|
719
|
+
repo,
|
|
720
|
+
"merge-base",
|
|
721
|
+
"--is-ancestor",
|
|
722
|
+
landing_sha,
|
|
723
|
+
base_sha,
|
|
724
|
+
check=False,
|
|
725
|
+
)
|
|
726
|
+
if landing_integrated.returncode == 1:
|
|
727
|
+
raise PipelineGitError("milestone landing is not integrated into the validated base")
|
|
728
|
+
if landing_integrated.returncode != 0:
|
|
729
|
+
detail = landing_integrated.stderr.strip() or landing_integrated.stdout.strip()
|
|
730
|
+
raise PipelineGitError(f"could not verify integrated branch: {detail}")
|
|
731
|
+
if allow_remote_absent:
|
|
732
|
+
proven_landing = _require_pull_request_integration_proof(
|
|
733
|
+
repo,
|
|
734
|
+
ship_sha,
|
|
735
|
+
base_sha,
|
|
736
|
+
previous_branch,
|
|
737
|
+
)
|
|
738
|
+
if proven_landing != landing_sha:
|
|
739
|
+
raise PipelineGitError(
|
|
740
|
+
"validated landing does not match pull-request integration proof"
|
|
741
|
+
)
|
|
742
|
+
|
|
743
|
+
symbolic_branch = _run_git(
|
|
744
|
+
repo,
|
|
745
|
+
"symbolic-ref",
|
|
746
|
+
"--quiet",
|
|
747
|
+
"--short",
|
|
748
|
+
"HEAD",
|
|
749
|
+
check=False,
|
|
750
|
+
)
|
|
751
|
+
if symbolic_branch.returncode != 0:
|
|
752
|
+
raise PipelineGitError("primary worktree must be on a named branch")
|
|
753
|
+
current = symbolic_branch.stdout.strip()
|
|
754
|
+
status = "already-bound" if current == branch else "bound"
|
|
755
|
+
if _run_git(repo, "status", "--porcelain", "--untracked-files=all").stdout:
|
|
756
|
+
raise PipelineGitError("primary worktree is not clean")
|
|
757
|
+
if _remote_branch_exists(repo, branch):
|
|
758
|
+
raise PipelineGitError(f"bound branch already exists: refs/heads/{branch} on origin")
|
|
759
|
+
|
|
760
|
+
request: dict[str, object] = {
|
|
761
|
+
"schema": BIND_NEXT_JOURNAL_SCHEMA,
|
|
762
|
+
"repo": str(repo),
|
|
763
|
+
"branch": branch,
|
|
764
|
+
"previous_branch": previous_branch,
|
|
765
|
+
"ship": ship_sha,
|
|
766
|
+
"remote_default": remote_default,
|
|
767
|
+
"base": base_sha,
|
|
768
|
+
"landing": landing_sha,
|
|
769
|
+
}
|
|
770
|
+
if allow_remote_absent:
|
|
771
|
+
request["allow_remote_absent"] = True
|
|
772
|
+
journal_path = bind_next_journal_path(repo, branch)
|
|
773
|
+
new_journal = False
|
|
774
|
+
if journal_path.exists() or journal_path.is_symlink():
|
|
775
|
+
journal = _read_bind_next_journal(journal_path)
|
|
776
|
+
legacy = journal.get("schema") == LEGACY_BIND_NEXT_JOURNAL_SCHEMA
|
|
777
|
+
expected_fields = set(request) | {"stage"}
|
|
778
|
+
if legacy:
|
|
779
|
+
legacy_fields = expected_fields - {"landing"}
|
|
780
|
+
if set(journal) != legacy_fields and set(journal) != expected_fields:
|
|
781
|
+
raise PipelineGitError("bind-next journal has unsupported fields")
|
|
782
|
+
elif set(journal) != expected_fields:
|
|
783
|
+
raise PipelineGitError("bind-next journal has unsupported fields")
|
|
784
|
+
mismatches = {
|
|
785
|
+
key: {"expected": value, "actual": journal.get(key)}
|
|
786
|
+
for key, value in request.items()
|
|
787
|
+
if key != "schema"
|
|
788
|
+
and (key != "landing" or "landing" in journal)
|
|
789
|
+
and journal.get(key) != value
|
|
790
|
+
}
|
|
791
|
+
if not legacy and journal.get("schema") != BIND_NEXT_JOURNAL_SCHEMA:
|
|
792
|
+
mismatches["schema"] = {
|
|
793
|
+
"expected": BIND_NEXT_JOURNAL_SCHEMA,
|
|
794
|
+
"actual": journal.get("schema"),
|
|
795
|
+
}
|
|
796
|
+
if mismatches:
|
|
797
|
+
raise PipelineGitError(
|
|
798
|
+
f"bind-next journal does not match request: {json.dumps(mismatches, sort_keys=True)}"
|
|
799
|
+
)
|
|
800
|
+
if journal.get("stage") not in {"prepared", "switched", "retired"}:
|
|
801
|
+
raise PipelineGitError("bind-next journal has invalid stage")
|
|
802
|
+
if legacy:
|
|
803
|
+
journal["schema"] = BIND_NEXT_JOURNAL_SCHEMA
|
|
804
|
+
journal["landing"] = landing_sha
|
|
805
|
+
_write_bind_next_journal(journal_path, journal)
|
|
806
|
+
else:
|
|
807
|
+
if current == branch:
|
|
808
|
+
raise PipelineGitError(
|
|
809
|
+
f"current target branch {branch} has no matching bind-next journal"
|
|
810
|
+
)
|
|
811
|
+
if current != previous_branch:
|
|
812
|
+
raise PipelineGitError(
|
|
813
|
+
f"current branch {current} does not match previous branch {previous_branch}"
|
|
814
|
+
)
|
|
815
|
+
local_branch_ref = f"refs/heads/{branch}"
|
|
816
|
+
if _ref_exists(repo, local_branch_ref):
|
|
817
|
+
raise PipelineGitError(f"bound branch already exists: {local_branch_ref}")
|
|
818
|
+
journal = {**request, "stage": "prepared"}
|
|
819
|
+
new_journal = True
|
|
820
|
+
|
|
821
|
+
stage = journal["stage"]
|
|
822
|
+
if current not in {previous_branch, branch}:
|
|
823
|
+
raise PipelineGitError(
|
|
824
|
+
f"current branch {current} does not match bind-next journal ownership"
|
|
825
|
+
)
|
|
826
|
+
if current == previous_branch and stage != "prepared":
|
|
827
|
+
raise PipelineGitError(
|
|
828
|
+
f"bind-next journal stage {stage} conflicts with current {previous_branch}"
|
|
829
|
+
)
|
|
830
|
+
if current == branch:
|
|
831
|
+
head = _run_git(repo, "rev-parse", "HEAD").stdout.strip()
|
|
832
|
+
if head != base_sha:
|
|
833
|
+
raise PipelineGitError(
|
|
834
|
+
f"existing {branch} is not at {remote_default}: {head} != {default_sha}"
|
|
835
|
+
)
|
|
836
|
+
if stage == "prepared":
|
|
837
|
+
journal["stage"] = "switched"
|
|
838
|
+
_write_bind_next_journal(journal_path, journal)
|
|
839
|
+
|
|
840
|
+
remote_previous = _remote_ref_sha(repo, f"refs/heads/{previous_branch}")
|
|
841
|
+
if remote_previous is None:
|
|
842
|
+
if not allow_remote_absent and (
|
|
843
|
+
current != branch or journal["stage"] not in {"switched", "retired"}
|
|
844
|
+
):
|
|
845
|
+
raise PipelineGitError(
|
|
846
|
+
f"origin/{previous_branch} is missing before retirement"
|
|
847
|
+
)
|
|
848
|
+
elif remote_previous != ship_sha:
|
|
849
|
+
raise PipelineGitError(
|
|
850
|
+
f"origin/{previous_branch} moved after ship: {remote_previous} != {ship_sha}"
|
|
851
|
+
)
|
|
852
|
+
|
|
853
|
+
if current == previous_branch:
|
|
854
|
+
if new_journal:
|
|
855
|
+
_write_bind_next_journal(journal_path, journal)
|
|
856
|
+
_run_git(repo, "switch", "--no-track", "-c", branch, base_sha)
|
|
857
|
+
journal["stage"] = "switched"
|
|
858
|
+
_write_bind_next_journal(journal_path, journal)
|
|
859
|
+
|
|
860
|
+
retire_previous_branch(
|
|
861
|
+
repo,
|
|
862
|
+
previous_branch,
|
|
863
|
+
ship_sha,
|
|
864
|
+
allow_remote_absent=(
|
|
865
|
+
allow_remote_absent or journal["stage"] in {"switched", "retired"}
|
|
866
|
+
),
|
|
867
|
+
)
|
|
868
|
+
journal["stage"] = "retired"
|
|
869
|
+
_write_bind_next_journal(journal_path, journal)
|
|
870
|
+
return {
|
|
871
|
+
"schema": "gsd-path/bind-next/v1",
|
|
872
|
+
"status": status,
|
|
873
|
+
"base": default_sha,
|
|
874
|
+
"landing": landing_sha,
|
|
875
|
+
"branch": branch,
|
|
876
|
+
"previous_branch": previous_branch,
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace:
|
|
881
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
882
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
883
|
+
bind_next = subparsers.add_parser(
|
|
884
|
+
"bind-next",
|
|
885
|
+
help=(
|
|
886
|
+
"bind the next milestone branch at the integrated remote default "
|
|
887
|
+
"and retire the previous bound branch"
|
|
888
|
+
),
|
|
889
|
+
)
|
|
890
|
+
bind_next.add_argument("--repo", required=True, type=Path)
|
|
891
|
+
bind_next.add_argument("--branch", required=True)
|
|
892
|
+
bind_next.add_argument("--previous-branch", required=True)
|
|
893
|
+
bind_next.add_argument("--ship", required=True)
|
|
894
|
+
bind_next.add_argument("--remote-default", required=True)
|
|
895
|
+
bind_next.add_argument("--base", required=True)
|
|
896
|
+
bind_next.add_argument("--landing", required=True)
|
|
897
|
+
bind_next.add_argument("--allow-missing-previous", action="store_true")
|
|
898
|
+
bind_initial = subparsers.add_parser(
|
|
899
|
+
"bind-initial",
|
|
900
|
+
help="bind the first milestone branch at the exact fetched remote default",
|
|
901
|
+
)
|
|
902
|
+
bind_initial.add_argument("--repo", required=True, type=Path)
|
|
903
|
+
bind_initial.add_argument("--branch", required=True)
|
|
904
|
+
bind_initial.add_argument("--remote-default", required=True)
|
|
905
|
+
bind_initial.add_argument("--base", required=True)
|
|
906
|
+
return parser.parse_args(argv)
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
910
|
+
args = parse_args(argv)
|
|
911
|
+
try:
|
|
912
|
+
if args.command == "bind-next":
|
|
913
|
+
result = bind_next_milestone_branch(
|
|
914
|
+
args.repo,
|
|
915
|
+
args.branch,
|
|
916
|
+
args.previous_branch,
|
|
917
|
+
args.ship,
|
|
918
|
+
args.remote_default,
|
|
919
|
+
args.base,
|
|
920
|
+
args.landing,
|
|
921
|
+
args.allow_missing_previous,
|
|
922
|
+
)
|
|
923
|
+
elif args.command == "bind-initial":
|
|
924
|
+
result = bind_initial_milestone_branch(
|
|
925
|
+
args.repo,
|
|
926
|
+
args.branch,
|
|
927
|
+
args.remote_default,
|
|
928
|
+
args.base,
|
|
929
|
+
)
|
|
930
|
+
else: # pragma: no cover - argparse rejects unknown commands
|
|
931
|
+
raise PipelineGitError(f"unknown command: {args.command}")
|
|
932
|
+
except PipelineGitError as exc:
|
|
933
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
934
|
+
return 1
|
|
935
|
+
print(json.dumps(result, sort_keys=True))
|
|
936
|
+
return 0
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
if __name__ == "__main__":
|
|
940
|
+
raise SystemExit(main())
|