@opengsd/gsd-core 1.12.0 → 1.14.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.opencode/plugins/gsd-core.js +12 -0
- package/agents/gsd-advisor-researcher.compact.md +85 -0
- package/agents/gsd-ai-researcher.compact.md +96 -0
- package/agents/gsd-assumptions-analyzer.compact.md +81 -0
- package/agents/gsd-code-fixer.compact.md +458 -0
- package/agents/gsd-code-fixer.md +5 -5
- package/agents/gsd-code-reviewer.compact.md +269 -0
- package/agents/gsd-code-reviewer.md +15 -3
- package/agents/gsd-codebase-mapper.compact.md +760 -0
- package/agents/gsd-debug-session-manager.compact.md +345 -0
- package/agents/gsd-doc-classifier.compact.md +192 -0
- package/agents/gsd-doc-synthesizer.compact.md +200 -0
- package/agents/gsd-doc-verifier.compact.md +143 -0
- package/agents/gsd-doc-writer.compact.md +440 -0
- package/agents/gsd-dom-verifier.compact.md +138 -0
- package/agents/gsd-domain-researcher.compact.md +141 -0
- package/agents/gsd-eval-auditor.compact.md +160 -0
- package/agents/gsd-eval-planner.compact.md +137 -0
- package/agents/gsd-executor.md +63 -35
- package/agents/gsd-framework-selector.compact.md +82 -0
- package/agents/gsd-integration-checker.compact.md +245 -0
- package/agents/gsd-intel-updater.compact.md +226 -0
- package/agents/gsd-mempalace-curator.compact.md +45 -0
- package/agents/gsd-nyquist-auditor.compact.md +179 -0
- package/agents/gsd-pattern-mapper.compact.md +275 -0
- package/agents/gsd-plan-checker.md +76 -57
- package/agents/gsd-planner.md +14 -0
- package/agents/gsd-project-researcher.compact.md +587 -0
- package/agents/gsd-research-synthesizer.compact.md +212 -0
- package/agents/gsd-roadmapper.compact.md +454 -0
- package/agents/gsd-roadmapper.md +13 -0
- package/agents/gsd-security-auditor.compact.md +162 -0
- package/agents/gsd-ui-auditor.compact.md +404 -0
- package/agents/gsd-ui-checker.compact.md +277 -0
- package/agents/gsd-ui-checker.md +19 -3
- package/agents/gsd-ui-researcher.compact.md +282 -0
- package/agents/gsd-ui-researcher.md +29 -0
- package/agents/gsd-user-profiler.compact.md +108 -0
- package/agents/gsd-verifier.md +23 -1
- package/bin/install.js +444 -134
- package/commands/gsd/cleanup.md +1 -0
- package/commands/gsd/code-review.md +2 -1
- package/commands/gsd/complete-milestone.md +1 -0
- package/commands/gsd/config.md +1 -0
- package/commands/gsd/debug.md +1 -0
- package/commands/gsd/execute-phase.md +1 -1
- package/commands/gsd/graphify.md +1 -0
- package/commands/gsd/health.md +1 -0
- package/commands/gsd/mempalace-capture.md +1 -0
- package/commands/gsd/mempalace-recall.md +1 -0
- package/commands/gsd/new-milestone.md +1 -0
- package/commands/gsd/new-project.md +1 -0
- package/commands/gsd/next.md +1 -0
- package/commands/gsd/ns-workflow.md +2 -1
- package/commands/gsd/pause-work.md +1 -0
- package/commands/gsd/phase.md +2 -1
- package/commands/gsd/pr-branch.md +1 -0
- package/commands/gsd/quick-batch.md +105 -0
- package/commands/gsd/resume-work.md +1 -0
- package/commands/gsd/review-backlog.md +1 -0
- package/commands/gsd/settings.md +2 -1
- package/commands/gsd/stats.md +1 -0
- package/commands/gsd/surface.md +18 -8
- package/commands/gsd/thread.md +1 -0
- package/commands/gsd/workspace.md +1 -0
- package/commands/gsd/workstreams.md +1 -0
- package/gsd-core/bin/check-latest-version.cjs +8 -3
- package/gsd-core/bin/gsd-tools.cjs +532 -174
- package/gsd-core/bin/lib/adr-parser.cjs +1 -1
- package/gsd-core/bin/lib/artifacts.cjs +2 -1
- package/gsd-core/bin/lib/audit.cjs +39 -22
- package/gsd-core/bin/lib/broken-windows.cjs +168 -49
- package/gsd-core/bin/lib/capability-activation.cjs +27 -0
- package/gsd-core/bin/lib/capability-lifecycle.cjs +10 -6
- package/gsd-core/bin/lib/capability-loader.cjs +135 -1
- package/gsd-core/bin/lib/capability-registry.cjs +528 -116
- package/gsd-core/bin/lib/capability-source.cjs +19 -2
- package/gsd-core/bin/lib/capability-state.cjs +7 -1
- package/gsd-core/bin/lib/capability-validator.cjs +134 -5
- package/gsd-core/bin/lib/capability-writer.cjs +14 -4
- package/gsd-core/bin/lib/check-command-router.cjs +198 -38
- package/gsd-core/bin/lib/claude-orchestration.cjs +10 -25
- package/gsd-core/bin/lib/clusters.cjs +1 -0
- package/gsd-core/bin/lib/code-review-depth.cjs +2 -2
- package/gsd-core/bin/lib/command-aliases.cjs +16 -0
- package/gsd-core/bin/lib/commands.cjs +981 -79
- package/gsd-core/bin/lib/config-loader.cjs +4 -0
- package/gsd-core/bin/lib/config.cjs +153 -38
- package/gsd-core/bin/lib/core-utils.cjs +34 -7
- package/gsd-core/bin/lib/coverage.cjs +1 -1
- package/gsd-core/bin/lib/decisions.cjs +343 -28
- package/gsd-core/bin/lib/edge-probe.cjs +14 -1
- package/gsd-core/bin/lib/external-descriptor-trust.cjs +29 -14
- package/gsd-core/bin/lib/file-overlap-partitioner.cjs +74 -0
- package/gsd-core/bin/lib/frontmatter.cjs +137 -23
- package/gsd-core/bin/lib/gap-checker.cjs +22 -13
- package/gsd-core/bin/lib/git-base-branch.cjs +10 -2
- package/gsd-core/bin/lib/gsd2-import.cjs +1 -2
- package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +8 -2
- package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +54 -11
- package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +87 -23
- package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +1 -1
- package/gsd-core/bin/lib/host-integration.cjs +57 -5
- package/gsd-core/bin/lib/init-command-router.cjs +14 -0
- package/gsd-core/bin/lib/init.cjs +539 -60
- package/gsd-core/bin/lib/install-engine.cjs +199 -14
- package/gsd-core/bin/lib/install-model-override-resolver.cjs +45 -0
- package/gsd-core/bin/lib/install-profiles.cjs +36 -14
- package/gsd-core/bin/lib/installer-migration-report.cjs +1 -0
- package/gsd-core/bin/lib/installer-migrations.cjs +33 -4
- package/gsd-core/bin/lib/io.cjs +35 -0
- package/gsd-core/bin/lib/loop-resolver.cjs +64 -39
- package/gsd-core/bin/lib/markdown-table.cjs +123 -0
- package/gsd-core/bin/lib/mcp-catalog.cjs +2 -2
- package/gsd-core/bin/lib/milestone.cjs +41 -10
- package/gsd-core/bin/lib/model-resolver.cjs +101 -10
- package/gsd-core/bin/lib/phase-command-router.cjs +20 -7
- package/gsd-core/bin/lib/phase-id.cjs +412 -31
- package/gsd-core/bin/lib/phase-lifecycle.cjs +61 -0
- package/gsd-core/bin/lib/phase.cjs +941 -98
- package/gsd-core/bin/lib/plan-document.cjs +10 -0
- package/gsd-core/bin/lib/planning-inspect.cjs +34 -18
- package/gsd-core/bin/lib/planning-snapshot.cjs +206 -30
- package/gsd-core/bin/lib/planning-workspace.cjs +153 -29
- package/gsd-core/bin/lib/pristine-baseline.cjs +182 -0
- package/gsd-core/bin/lib/prohibition-enforcement.cjs +91 -4
- package/gsd-core/bin/lib/quick-batch-command-router.cjs +285 -0
- package/gsd-core/bin/lib/quick-batch-dispatch.cjs +250 -0
- package/gsd-core/bin/lib/quick-batch.cjs +840 -0
- package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +61 -2
- package/gsd-core/bin/lib/research-store.cjs +11 -12
- package/gsd-core/bin/lib/review-lane-descriptor.cjs +53 -5
- package/gsd-core/bin/lib/review-lane-invocation.cjs +96 -1
- package/gsd-core/bin/lib/review-lane-runner.cjs +136 -10
- package/gsd-core/bin/lib/reviewer-step-dispatch.cjs +337 -0
- package/gsd-core/bin/lib/roadmap-parser.cjs +555 -41
- package/gsd-core/bin/lib/roadmap.cjs +292 -69
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +260 -43
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +28 -20
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +294 -108
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +408 -47
- package/gsd-core/bin/lib/security.cjs +126 -7
- package/gsd-core/bin/lib/shell-command-projection.cjs +4 -0
- package/gsd-core/bin/lib/smart-entry.cjs +7 -9
- package/gsd-core/bin/lib/state-document.cjs +159 -32
- package/gsd-core/bin/lib/state-md-schema.cjs +44 -27
- package/gsd-core/bin/lib/state-transition.cjs +465 -62
- package/gsd-core/bin/lib/state.cjs +906 -151
- package/gsd-core/bin/lib/surface.cjs +83 -10
- package/gsd-core/bin/lib/task-command-router.cjs +12 -6
- package/gsd-core/bin/lib/tdd-red-evidence.cjs +133 -0
- package/gsd-core/bin/lib/uat.cjs +1420 -516
- package/gsd-core/bin/lib/update-context.cjs +36 -26
- package/gsd-core/bin/lib/validate.cjs +230 -12
- package/gsd-core/bin/lib/vendor/js-yaml.cjs +11 -3
- package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
- package/gsd-core/bin/lib/verification.cjs +316 -23
- package/gsd-core/bin/lib/verify-command-grounding.cjs +1 -1
- package/gsd-core/bin/lib/verify-command-router.cjs +1 -0
- package/gsd-core/bin/lib/verify.cjs +531 -36
- package/gsd-core/bin/lib/workstream-inventory.cjs +21 -2
- package/gsd-core/bin/lib/worktree-safety.cjs +21 -7
- package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +13 -0
- package/gsd-core/bin/verify-reapply-patches.cjs +507 -81
- package/gsd-core/references/agent-contracts.md +3 -3
- package/gsd-core/references/compact-content-gate.md +66 -0
- package/gsd-core/references/edge-probe.md +17 -13
- package/gsd-core/references/execute-mvp-tdd.md +18 -16
- package/gsd-core/references/execute-phase-response-language.md +6 -0
- package/gsd-core/references/executor-examples.md +42 -0
- package/gsd-core/references/few-shot-examples/plan-checker.md +15 -15
- package/gsd-core/references/loop-hook-dispatch.md +18 -0
- package/gsd-core/references/model-profiles.md +12 -3
- package/gsd-core/references/mvp-concepts.md +2 -2
- package/gsd-core/references/plan-checker-examples.md +41 -0
- package/gsd-core/references/planner-antipatterns.md +25 -0
- package/gsd-core/references/planner-chunked.md +5 -1
- package/gsd-core/references/planner-coupling.md +42 -0
- package/gsd-core/references/planner-quick-batch.md +71 -0
- package/gsd-core/references/planner-reviews.md +47 -0
- package/gsd-core/references/planner-revision.md +75 -2
- package/gsd-core/references/planning-config.md +5 -1
- package/gsd-core/references/response-language-directive.md +9 -0
- package/gsd-core/references/revision-loop.md +118 -11
- package/gsd-core/references/tdd.md +17 -9
- package/gsd-core/references/thinking-models-planning.md +18 -2
- package/gsd-core/references/verification-patterns.md +17 -4
- package/gsd-core/references/verifier-evidence-gate.md +160 -0
- package/gsd-core/references/worktree-path-safety.md +112 -2
- package/gsd-core/templates/README.md +7 -1
- package/gsd-core/templates/phase-prompt.md +4 -0
- package/gsd-core/templates/state.md +6 -3
- package/gsd-core/templates/summary.compact.md +212 -0
- package/gsd-core/templates/user-setup.compact.md +199 -0
- package/gsd-core/templates/user-setup.md +0 -9
- package/gsd-core/templates/verification-report.md +5 -0
- package/gsd-core/workflows/add-backlog.md +2 -0
- package/gsd-core/workflows/add-phase.md +2 -0
- package/gsd-core/workflows/add-tests.md +1 -1
- package/gsd-core/workflows/add-todo.md +4 -3
- package/gsd-core/workflows/ai-integration-phase.md +1 -1
- package/gsd-core/workflows/analyze-dependencies.md +2 -0
- package/gsd-core/workflows/audit-fix.md +2 -0
- package/gsd-core/workflows/audit-milestone.md +2 -0
- package/gsd-core/workflows/audit-uat.md +2 -0
- package/gsd-core/workflows/autonomous.md +15 -10
- package/gsd-core/workflows/check-todos.md +5 -3
- package/gsd-core/workflows/cleanup.md +4 -2
- package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +22 -13
- package/gsd-core/workflows/code-review-fix.md +5 -3
- package/gsd-core/workflows/code-review.md +211 -43
- package/gsd-core/workflows/complete-milestone/detail/elaboration.md +274 -0
- package/gsd-core/workflows/complete-milestone.md +40 -254
- package/gsd-core/workflows/debug.md +1 -1
- package/gsd-core/workflows/diagnose-issues.md +5 -1
- package/gsd-core/workflows/discuss-phase/modes/advisor.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/all.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/analyze.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/auto.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/batch.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/chain.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/default.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/power.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/text.md +2 -0
- package/gsd-core/workflows/discuss-phase/templates/context.md +2 -0
- package/gsd-core/workflows/discuss-phase/templates/discussion-log.md +2 -0
- package/gsd-core/workflows/discuss-phase-assumptions.md +1 -1
- package/gsd-core/workflows/discuss-phase-power.md +2 -0
- package/gsd-core/workflows/discuss-phase.md +1 -1
- package/gsd-core/workflows/do.md +43 -13
- package/gsd-core/workflows/docs-update/detail/elaboration.md +179 -0
- package/gsd-core/workflows/docs-update.md +15 -156
- package/gsd-core/workflows/edit-phase.md +2 -0
- package/gsd-core/workflows/eval-review.md +1 -1
- package/gsd-core/workflows/execute-phase/detail/elaboration.md +124 -0
- package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +20 -3
- package/gsd-core/workflows/execute-phase/steps/completion-reconciliation.md +56 -0
- package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +24 -3
- package/gsd-core/workflows/execute-phase/steps/executor-progress-policy.md +43 -0
- package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +8 -2
- package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +2 -0
- package/gsd-core/workflows/execute-phase/steps/sequential-root-pin.md +35 -0
- package/gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md +25 -0
- package/gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md +2 -0
- package/gsd-core/workflows/execute-phase.md +78 -159
- package/gsd-core/workflows/execute-plan.md +28 -15
- package/gsd-core/workflows/explore.md +2 -0
- package/gsd-core/workflows/extract-learnings.md +2 -0
- package/gsd-core/workflows/fast.md +6 -0
- package/gsd-core/workflows/forensics.md +2 -0
- package/gsd-core/workflows/graduation.md +1 -1
- package/gsd-core/workflows/health.md +1 -1
- package/gsd-core/workflows/help/modes/brief.md +2 -0
- package/gsd-core/workflows/help/modes/default.md +2 -0
- package/gsd-core/workflows/help/modes/full.compact.md +398 -0
- package/gsd-core/workflows/help/modes/full.md +12 -0
- package/gsd-core/workflows/help/modes/topic.md +2 -0
- package/gsd-core/workflows/help.md +3 -1
- package/gsd-core/workflows/import.md +3 -3
- package/gsd-core/workflows/inbox.md +1 -1
- package/gsd-core/workflows/ingest-docs.md +1 -1
- package/gsd-core/workflows/insert-phase.md +2 -0
- package/gsd-core/workflows/list-phase-assumptions.md +2 -0
- package/gsd-core/workflows/list-seeds.md +2 -0
- package/gsd-core/workflows/list-workspaces.md +2 -0
- package/gsd-core/workflows/manager.md +3 -3
- package/gsd-core/workflows/map-codebase.md +52 -3
- package/gsd-core/workflows/milestone-summary.md +2 -0
- package/gsd-core/workflows/mvp-phase.md +1 -1
- package/gsd-core/workflows/new-milestone.md +55 -13
- package/gsd-core/workflows/new-project/detail/elaboration.md +216 -0
- package/gsd-core/workflows/new-project.md +37 -205
- package/gsd-core/workflows/new-workspace.md +1 -1
- package/gsd-core/workflows/next.md +2 -0
- package/gsd-core/workflows/node-repair.md +2 -0
- package/gsd-core/workflows/note.md +2 -0
- package/gsd-core/workflows/onboard.md +1 -1
- package/gsd-core/workflows/pause-work.md +19 -4
- package/gsd-core/workflows/plan-phase/detail/elaboration.md +209 -0
- package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +100 -18
- package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +2 -0
- package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +9 -0
- package/gsd-core/workflows/plan-phase.md +144 -185
- package/gsd-core/workflows/plan-review-convergence.md +102 -10
- package/gsd-core/workflows/plant-seed.md +1 -1
- package/gsd-core/workflows/pr-branch.md +30 -10
- package/gsd-core/workflows/profile-user.md +1 -1
- package/gsd-core/workflows/progress/steps/forensic-audit.md +1 -1
- package/gsd-core/workflows/progress.md +25 -3
- package/gsd-core/workflows/quick/steps/plan-checker-loop.md +37 -2
- package/gsd-core/workflows/quick/steps/research-phase.md +3 -3
- package/gsd-core/workflows/quick-batch/steps/batch-init.md +55 -0
- package/gsd-core/workflows/quick-batch/steps/completion.md +65 -0
- package/gsd-core/workflows/quick-batch/steps/merge-wave.md +100 -0
- package/gsd-core/workflows/quick-batch/steps/plan-checker-loop.md +147 -0
- package/gsd-core/workflows/quick-batch/steps/planner-wave.md +158 -0
- package/gsd-core/workflows/quick-batch/steps/research-phase.md +95 -0
- package/gsd-core/workflows/quick-batch/steps/resume-mode.md +49 -0
- package/gsd-core/workflows/quick-batch/steps/verification-wave.md +73 -0
- package/gsd-core/workflows/quick-batch/steps/worktree-dispatch.md +169 -0
- package/gsd-core/workflows/quick-batch.md +203 -0
- package/gsd-core/workflows/quick.md +21 -4
- package/gsd-core/workflows/reapply-patches.md +79 -3
- package/gsd-core/workflows/remove-phase.md +2 -0
- package/gsd-core/workflows/remove-workspace.md +1 -1
- package/gsd-core/workflows/resume-project.md +6 -2
- package/gsd-core/workflows/review.md +215 -10
- package/gsd-core/workflows/scan.md +2 -0
- package/gsd-core/workflows/section-manifest.json +12 -0
- package/gsd-core/workflows/secure-phase.md +1 -1
- package/gsd-core/workflows/session-report.md +2 -0
- package/gsd-core/workflows/settings-advanced.md +2 -0
- package/gsd-core/workflows/settings-integrations.md +9 -8
- package/gsd-core/workflows/settings.md +19 -6
- package/gsd-core/workflows/ship.md +10 -10
- package/gsd-core/workflows/sketch-wrap-up.md +2 -0
- package/gsd-core/workflows/sketch.md +1 -1
- package/gsd-core/workflows/smart-entry.md +1 -1
- package/gsd-core/workflows/spec-phase.md +24 -19
- package/gsd-core/workflows/spike-wrap-up.md +2 -0
- package/gsd-core/workflows/spike.md +1 -1
- package/gsd-core/workflows/stats.md +2 -0
- package/gsd-core/workflows/sync-skills.md +12 -4
- package/gsd-core/workflows/thread.md +2 -0
- package/gsd-core/workflows/transition.md +2 -0
- package/gsd-core/workflows/ui-phase.md +26 -5
- package/gsd-core/workflows/ui-review.md +1 -1
- package/gsd-core/workflows/ultraplan-phase.md +2 -0
- package/gsd-core/workflows/undo.md +1 -1
- package/gsd-core/workflows/update.md +48 -43
- package/gsd-core/workflows/validate-phase.md +1 -1
- package/gsd-core/workflows/verify-work/detail/elaboration.md +230 -0
- package/gsd-core/workflows/verify-work.md +68 -182
- package/hooks/dist/gsd-agent-isolation-guard.js +42 -16
- package/hooks/dist/gsd-check-update-worker.js +19 -2
- package/hooks/dist/gsd-context-monitor.js +371 -27
- package/hooks/dist/gsd-cursor-subagent-start.js +34 -14
- package/hooks/dist/gsd-node-runner.sh +1 -0
- package/hooks/dist/gsd-prompt-guard.js +30 -5
- package/hooks/dist/gsd-read-guard.js +2 -0
- package/hooks/dist/gsd-read-injection-scanner.js +5 -5
- package/hooks/dist/gsd-secret-read-guard.js +1105 -0
- package/hooks/dist/gsd-statusline.js +18 -10
- package/hooks/dist/gsd-validate-commit.sh +474 -7
- package/hooks/dist/gsd-workflow-guard.js +2 -1
- package/hooks/dist/gsd-worktree-path-guard.js +25 -14
- package/hooks/dist/gsd-write-guard.js +46 -1
- package/hooks/dist/lib/dispatch-identity.js +187 -0
- package/hooks/dist/lib/filename-classification.js +64 -0
- package/hooks/dist/lib/git-cmd.js +210 -1
- package/hooks/dist/lib/injection-patterns.js +36 -6
- package/hooks/dist/lib/isolation-deny-reason.js +53 -1
- package/hooks/dist/lib/isolation-sentinel.js +58 -19
- package/hooks/dist/managed-hooks-registry.cjs +1 -0
- package/hooks/gsd-agent-isolation-guard.js +42 -16
- package/hooks/gsd-check-update-worker.js +19 -2
- package/hooks/gsd-context-monitor.js +371 -27
- package/hooks/gsd-cursor-subagent-start.js +34 -14
- package/hooks/gsd-node-runner.sh +1 -0
- package/hooks/gsd-prompt-guard.js +30 -5
- package/hooks/gsd-read-guard.js +2 -0
- package/hooks/gsd-read-injection-scanner.js +5 -5
- package/hooks/gsd-secret-read-guard.js +1105 -0
- package/hooks/gsd-statusline.js +18 -10
- package/hooks/gsd-validate-commit.sh +474 -7
- package/hooks/gsd-workflow-guard.js +2 -1
- package/hooks/gsd-worktree-path-guard.js +25 -14
- package/hooks/gsd-write-guard.js +46 -1
- package/hooks/hooks.json +6 -0
- package/hooks/lib/dispatch-identity.js +187 -0
- package/hooks/lib/filename-classification.js +64 -0
- package/hooks/lib/git-cmd.js +210 -1
- package/hooks/lib/injection-patterns.js +36 -6
- package/hooks/lib/isolation-deny-reason.js +53 -1
- package/hooks/lib/isolation-sentinel.js +58 -19
- package/hooks/managed-hooks-registry.cjs +1 -0
- package/package.json +13 -9
- package/scripts/benchmark-compact-content-variants.cjs +298 -0
- package/scripts/benchmark-compact-content.cjs +368 -0
- package/scripts/build-hooks.js +11 -4
- package/scripts/check-contract-drift.cjs +4 -1
- package/scripts/check-env.cjs +36 -8
- package/scripts/check-glossary-refs.cjs +25 -21
- package/scripts/ci-next-health.cjs +271 -0
- package/scripts/ci-prepare-test-scope.cjs +7 -7
- package/scripts/ci-test-scope.cjs +133 -20
- package/scripts/ci-timeout-report.cjs +1 -1
- package/scripts/diff-touches-shipped-paths.cjs +1 -1
- package/scripts/docs-guard-registry.cjs +17 -2
- package/scripts/gen-adr-index.cjs +8 -2
- package/scripts/gen-inventory-manifest.cjs +12 -0
- package/scripts/gen-loop-host-contract.cjs +67 -15
- package/scripts/gen-platform-conformance-tier.cjs +557 -0
- package/scripts/lib/drift-scan.cjs +1 -1
- package/scripts/lib/macos-conformance-tier.generated.cjs +210 -0
- package/scripts/lib/npm-version-check-diagnosis.cjs +59 -0
- package/scripts/lib/platform-conformance-tier.generated.cjs +276 -0
- package/scripts/lib/shellcheck-fetch.cjs +247 -0
- package/scripts/lib/suite-detection.cjs +32 -0
- package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -6
- package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +1 -1
- package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +1 -1
- package/scripts/lint-allowed-tools-parity.cjs +221 -0
- package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +24 -2
- package/scripts/lint-phase-enumeration-drift.cjs +24 -6
- package/scripts/lint-phase-id-drift.cjs +465 -15
- package/scripts/lint-portable-grep.cjs +176 -0
- package/scripts/lint-response-language-coverage.cjs +530 -0
- package/scripts/lint-source-test-name-collision.cjs +1 -1
- package/scripts/lint-test-file-count.allowlist.json +4 -1
- package/scripts/lint-vendored-deps.cjs +128 -17
- package/scripts/lint-workflow-shellcheck-baseline.json +1112 -0
- package/scripts/lint-workflow-shellcheck.cjs +614 -0
- package/scripts/npm-audit-baseline.cjs +376 -0
- package/scripts/prompt-injection-scan.sh +22 -0
- package/scripts/require-issue-link-policy.cjs +16 -1
- package/scripts/workflow-size.cjs +139 -0
- package/skills/gsd-cleanup/SKILL.md +1 -0
- package/skills/gsd-code-review/SKILL.md +2 -1
- package/skills/gsd-complete-milestone/SKILL.md +1 -0
- package/skills/gsd-config/SKILL.md +1 -0
- package/skills/gsd-debug/SKILL.md +1 -0
- package/skills/gsd-execute-phase/SKILL.md +1 -1
- package/skills/gsd-graphify/SKILL.md +1 -0
- package/skills/gsd-health/SKILL.md +1 -0
- package/skills/gsd-mempalace-capture/SKILL.md +1 -0
- package/skills/gsd-mempalace-recall/SKILL.md +1 -0
- package/skills/gsd-new-milestone/SKILL.md +1 -0
- package/skills/gsd-new-project/SKILL.md +1 -0
- package/skills/gsd-next/SKILL.md +1 -0
- package/skills/gsd-ns-workflow/SKILL.md +1 -0
- package/skills/gsd-pause-work/SKILL.md +1 -0
- package/skills/gsd-phase/SKILL.md +2 -1
- package/skills/gsd-pr-branch/SKILL.md +1 -0
- package/skills/gsd-quick-batch/SKILL.md +105 -0
- package/skills/gsd-resume-work/SKILL.md +1 -0
- package/skills/gsd-review-backlog/SKILL.md +1 -0
- package/skills/gsd-settings/SKILL.md +2 -1
- package/skills/gsd-stats/SKILL.md +1 -0
- package/skills/gsd-surface/SKILL.md +18 -8
- package/skills/gsd-thread/SKILL.md +1 -0
- package/skills/gsd-workspace/SKILL.md +1 -0
- package/skills/gsd-workstreams/SKILL.md +1 -0
- package/vscode/package.json +1 -1
- package/gsd-core/templates/claude-md.md +0 -145
- package/gsd-core/templates/codebase/concerns.md +0 -310
- package/gsd-core/templates/codebase/conventions.md +0 -307
- package/gsd-core/templates/codebase/integrations.md +0 -280
- package/gsd-core/templates/codebase/structure.md +0 -285
- package/gsd-core/templates/codebase/testing.md +0 -480
- package/gsd-core/templates/debug-subagent-prompt.md +0 -91
- package/gsd-core/templates/discovery.md +0 -146
|
@@ -17,7 +17,11 @@ const pattern_cjs_1 = require("./pattern.cjs");
|
|
|
17
17
|
const security_cjs_1 = require("./security.cjs");
|
|
18
18
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
19
19
|
const ioMod = require("./io.cjs");
|
|
20
|
-
const { output,
|
|
20
|
+
const { output, ERROR_REASON } = ioMod;
|
|
21
|
+
// Explicitly annotated so TypeScript applies never-return control-flow narrowing.
|
|
22
|
+
// See the identical note in check-command-router.cts: a destructured const carries no
|
|
23
|
+
// type annotation, so TS will not narrow after `error(...)` without this.
|
|
24
|
+
const error = ioMod.error;
|
|
21
25
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
22
26
|
const configLoaderMod = require("./config-loader.cjs");
|
|
23
27
|
const { loadConfig, isGitIgnored } = configLoaderMod;
|
|
@@ -26,7 +30,7 @@ const coreUtilsMod = require("./core-utils.cjs");
|
|
|
26
30
|
const { toPosixPath, generateSlugInternal, extractOneLinerFromBody } = coreUtilsMod;
|
|
27
31
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
28
32
|
const phaseIdMod = require("./phase-id.cjs");
|
|
29
|
-
const { normalizePhaseName, comparePhaseNum, extractPhaseToken, PHASE_NUMBER_TOKEN_SOURCE, isSentinelPhaseId } = phaseIdMod;
|
|
33
|
+
const { normalizePhaseName, comparePhaseNum, extractPhaseToken, PHASE_NUMBER_TOKEN_SOURCE, isSentinelPhaseId, renderPhaseBranchName } = phaseIdMod;
|
|
30
34
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
31
35
|
const phaseLocatorMod = require("./phase-locator.cjs");
|
|
32
36
|
const { getArchivedPhaseDirs, findPhaseInternal, listMilestonePhaseDirs } = phaseLocatorMod;
|
|
@@ -52,7 +56,7 @@ const codex_agent_toml_cjs_1 = require("./codex-agent-toml.cjs");
|
|
|
52
56
|
const hostIntegrationMod = require("./host-integration.cjs");
|
|
53
57
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
54
58
|
const planningWorkspace = require("./planning-workspace.cjs");
|
|
55
|
-
const { planningDir, planningPaths } = planningWorkspace;
|
|
59
|
+
const { planningDir, planningPaths, todosDir } = planningWorkspace;
|
|
56
60
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
57
61
|
const frontmatter = require("./frontmatter.cjs");
|
|
58
62
|
const { extractFrontmatter, agentScalarNeedsDoubleQuoting, escapeDoubleQuotedScalar } = frontmatter;
|
|
@@ -186,7 +190,10 @@ function cmdCurrentTimestamp(format, raw) {
|
|
|
186
190
|
output({ timestamp: result }, raw, result);
|
|
187
191
|
}
|
|
188
192
|
function cmdListTodos(cwd, area, raw) {
|
|
189
|
-
|
|
193
|
+
// #4256: todos are root-scoped shared state — resolve via todosDir(cwd),
|
|
194
|
+
// never planningDir(cwd) (workstream-scoped), or the listing goes empty
|
|
195
|
+
// under a workstream.
|
|
196
|
+
const pendingDir = node_path_1.default.join(todosDir(cwd), 'pending');
|
|
190
197
|
let count = 0;
|
|
191
198
|
const todos = [];
|
|
192
199
|
try {
|
|
@@ -282,7 +289,7 @@ function cmdListSeeds(cwd, statusFilter, raw) {
|
|
|
282
289
|
continue;
|
|
283
290
|
let safeFilePath;
|
|
284
291
|
try {
|
|
285
|
-
safeFilePath = (0, security_cjs_1.requireSafePath)(node_path_1.default.join(seedsDir, entry.name), planDir, 'seed file',
|
|
292
|
+
safeFilePath = (0, security_cjs_1.requireSafePath)(node_path_1.default.join(seedsDir, entry.name), planDir, 'seed file', security_cjs_1.PathAcceptance.AbsoluteInsideRoot);
|
|
286
293
|
}
|
|
287
294
|
catch {
|
|
288
295
|
continue;
|
|
@@ -553,13 +560,11 @@ function cmdResolveExecution(cwd, agentType, raw, opts) {
|
|
|
553
560
|
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/unbound-method
|
|
554
561
|
const { getGlobalConfigDir } = require('./runtime-homes.cjs');
|
|
555
562
|
const agentsDirEff = node_path_1.default.join(getGlobalConfigDir(runtime), 'agents');
|
|
556
|
-
const agentPath = node_path_1.default.join(agentsDirEff, `${agentType}.md`);
|
|
557
563
|
// agentType is an unvalidated CLI positional: keep the read inside the
|
|
558
564
|
// agents dir so `../../x` cannot point it elsewhere (defense in depth —
|
|
559
|
-
// the reflected surface is only a frontmatter effort line).
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
}
|
|
565
|
+
// the reflected surface is only a frontmatter effort line). Untrusted
|
|
566
|
+
// input feeding a real read → realpath family (ADR-4650 decision 6).
|
|
567
|
+
const agentPath = (0, security_cjs_1.assertWithinRoot)(`${agentType}.md`, agentsDirEff, 'agent file');
|
|
563
568
|
const agentContent = node_fs_1.default.readFileSync(agentPath, 'utf8');
|
|
564
569
|
// eslint-disable-next-line local/no-unbounded-quantifier -- same lazy `*?` bounded by the `^---$/m` closing anchor as the sibling frontmatter regexes in this file
|
|
565
570
|
const fmMatchEff = /^---\r?\n([\s\S]*?)^---\r?$/m.exec(agentContent);
|
|
@@ -625,6 +630,11 @@ function cmdResolveExecution(cwd, agentType, raw, opts) {
|
|
|
625
630
|
* applies here exactly as everywhere else: an unknown host, a missing axis, or the
|
|
626
631
|
* `undocumented` sentinel all degrade to the safe floor rather than being trusted.
|
|
627
632
|
* Never throws — a lookup failure yields `'none'`, which renders no argument.
|
|
633
|
+
*
|
|
634
|
+
* On the module's export surface for #4255: the reviewer-lane effort resolver renders a lane's own
|
|
635
|
+
* configured level through this same negotiation, so a lane can never emit an argument for a host
|
|
636
|
+
* whose negotiated surface does not accept one. One negotiation, both channels — a second copy in
|
|
637
|
+
* the lane path is exactly how the two would drift.
|
|
628
638
|
*/
|
|
629
639
|
function effortSurfaceForHost(cwd, host) {
|
|
630
640
|
void cwd;
|
|
@@ -1352,19 +1362,33 @@ function detectPhaseNumberFromFiles(files) {
|
|
|
1352
1362
|
if (!phaseDir)
|
|
1353
1363
|
continue;
|
|
1354
1364
|
const token = extractPhaseToken(phaseDir);
|
|
1355
|
-
//
|
|
1356
|
-
//
|
|
1357
|
-
//
|
|
1358
|
-
//
|
|
1359
|
-
//
|
|
1360
|
-
//
|
|
1361
|
-
//
|
|
1362
|
-
|
|
1365
|
+
// normalizePhaseName is the canonical arbiter of "is this a real phase
|
|
1366
|
+
// token": it strips the project-code prefix and returns a zero-padded
|
|
1367
|
+
// numeric form for a genuine phase token, or the input unchanged
|
|
1368
|
+
// otherwise. Accept the token whenever it normalizes to a numeric
|
|
1369
|
+
// phase form (the single-owner rule shared by every other phase-token
|
|
1370
|
+
// reader — see #2528).
|
|
1371
|
+
//
|
|
1372
|
+
// #4126 fix: this used to also require `token !== phaseDir`, on the
|
|
1373
|
+
// assumption that extractPhaseToken returning its input unchanged
|
|
1374
|
+
// always means "no numeric token found" (its no-match fallback).
|
|
1375
|
+
// That assumption is false for a BARE phase directory with no slug
|
|
1376
|
+
// remainder (e.g. `.planning/phases/01/`): extractPhaseToken correctly
|
|
1377
|
+
// reads "01" as the token, which is simply identical to the directory
|
|
1378
|
+
// name in that case — not a fallback. The stale equality check
|
|
1379
|
+
// rejected every such directory, leaving `phaseNum` null and silently
|
|
1380
|
+
// skipping the whole phase-branch block below (undetected because
|
|
1381
|
+
// `phaseTokenShape.test(normalized)` already excludes genuine
|
|
1382
|
+
// non-phase fallbacks — e.g. `docs`, `CK-docs` — on its own, since
|
|
1383
|
+
// extractPhaseToken's real no-match fallback only fires for dirNames
|
|
1384
|
+
// that do not start with a digit or short letter+digit prefix, which
|
|
1385
|
+
// normalizePhaseName's leading-`\d+` requirement rejects regardless).
|
|
1363
1386
|
// Built from the single-owner PHASE_NUMBER_TOKEN_SOURCE (the canonical
|
|
1364
1387
|
// phase-number grammar — #2128 anti-divergence guard) so this read-side
|
|
1365
1388
|
// acceptance check cannot drift from every other phase-token reader.
|
|
1389
|
+
const normalized = normalizePhaseName(token);
|
|
1366
1390
|
const phaseTokenShape = new RegExp(`^${PHASE_NUMBER_TOKEN_SOURCE}$`, 'i');
|
|
1367
|
-
if (
|
|
1391
|
+
if (phaseTokenShape.test(normalized)) {
|
|
1368
1392
|
return token;
|
|
1369
1393
|
}
|
|
1370
1394
|
}
|
|
@@ -1437,7 +1461,296 @@ const COMMIT_DOCS_SKIP_REASON = {
|
|
|
1437
1461
|
config: 'skipped_commit_docs_false',
|
|
1438
1462
|
gitignore: 'skipped_gitignored',
|
|
1439
1463
|
};
|
|
1440
|
-
|
|
1464
|
+
// #4208 review: the declared-removal staging lifted out of cmdCommit, which was
|
|
1465
|
+
// already a critical-risk hotspot before this flag existed. Pure motion -- the
|
|
1466
|
+
// classification, canonicalisation and entry recording below are unchanged; only
|
|
1467
|
+
// the two accumulators are local names that the caller merges. `removedPathspec`
|
|
1468
|
+
// is what joins the commit's pathspec; `removedEntries` is what the caller's
|
|
1469
|
+
// rollback and its no-change exits restore from.
|
|
1470
|
+
function stageDeclaredRemovals(cwd, removedDeclared) {
|
|
1471
|
+
const failures = [];
|
|
1472
|
+
const removedPathspec = [];
|
|
1473
|
+
// The empty blob under SHA-1 and SHA-256 object formats — intent-to-add's tell.
|
|
1474
|
+
const EMPTY_BLOBS = new Set(['e69de29bb2d1d6434b8b29ae775ad8c2e48c5391', '473a0f4c3be8a93681a267e3b1e9a7dcda1185436fe141f7749120a303721813']);
|
|
1475
|
+
// A PATH FROM THE INDEX IS NOT A PATHSPEC. `git rm`, `ls-files` and friends
|
|
1476
|
+
// parse their operands as pathspecs, so a tracked file literally named
|
|
1477
|
+
// `.planning/*.md` GLOBS when handed back to git: driven, `rm --cached` on it
|
|
1478
|
+
// also removed `peer.md` and `stays.md`, and only the declared entry was
|
|
1479
|
+
// recorded — so the rollback restored one of three and the other two rode out
|
|
1480
|
+
// as undisclosed staged deletions. The magic-prefix twin is quieter still: a
|
|
1481
|
+
// file named `:(literal)mine` has its prefix PARSED, so the rm matches nothing,
|
|
1482
|
+
// exits 0, and the entry silently survives a removal this call then claims.
|
|
1483
|
+
// `:(literal)` disables every other magic, including globbing, so the operand
|
|
1484
|
+
// means the file it names.
|
|
1485
|
+
const lit = (p) => `:(literal)${p}`;
|
|
1486
|
+
const notARemoval = (e) => {
|
|
1487
|
+
if (e.mode === '160000')
|
|
1488
|
+
return 'a submodule gitlink, not a file';
|
|
1489
|
+
if (e.tag === 'S')
|
|
1490
|
+
return 'skip-worktree (sparse-checkout): absent by checkout, not removed';
|
|
1491
|
+
if (e.tag === 'h')
|
|
1492
|
+
return 'assume-unchanged: git does not consult its worktree state';
|
|
1493
|
+
if (e.stage !== '0')
|
|
1494
|
+
return 'an unmerged index entry';
|
|
1495
|
+
if (e.tag !== 'H')
|
|
1496
|
+
return `index state '${e.tag}'`;
|
|
1497
|
+
return null;
|
|
1498
|
+
};
|
|
1499
|
+
const lstatState = (p) => {
|
|
1500
|
+
try {
|
|
1501
|
+
node_fs_1.default.lstatSync(p);
|
|
1502
|
+
return 'present';
|
|
1503
|
+
}
|
|
1504
|
+
catch (e) {
|
|
1505
|
+
const err = e;
|
|
1506
|
+
return err.code === 'ENOENT' || err.code === 'ENOTDIR' ? 'absent' : err;
|
|
1507
|
+
}
|
|
1508
|
+
};
|
|
1509
|
+
// `rev-parse -q --verify HEAD` exits 1 both for an unborn HEAD and for a
|
|
1510
|
+
// spawn timeout (`execGit` collapses one to `exitCode: 1`). Only a probe that
|
|
1511
|
+
// actually answered may downgrade the union to index-only; an unanswered one
|
|
1512
|
+
// fails closed, because silently dropping the HEAD half re-opens the
|
|
1513
|
+
// pre-staged-deletion omission this union exists to close.
|
|
1514
|
+
let headExists = false;
|
|
1515
|
+
let headProbeFailure = null;
|
|
1516
|
+
if (removedDeclared.length > 0) {
|
|
1517
|
+
const headProbe = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '-q', '--verify', 'HEAD'], { cwd });
|
|
1518
|
+
if (headProbe.exitCode === 0) {
|
|
1519
|
+
headExists = true;
|
|
1520
|
+
}
|
|
1521
|
+
else if ((0, shell_command_projection_cjs_1.isSpawnTimeout)(headProbe) || headProbe.error !== null) {
|
|
1522
|
+
headProbeFailure = { error: headProbe.stderr || headProbe.stdout || 'HEAD probe failed', timed_out: (0, shell_command_projection_cjs_1.isSpawnTimeout)(headProbe) };
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
// Every index entry this call removes, recorded BEFORE the `rm --cached`
|
|
1526
|
+
// so the rollback below can put it back exactly — mode and blob — with
|
|
1527
|
+
// `update-index --cacheinfo`. `git reset -- <path>` cannot do that: it
|
|
1528
|
+
// restores from HEAD, which does not exist on an unborn branch (so a root
|
|
1529
|
+
// commit's failed call used to leave every earlier removal unstaged, in
|
|
1530
|
+
// violation of the only-what-THIS-call-staged invariant above) and which
|
|
1531
|
+
// is not what the index held when the caller had pre-staged a modified
|
|
1532
|
+
// blob at that path. Recording the entry answers both without putting the
|
|
1533
|
+
// path on the commit pathspec, where an unborn HEAD makes `git commit`
|
|
1534
|
+
// refuse it (driven; see the union note above).
|
|
1535
|
+
const removedEntries = [];
|
|
1536
|
+
for (const entry of removedDeclared) {
|
|
1537
|
+
if (headProbeFailure !== null) {
|
|
1538
|
+
failures.push({ file: entry, ...headProbeFailure });
|
|
1539
|
+
continue;
|
|
1540
|
+
}
|
|
1541
|
+
// `-v -s`: tag, mode, blob, stage and path per record — see notARemoval.
|
|
1542
|
+
// `lit` here too: the caller's declared entry is a PATH, not a glob —
|
|
1543
|
+
// that is `--files-removed`'s whole contract — and :(literal) still
|
|
1544
|
+
// resolves a directory to its descendants (driven), so the directory form
|
|
1545
|
+
// is unaffected while a file literally named `*.md` or `:(literal)x` means
|
|
1546
|
+
// itself.
|
|
1547
|
+
const listed = (0, shell_command_projection_cjs_1.execGit)(['ls-files', '-v', '-s', '-z', '--', lit(entry)], { cwd });
|
|
1548
|
+
if (listed.exitCode !== 0) {
|
|
1549
|
+
failures.push({
|
|
1550
|
+
file: entry,
|
|
1551
|
+
error: listed.stderr || listed.stdout,
|
|
1552
|
+
timed_out: (0, shell_command_projection_cjs_1.isSpawnTimeout)(listed),
|
|
1553
|
+
});
|
|
1554
|
+
continue;
|
|
1555
|
+
}
|
|
1556
|
+
const indexed = new Map();
|
|
1557
|
+
let unparseable = null;
|
|
1558
|
+
for (const rec of listed.stdout.split('\0').filter(Boolean)) {
|
|
1559
|
+
const m = /^(\S) (\d{6}) ([0-9a-f]+) ([0-3])\t([\s\S]+)$/.exec(rec);
|
|
1560
|
+
if (m === null) {
|
|
1561
|
+
unparseable = rec;
|
|
1562
|
+
break;
|
|
1563
|
+
}
|
|
1564
|
+
indexed.set(m[5], { tag: m[1], mode: m[2], sha: m[3], stage: m[4] });
|
|
1565
|
+
}
|
|
1566
|
+
if (unparseable !== null) {
|
|
1567
|
+
// A record this code cannot read is not a path it may remove.
|
|
1568
|
+
failures.push({ file: entry, error: `unparseable ls-files record: ${unparseable}`, timed_out: false });
|
|
1569
|
+
continue;
|
|
1570
|
+
}
|
|
1571
|
+
const tracked = new Set(indexed.keys());
|
|
1572
|
+
// Does the entry name THIS tracked path itself (the caller declared a
|
|
1573
|
+
// FILE removed) or a directory above it? Decided on RESOLVED paths, never
|
|
1574
|
+
// on the strings: `ls-files` prints cwd-relative paths, and a caller may
|
|
1575
|
+
// pass an absolute path, `./x`, a trailing slash, or run under `--cwd`,
|
|
1576
|
+
// any of which fails a string compare and would silently take the
|
|
1577
|
+
// directory polarity — a directly named gitlink then SKIPS instead of
|
|
1578
|
+
// refusing (found by the round's review, driven with an absolute path).
|
|
1579
|
+
const entryAbs = node_path_1.default.resolve(cwd, entry);
|
|
1580
|
+
const entryRel = node_path_1.default.relative(cwd, entryAbs).split(node_path_1.default.sep).join('/');
|
|
1581
|
+
// Canonical form: realpath of the longest EXISTING prefix, with the absent
|
|
1582
|
+
// tail re-appended. The declared path is usually absent (that is the
|
|
1583
|
+
// point), and `process.cwd()` returns the real path where the caller may
|
|
1584
|
+
// hold a symlinked spelling — macOS `/var` → `/private/var` is the live
|
|
1585
|
+
// instance (CI, this PR's own test) — so a resolve-only compare still
|
|
1586
|
+
// took the directory polarity there.
|
|
1587
|
+
const canon = (p) => {
|
|
1588
|
+
let cur = node_path_1.default.resolve(cwd, p);
|
|
1589
|
+
const tail = [];
|
|
1590
|
+
for (;;) {
|
|
1591
|
+
try {
|
|
1592
|
+
return node_path_1.default.join(node_fs_1.default.realpathSync.native(cur), ...tail);
|
|
1593
|
+
}
|
|
1594
|
+
catch { /* absent: climb */ }
|
|
1595
|
+
const parent = node_path_1.default.dirname(cur);
|
|
1596
|
+
if (parent === cur)
|
|
1597
|
+
return node_path_1.default.join(cur, ...tail);
|
|
1598
|
+
tail.unshift(node_path_1.default.basename(cur));
|
|
1599
|
+
cur = parent;
|
|
1600
|
+
}
|
|
1601
|
+
};
|
|
1602
|
+
const namesItself = (p) => p === entryRel || node_path_1.default.resolve(cwd, p) === entryAbs || canon(p) === canon(entry);
|
|
1603
|
+
const inHeadPaths = new Set();
|
|
1604
|
+
if (headExists) {
|
|
1605
|
+
const inHead = (0, shell_command_projection_cjs_1.execGit)(['ls-tree', '-r', '-z', '--name-only', 'HEAD', '--', lit(entry)], { cwd });
|
|
1606
|
+
if (inHead.exitCode !== 0) {
|
|
1607
|
+
failures.push({
|
|
1608
|
+
file: entry,
|
|
1609
|
+
error: inHead.stderr || inHead.stdout,
|
|
1610
|
+
timed_out: (0, shell_command_projection_cjs_1.isSpawnTimeout)(inHead),
|
|
1611
|
+
});
|
|
1612
|
+
continue;
|
|
1613
|
+
}
|
|
1614
|
+
for (const p of inHead.stdout.split('\0').filter(Boolean)) {
|
|
1615
|
+
tracked.add(p);
|
|
1616
|
+
inHeadPaths.add(p);
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
if (tracked.size === 0)
|
|
1620
|
+
continue;
|
|
1621
|
+
const entryState = lstatState(node_path_1.default.resolve(cwd, entry));
|
|
1622
|
+
if (entryState !== 'present' && entryState !== 'absent') {
|
|
1623
|
+
failures.push({ file: entry, error: `lstat ${entryState.code ?? ''}: ${entryState.message}`, timed_out: false });
|
|
1624
|
+
continue;
|
|
1625
|
+
}
|
|
1626
|
+
let entryIsDirectory = false;
|
|
1627
|
+
if (entryState === 'present') {
|
|
1628
|
+
try {
|
|
1629
|
+
entryIsDirectory = node_fs_1.default.lstatSync(node_path_1.default.resolve(cwd, entry)).isDirectory();
|
|
1630
|
+
}
|
|
1631
|
+
catch { /* raced away: treat as a present non-directory below */ }
|
|
1632
|
+
}
|
|
1633
|
+
if (entryState === 'present' && !entryIsDirectory) {
|
|
1634
|
+
// A present non-directory entry (a file, or ANY symlink — a link to a
|
|
1635
|
+
// directory is still one tracked path) contradicts the declaration.
|
|
1636
|
+
failures.push({
|
|
1637
|
+
file: entry,
|
|
1638
|
+
error: `declared in --files-removed but still present on disk: ${entry}`,
|
|
1639
|
+
timed_out: false,
|
|
1640
|
+
});
|
|
1641
|
+
continue;
|
|
1642
|
+
}
|
|
1643
|
+
for (const trackedPath of tracked) {
|
|
1644
|
+
const indexEntry = indexed.get(trackedPath);
|
|
1645
|
+
let reason = indexEntry === undefined ? null : notARemoval(indexEntry);
|
|
1646
|
+
// Intent-to-add (`git add -N`) renders as a plain `H 100644 <empty
|
|
1647
|
+
// blob> 0` — the flag is not in the listing — yet nothing tracked exists
|
|
1648
|
+
// to remove, and a rollback via `--cacheinfo` cannot restore the flag.
|
|
1649
|
+
// It is the one state whose blob is the empty blob, whose path is not in
|
|
1650
|
+
// HEAD, and which `diff --cached` treats as absent from the index; an
|
|
1651
|
+
// ordinary staged empty file shows there as added. Three probes, on the
|
|
1652
|
+
// rare empty-blob path only.
|
|
1653
|
+
if (reason === null && indexEntry !== undefined && EMPTY_BLOBS.has(indexEntry.sha) && !inHeadPaths.has(trackedPath)) {
|
|
1654
|
+
const cached = (0, shell_command_projection_cjs_1.execGit)(['diff', '--cached', '--name-only', '-z', '--', lit(trackedPath)], { cwd });
|
|
1655
|
+
if (cached.exitCode === 0 && cached.stdout.split('\0').filter(Boolean).length === 0)
|
|
1656
|
+
reason = 'an intent-to-add entry (git add -N), not tracked content';
|
|
1657
|
+
}
|
|
1658
|
+
if (reason !== null) {
|
|
1659
|
+
if (namesItself(trackedPath)) {
|
|
1660
|
+
failures.push({
|
|
1661
|
+
file: entry,
|
|
1662
|
+
error: `declared in --files-removed but is ${reason}: ${trackedPath}`,
|
|
1663
|
+
timed_out: false,
|
|
1664
|
+
});
|
|
1665
|
+
}
|
|
1666
|
+
continue;
|
|
1667
|
+
}
|
|
1668
|
+
const state = lstatState(node_path_1.default.resolve(cwd, trackedPath));
|
|
1669
|
+
if (state === 'present')
|
|
1670
|
+
continue;
|
|
1671
|
+
if (state !== 'absent') {
|
|
1672
|
+
failures.push({ file: trackedPath, error: `lstat ${state.code ?? ''}: ${state.message}`, timed_out: false });
|
|
1673
|
+
continue;
|
|
1674
|
+
}
|
|
1675
|
+
// A HEAD-only path (the caller already `git rm`'d it) has no index entry
|
|
1676
|
+
// to record or restore; the `rm` below is then a no-op.
|
|
1677
|
+
// READ the entry before the mutation, RECORD it only after the mutation
|
|
1678
|
+
// SUCCEEDS. The read must precede (the rm is what destroys the mode/blob
|
|
1679
|
+
// the restore needs); the record must not, because `removedEntries` is
|
|
1680
|
+
// the set this call claims to have staged. Recording ahead of the rm made
|
|
1681
|
+
// a FAILED rm — a stale `index.lock` is the driven case — contribute an
|
|
1682
|
+
// entry the rollback then reported as "still staged in the index" when
|
|
1683
|
+
// nothing had been staged at all: a false disclosure, the mirror of the
|
|
1684
|
+
// silent one the disclosure was added to fix.
|
|
1685
|
+
const recordable = indexEntry !== undefined
|
|
1686
|
+
? { path: trackedPath, mode: indexEntry.mode, sha: indexEntry.sha }
|
|
1687
|
+
: null;
|
|
1688
|
+
// `--ignore-unmatch` makes "no such index entry" a success, so a non-zero
|
|
1689
|
+
// exit is a real I/O failure — same reading as the default-mode branch.
|
|
1690
|
+
const rmResult = (0, shell_command_projection_cjs_1.execGit)(['rm', '--cached', '--ignore-unmatch', '--', lit(trackedPath)], { cwd });
|
|
1691
|
+
if (rmResult.exitCode === 0) {
|
|
1692
|
+
if (recordable !== null)
|
|
1693
|
+
removedEntries.push(recordable);
|
|
1694
|
+
// Re-check AFTER the index mutation. The absence test and the `rm` are
|
|
1695
|
+
// not atomic, and the scoped `git commit -- <paths>` below reads the
|
|
1696
|
+
// WORKTREE, so a path recreated in between would be committed as its
|
|
1697
|
+
// new content under a message that declared it removed. A reappearance
|
|
1698
|
+
// is a contradiction like any other: staging failure, and the rollback
|
|
1699
|
+
// restores the recorded entry. Narrows the window; does not close it.
|
|
1700
|
+
if (lstatState(node_path_1.default.resolve(cwd, trackedPath)) !== 'absent') {
|
|
1701
|
+
failures.push({
|
|
1702
|
+
file: trackedPath,
|
|
1703
|
+
error: `declared in --files-removed but reappeared on disk: ${trackedPath}`,
|
|
1704
|
+
timed_out: false,
|
|
1705
|
+
});
|
|
1706
|
+
continue;
|
|
1707
|
+
}
|
|
1708
|
+
// Unborn HEAD: nothing to delete FROM, so the path is unstaged only and
|
|
1709
|
+
// never joins the pathspec; its rollback is the recorded entry above.
|
|
1710
|
+
if (headExists)
|
|
1711
|
+
removedPathspec.push(trackedPath);
|
|
1712
|
+
}
|
|
1713
|
+
else {
|
|
1714
|
+
// A NON-ZERO rm is NOT proof the index is untouched. `execGit` collapses
|
|
1715
|
+
// a spawn timeout to a non-zero exit, and a killed `git rm` can already
|
|
1716
|
+
// have written the index — so keying the record on the exit code alone
|
|
1717
|
+
// drops a real mutation on the timeout path (driven: a post-index-change
|
|
1718
|
+
// hook that outlives the timeout leaves `D <path>` staged and reported
|
|
1719
|
+
// nowhere). The exit code answers "did the command succeed", never "did
|
|
1720
|
+
// the index change". ASK THE INDEX instead — three honest arms, and no
|
|
1721
|
+
// arm asserts a state it did not observe.
|
|
1722
|
+
// THE ORIGINAL FAILURE IS PUSHED FIRST. `failures[0]` sets the result's
|
|
1723
|
+
// `reason`, `file`, `error` and timeout classification, so appending the
|
|
1724
|
+
// probe's diagnostic ahead of it renamed the cause: a timed-out rm was
|
|
1725
|
+
// reported as a permission error and lost its `timed_out: true`.
|
|
1726
|
+
failures.push({
|
|
1727
|
+
file: trackedPath,
|
|
1728
|
+
error: rmResult.stderr || rmResult.stdout,
|
|
1729
|
+
timed_out: (0, shell_command_projection_cjs_1.isSpawnTimeout)(rmResult),
|
|
1730
|
+
});
|
|
1731
|
+
if (recordable !== null) {
|
|
1732
|
+
const after = (0, shell_command_projection_cjs_1.execGit)(['ls-files', '-s', '-z', '--', lit(trackedPath)], { cwd });
|
|
1733
|
+
if (after.exitCode !== 0) {
|
|
1734
|
+
// Could not determine. Say so; never silently assume either way.
|
|
1735
|
+
failures.push({
|
|
1736
|
+
file: trackedPath,
|
|
1737
|
+
error: `removal failed and the index state for this path could NOT be determined: ${after.stderr || after.stdout}`,
|
|
1738
|
+
timed_out: (0, shell_command_projection_cjs_1.isSpawnTimeout)(after),
|
|
1739
|
+
});
|
|
1740
|
+
}
|
|
1741
|
+
else if (after.stdout.replace(/\0/g, '').trim() === '') {
|
|
1742
|
+
// The entry is gone: the rm mutated the index before it failed, so
|
|
1743
|
+
// this call owns the removal and must restore/disclose it.
|
|
1744
|
+
removedEntries.push(recordable);
|
|
1745
|
+
}
|
|
1746
|
+
// else: the entry is still there — nothing was staged, nothing to undo.
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
return { removedEntries, removedPathspec, failures };
|
|
1752
|
+
}
|
|
1753
|
+
function cmdCommit(cwd, message, files, raw, amend, noVerify, filesRemoved) {
|
|
1441
1754
|
if (!message && !amend) {
|
|
1442
1755
|
error('commit message required');
|
|
1443
1756
|
}
|
|
@@ -1473,6 +1786,10 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
|
|
|
1473
1786
|
const branchingStrategy = config['branching_strategy'];
|
|
1474
1787
|
if (branchingStrategy && branchingStrategy !== 'none') {
|
|
1475
1788
|
let branchName = null;
|
|
1789
|
+
// #4055: the phase directory (cwd-relative POSIX path from
|
|
1790
|
+
// findPhaseInternal) captured while resolving the phase identity — the
|
|
1791
|
+
// state-3 guard below needs it for the committed-history check.
|
|
1792
|
+
let phaseDirRelative = null;
|
|
1476
1793
|
if (branchingStrategy === 'phase') {
|
|
1477
1794
|
// Determine which phase we're committing for from the file paths.
|
|
1478
1795
|
// #2539: the extraction is anchored to the directory SEGMENT immediately
|
|
@@ -1494,9 +1811,16 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
|
|
|
1494
1811
|
if (phaseNum && !isSentinelPhaseId(phaseNum)) {
|
|
1495
1812
|
const phaseInfo = findPhaseInternal(cwd, phaseNum);
|
|
1496
1813
|
if (phaseInfo) {
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1814
|
+
// #4126: shared with init.cts's cmdInitExecutePhase branch_name field
|
|
1815
|
+
// via the one canonical renderer (src/phase-id.cts) so an undeliverable
|
|
1816
|
+
// phase_slug degrades identically at both call sites instead of each
|
|
1817
|
+
// independently substituting the literal word 'phase'.
|
|
1818
|
+
branchName = renderPhaseBranchName(config['phase_branch_template'], phaseInfo['phase_number'], phaseInfo['phase_slug']);
|
|
1819
|
+
// #4055: findPhaseInternal already returns the directory as a
|
|
1820
|
+
// cwd-relative POSIX path.
|
|
1821
|
+
const dir = phaseInfo['directory'];
|
|
1822
|
+
if (typeof dir === 'string' && dir !== '')
|
|
1823
|
+
phaseDirRelative = dir;
|
|
1500
1824
|
}
|
|
1501
1825
|
}
|
|
1502
1826
|
}
|
|
@@ -1521,6 +1845,14 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
|
|
|
1521
1845
|
}
|
|
1522
1846
|
}
|
|
1523
1847
|
if (branchName) {
|
|
1848
|
+
// #4055: state-3 discriminator for the create arm. `rev-parse --verify`
|
|
1849
|
+
// alone cannot distinguish "branch never existed" (create is the #1278
|
|
1850
|
+
// intent) from "branch existed, was merged, then deleted" (the phase is
|
|
1851
|
+
// over — recreating it hijacks the close-out commit onto a resurrected
|
|
1852
|
+
// ref, the #3079 bug #3363 reopened). Both extra conditions come from
|
|
1853
|
+
// the confirmed issue: the create arm may fire only for a phase whose
|
|
1854
|
+
// directory has NO committed history on the current line (a genuinely
|
|
1855
|
+
// new phase) while the caller sits on the resolved base branch.
|
|
1524
1856
|
const currentBranch = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd });
|
|
1525
1857
|
if (currentBranch.exitCode === 0 && currentBranch.stdout.trim() !== branchName) {
|
|
1526
1858
|
// #2539/#3079/#3207: two cases the prior (#3079) code collapsed into one.
|
|
@@ -1535,18 +1867,63 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
|
|
|
1535
1867
|
// EXISTING branch is never switched to (the else arm logs + commits in
|
|
1536
1868
|
// place). The fresh create is logged so the first phase-scoped commit is
|
|
1537
1869
|
// not silent about where the work is landing (#3207 AC3).
|
|
1870
|
+
// #4055: "brand-new" is now VERIFIED, not assumed — see the state-3
|
|
1871
|
+
// guard between the verify and the create below.
|
|
1538
1872
|
const verify = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '--verify', `refs/heads/${branchName}`], { cwd });
|
|
1539
1873
|
if (verify.exitCode !== 0) {
|
|
1540
|
-
// Branch does not exist —
|
|
1541
|
-
//
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1874
|
+
// Branch does not exist — but absence alone cannot distinguish a
|
|
1875
|
+
// genuinely new phase from a merged-and-deleted one (#4055).
|
|
1876
|
+
let createBlockReason = null;
|
|
1877
|
+
if (branchingStrategy === 'phase' && phaseDirRelative) {
|
|
1878
|
+
// #4055 residual: searchPhaseInDir's #2237 fail-safe can return an
|
|
1879
|
+
// empty `directory` for ambiguous phase names (leaving
|
|
1880
|
+
// phaseDirRelative null) — there the history half is skipped and
|
|
1881
|
+
// only the base check below guards; shallow clones can also show
|
|
1882
|
+
// an empty probe for old merged phases (depth-sensitive).
|
|
1883
|
+
const history = (0, shell_command_projection_cjs_1.execGit)(['log', 'HEAD', '--oneline', '--', phaseDirRelative], { cwd });
|
|
1884
|
+
if (history.exitCode === 0 && history.stdout.trim() !== '') {
|
|
1885
|
+
createBlockReason =
|
|
1886
|
+
'its phase directory already has committed history (the phase is resolved)';
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
if (!createBlockReason) {
|
|
1890
|
+
// The base half of the guard applies to BOTH strategies (it does
|
|
1891
|
+
// not need a directory): a phase/milestone branch is created only
|
|
1892
|
+
// from the resolved base branch. NOTE the milestone arm keeps its
|
|
1893
|
+
// existence-only guard for the HISTORY half — a merged-and-deleted
|
|
1894
|
+
// milestone branch remains resurrectable by an on-base caller
|
|
1895
|
+
// until a milestone-directory derivation exists here (#4055
|
|
1896
|
+
// follow-up candidate).
|
|
1897
|
+
/* eslint-disable @typescript-eslint/no-require-imports */
|
|
1898
|
+
const gitBaseBranch = require('./git-base-branch.cjs');
|
|
1899
|
+
/* eslint-enable @typescript-eslint/no-require-imports */
|
|
1900
|
+
const resolvedBase = gitBaseBranch.resolveBaseBranch(cwd);
|
|
1901
|
+
if (resolvedBase && resolvedBase !== currentBranch.stdout.trim()) {
|
|
1902
|
+
createBlockReason =
|
|
1903
|
+
`the current branch "${currentBranch.stdout.trim()}" is not the ` +
|
|
1904
|
+
`resolved base branch "${resolvedBase}"`;
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
if (createBlockReason === null) {
|
|
1908
|
+
// State 1 confirmed: brand-new phase, first phase-scoped commit
|
|
1909
|
+
// from the base branch. CREATE AND SWITCH (the #1278 first-commit
|
|
1910
|
+
// case). checkout -b cannot resurrect anything: the branch was
|
|
1911
|
+
// just verified absent, so it is created fresh at HEAD.
|
|
1912
|
+
const create = (0, shell_command_projection_cjs_1.execGit)(['checkout', '-b', branchName], { cwd });
|
|
1913
|
+
if (create.exitCode === 0) {
|
|
1914
|
+
process.stderr.write(`${branchingStrategy} branch "${branchName}" created; switched to it for this commit.\n`);
|
|
1915
|
+
}
|
|
1916
|
+
else {
|
|
1917
|
+
process.stderr.write(`Warning: could not create ${branchingStrategy} branch "${branchName}" ` +
|
|
1918
|
+
`(${create.stderr.trim()}); committing on the current branch "${currentBranch.stdout.trim()}".\n`);
|
|
1919
|
+
}
|
|
1546
1920
|
}
|
|
1547
1921
|
else {
|
|
1548
|
-
|
|
1549
|
-
|
|
1922
|
+
// State 3 (or a non-base caller): the phase is resolved — commit
|
|
1923
|
+
// in place, disclosed (#2539 AC2), never recreate the branch.
|
|
1924
|
+
process.stderr.write(`Warning: resolved ${branchingStrategy} branch "${branchName}" is absent and ` +
|
|
1925
|
+
`will not be recreated (${createBlockReason}); committing on the current ` +
|
|
1926
|
+
`branch "${currentBranch.stdout.trim()}" instead of recreating it.\n`);
|
|
1550
1927
|
}
|
|
1551
1928
|
}
|
|
1552
1929
|
else {
|
|
@@ -1558,8 +1935,12 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
|
|
|
1558
1935
|
}
|
|
1559
1936
|
}
|
|
1560
1937
|
// Stage files
|
|
1561
|
-
|
|
1562
|
-
|
|
1938
|
+
// #4208: `--files-removed` is a declared scope in its own right — a caller
|
|
1939
|
+
// that names only removals must not fall through to the unscoped
|
|
1940
|
+
// `.planning/` sweep, which would commit everything under it.
|
|
1941
|
+
const removedDeclared = filesRemoved ?? [];
|
|
1942
|
+
const explicitFiles = (files && files.length > 0) || removedDeclared.length > 0;
|
|
1943
|
+
const filesToStage = explicitFiles ? (files ?? []) : ['.planning/'];
|
|
1563
1944
|
const stagedPaths = [];
|
|
1564
1945
|
// #2608: a `git add` that fails must abort the commit, not be skipped.
|
|
1565
1946
|
// #2523 stopped a failed path entering the commit pathspec, but skipping it
|
|
@@ -1569,11 +1950,27 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
|
|
|
1569
1950
|
// a linked worktree, timeout) was discarded and the operator saw a downstream
|
|
1570
1951
|
// pathspec error pointing at an innocent file.
|
|
1571
1952
|
const stagingFailures = [];
|
|
1953
|
+
// #4454: explicit --files paths skipped because they were missing from disk
|
|
1954
|
+
// (the #2014 guard below). Tracked so the caller can tell a partial commit
|
|
1955
|
+
// from a complete one instead of an unqualified `committed: true`.
|
|
1956
|
+
const skippedFiles = [];
|
|
1572
1957
|
// Paths already in the index BEFORE this call. On a staging failure the
|
|
1573
1958
|
// rollback below unstages only what THIS call added — unstaging a path the
|
|
1574
1959
|
// caller had staged themselves would destroy their work.
|
|
1575
|
-
|
|
1576
|
-
|
|
1960
|
+
// `-z`: without it `core.quotePath` renders a non-ASCII name as
|
|
1961
|
+
// `"caf\303\251.md"`, which never equals the raw path in `stagedPaths`, so
|
|
1962
|
+
// the rollback below would treat a caller-pre-staged `café.md` as this
|
|
1963
|
+
// call's own and unstage it (#4208 review, driven).
|
|
1964
|
+
// `--relative`: `diff --cached` prints REPO-relative paths whatever the cwd,
|
|
1965
|
+
// while `stagedPaths` holds the caller's own cwd-relative names. In a project
|
|
1966
|
+
// nested inside its repo (`<repo>/sub/.planning/...`) the two name spaces
|
|
1967
|
+
// never intersect, so `preStaged` matched NOTHING and the rollback unstaged
|
|
1968
|
+
// every path including the caller's own pre-staged work. Driven on a nested
|
|
1969
|
+
// fixture: a caller-staged deletion vanished from `diff --cached` after an
|
|
1970
|
+
// unrelated declaration failed. Pre-existing -- it governs the `--files` side
|
|
1971
|
+
// too -- and a no-op when the project IS the repo root.
|
|
1972
|
+
const preStaged = new Set((0, shell_command_projection_cjs_1.execGit)(['diff', '--cached', '--name-only', '-z', '--relative'], { cwd })
|
|
1973
|
+
.stdout.split('\0').filter(Boolean));
|
|
1577
1974
|
for (const file of filesToStage) {
|
|
1578
1975
|
const fullPath = node_path_1.default.resolve(cwd, file);
|
|
1579
1976
|
if (!node_fs_1.default.existsSync(fullPath)) {
|
|
@@ -1581,6 +1978,9 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
|
|
|
1581
1978
|
// Caller passed an explicit --files list: missing files are skipped.
|
|
1582
1979
|
// Staging a deletion here would silently remove tracked planning files
|
|
1583
1980
|
// (e.g. STATE.md, ROADMAP.md) when they are temporarily absent (#2014).
|
|
1981
|
+
// #4454: record what was skipped so the caller can tell a partial
|
|
1982
|
+
// commit from a complete one, instead of an unqualified success.
|
|
1983
|
+
skippedFiles.push(file);
|
|
1584
1984
|
continue;
|
|
1585
1985
|
}
|
|
1586
1986
|
// Default mode (staging all of .planning/): stage the deletion so
|
|
@@ -1618,6 +2018,75 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
|
|
|
1618
2018
|
}
|
|
1619
2019
|
}
|
|
1620
2020
|
}
|
|
2021
|
+
// #4208: caller-declared removals -- see stageDeclaredRemovals.
|
|
2022
|
+
const declaredRemovals = stageDeclaredRemovals(cwd, removedDeclared);
|
|
2023
|
+
const removedEntries = declaredRemovals.removedEntries;
|
|
2024
|
+
stagingFailures.push(...declaredRemovals.failures);
|
|
2025
|
+
stagedPaths.push(...declaredRemovals.removedPathspec);
|
|
2026
|
+
// A REMOVAL'S PATH IS A PATH DOWNSTREAM TOO. Literalising the staging alone
|
|
2027
|
+
// does not protect the COMMIT's own pathspec: with a tracked file literally
|
|
2028
|
+
// named `.planning/*.md` declared removed beside a MODIFIED `peer.md`, the
|
|
2029
|
+
// `git commit -- <paths>` below globs and commits `M peer.md` the caller
|
|
2030
|
+
// never declared — the sweep this flag exists to remove, arriving one step
|
|
2031
|
+
// later. Driven. Only the removal-derived entries are literalised: `--files`
|
|
2032
|
+
// entries keep whatever pathspec behaviour they have today, which is not this
|
|
2033
|
+
// change's to alter.
|
|
2034
|
+
const removalPathspecs = new Set(declaredRemovals.removedPathspec);
|
|
2035
|
+
const asPathspec = (p) => (removalPathspecs.has(p) ? `:(literal)${p}` : p);
|
|
2036
|
+
const restoreRemovedEntries = () => {
|
|
2037
|
+
if (removedEntries.length === 0)
|
|
2038
|
+
return 'restored';
|
|
2039
|
+
(0, shell_command_projection_cjs_1.execGit)(['update-index', '--add', ...removedEntries.flatMap(e => ['--cacheinfo', `${e.mode},${e.sha},${e.path}`])], { cwd });
|
|
2040
|
+
// VERIFY BY READING THE INDEX BACK, never by the exit code. `execGit`
|
|
2041
|
+
// collapses a spawn timeout to a non-zero exit, and a killed `update-index`
|
|
2042
|
+
// can already have written the index — so an exit code answers "did the
|
|
2043
|
+
// command succeed", never "is the entry back". Driven: a post-index-change
|
|
2044
|
+
// hook outliving the timeout made the restore report failure over an index
|
|
2045
|
+
// it had in fact restored, publishing a disclosure that was simply false.
|
|
2046
|
+
//
|
|
2047
|
+
// `-z` IS LOAD-BEARING, and its absence is the #2014-era defect this PR
|
|
2048
|
+
// already fixed once for `preStaged`: without it `core.quotePath` renders a
|
|
2049
|
+
// non-ASCII name as `"caf\303\251.md"`, which never equals the raw path, so
|
|
2050
|
+
// an exactly-restored `café.md` (and any name carrying a tab or a newline)
|
|
2051
|
+
// read as NOT restored. Driven on all three shapes.
|
|
2052
|
+
const back = (0, shell_command_projection_cjs_1.execGit)(['ls-files', '-s', '-z', '--', ...removedEntries.map(e => `:(literal)${e.path}`)], { cwd });
|
|
2053
|
+
if (back.exitCode !== 0)
|
|
2054
|
+
return 'unverified'; // no observation — never an assertion of failure
|
|
2055
|
+
// COMPARE THE WHOLE ENTRY, not just the path. `--cacheinfo` restores mode,
|
|
2056
|
+
// blob and stage; a path present at a DIFFERENT mode or blob is not the
|
|
2057
|
+
// entry this call removed. Driven: a hook that rewrote the restored entry
|
|
2058
|
+
// 100644 -> 100755 was reported as restored by a path-only test.
|
|
2059
|
+
const present = new Map();
|
|
2060
|
+
for (const rec of back.stdout.split('\0')) {
|
|
2061
|
+
if (rec === '')
|
|
2062
|
+
continue;
|
|
2063
|
+
const tab = rec.indexOf('\t');
|
|
2064
|
+
if (tab === -1)
|
|
2065
|
+
continue;
|
|
2066
|
+
present.set(rec.slice(tab + 1), rec.slice(0, tab));
|
|
2067
|
+
}
|
|
2068
|
+
const ok = removedEntries.every(e => present.get(e.path) === `${e.mode} ${e.sha} 0`);
|
|
2069
|
+
return ok ? 'restored' : 'not-restored';
|
|
2070
|
+
};
|
|
2071
|
+
// The no-change exits' shared arm: restore, and if the restore failed, say so
|
|
2072
|
+
// instead of claiming nothing changed. `staging_failed` is the honest reason —
|
|
2073
|
+
// the index carries a mutation this call made and could not undo.
|
|
2074
|
+
const removalsLeftStaged = (verdict) => ({
|
|
2075
|
+
committed: false,
|
|
2076
|
+
hash: null,
|
|
2077
|
+
reason: 'staging_failed',
|
|
2078
|
+
file: removedEntries[0]?.path ?? null,
|
|
2079
|
+
error: verdict === 'not-restored'
|
|
2080
|
+
? `declared removal(s) staged but could not be restored after the commit recorded nothing: ${removedEntries.map(e => e.path).join(', ')}`
|
|
2081
|
+
: `declared removal(s) staged and the restore could NOT be VERIFIED after the commit recorded nothing: ${removedEntries.map(e => e.path).join(', ')}`,
|
|
2082
|
+
failures: removedEntries.map(e => ({
|
|
2083
|
+
file: e.path,
|
|
2084
|
+
error: verdict === 'not-restored'
|
|
2085
|
+
? 'update-index --cacheinfo restore failed'
|
|
2086
|
+
: 'update-index --cacheinfo restore could not be verified — the index was not readable',
|
|
2087
|
+
timed_out: false,
|
|
2088
|
+
})),
|
|
2089
|
+
});
|
|
1621
2090
|
// #2608: fail closed before `git commit` runs. Checked ahead of the
|
|
1622
2091
|
// nothing_to_commit branch below so a run where EVERY path failed to stage
|
|
1623
2092
|
// reports the staging cause rather than "nothing to commit", and ahead of the
|
|
@@ -1632,10 +2101,37 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
|
|
|
1632
2101
|
// best-effort: if the index is unwritable — the very failure being reported
|
|
1633
2102
|
// — the reset cannot succeed either, and the staging error is still what
|
|
1634
2103
|
// gets returned.
|
|
1635
|
-
const
|
|
2104
|
+
const removedPaths = new Set(removedEntries.map(e => e.path));
|
|
2105
|
+
const toUnstage = stagedPaths.filter(p => !preStaged.has(p) && !removedPaths.has(p));
|
|
1636
2106
|
if (toUnstage.length > 0) {
|
|
1637
|
-
|
|
1638
|
-
|
|
2107
|
+
// `asPathspec` here too. This reset is the LAST place a removal-derived
|
|
2108
|
+
// name reaches git as a pathspec, and it is the most damaging: driven,
|
|
2109
|
+
// a wildcard-named entry that slipped into `toUnstage` globbed and
|
|
2110
|
+
// unstaged the CALLER'S OWN pre-staged deletion and modification, then
|
|
2111
|
+
// reported only the contradiction that triggered the rollback.
|
|
2112
|
+
(0, shell_command_projection_cjs_1.execGit)(['reset', '-q', '--', ...toUnstage.map(asPathspec)], { cwd });
|
|
2113
|
+
}
|
|
2114
|
+
// Removals are restored from the recorded entries, never via `reset`
|
|
2115
|
+
// (no HEAD to reset to on an unborn branch; not the pre-staged blob when
|
|
2116
|
+
// the caller had one) — and unconditionally, since a removal this call
|
|
2117
|
+
// performed is this call's to undo whether or not the path was pre-staged.
|
|
2118
|
+
// DISCLOSE a failed restore here too. The earlier reading -- that this exit
|
|
2119
|
+
// is already reporting a failure, so the restore's result adds nothing --
|
|
2120
|
+
// is wrong, and the counterexample is the ordinary one: the reported
|
|
2121
|
+
// failure is usually a DIFFERENT cause (a contradictory declaration, a
|
|
2122
|
+
// reappeared path), so a caller reading `failures` sees only that cause
|
|
2123
|
+
// and learns nothing about the removal still sitting in its index. Append
|
|
2124
|
+
// rather than replace: the original failure is still the reason.
|
|
2125
|
+
const restoreVerdict = restoreRemovedEntries();
|
|
2126
|
+
const failures = restoreVerdict === 'restored'
|
|
2127
|
+
? stagingFailures
|
|
2128
|
+
: [...stagingFailures, ...removedEntries.map(e => ({
|
|
2129
|
+
file: e.path,
|
|
2130
|
+
error: restoreVerdict === 'not-restored'
|
|
2131
|
+
? 'staged removal could NOT be restored during rollback — it is still staged in the index'
|
|
2132
|
+
: 'staged removal was rolled back but the result could NOT be VERIFIED — the index was not readable',
|
|
2133
|
+
timed_out: false,
|
|
2134
|
+
}))];
|
|
1639
2135
|
const first = stagingFailures[0];
|
|
1640
2136
|
const result = {
|
|
1641
2137
|
committed: false,
|
|
@@ -1643,7 +2139,7 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
|
|
|
1643
2139
|
reason: first.timed_out ? 'staging_timeout' : 'staging_failed',
|
|
1644
2140
|
file: first.file,
|
|
1645
2141
|
error: first.error,
|
|
1646
|
-
failures
|
|
2142
|
+
failures,
|
|
1647
2143
|
};
|
|
1648
2144
|
output(result, raw, 'failed');
|
|
1649
2145
|
return;
|
|
@@ -1655,12 +2151,252 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
|
|
|
1655
2151
|
// During a merge, git refuses partial commits — fall back to a bare commit.
|
|
1656
2152
|
// --amend is left without a pathspec: amending with -- <paths> is a different
|
|
1657
2153
|
// operation that rewrites the tip with only those paths.
|
|
1658
|
-
|
|
1659
|
-
|
|
2154
|
+
const mergeHeadProbe = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '-q', '--verify', 'MERGE_HEAD'], { cwd });
|
|
2155
|
+
const isMergeInProgress = mergeHeadProbe.exitCode === 0;
|
|
2156
|
+
// PROVENANCE FOR THIS WHOLE BLOCK: every behavioural claim below was DRIVEN
|
|
2157
|
+
// against git 2.54, not reasoned by analogy. Individual claims state what was
|
|
2158
|
+
// observed and omit the version; where a claim is version-SENSITIVE rather
|
|
2159
|
+
// than merely version-observed, it says so at the claim.
|
|
2160
|
+
//
|
|
2161
|
+
// #3776: git refuses a PARTIAL commit (`git commit -- <paths>`) while a merge
|
|
2162
|
+
// or a cherry-pick is in progress, so in those states the pathspec describes
|
|
2163
|
+
// nothing about what would actually land and the empty-diff decision below
|
|
2164
|
+
// must not be made from it. The three sequencer states do NOT agree:
|
|
2165
|
+
// MERGE_HEAD -> `fatal: cannot do a partial commit during a merge.`
|
|
2166
|
+
// CHERRY_PICK_HEAD -> `fatal: cannot do a partial commit during a cherry-pick.`
|
|
2167
|
+
// REVERT_HEAD -> permitted; behaves like an ordinary commit.
|
|
2168
|
+
// REVERT_HEAD is therefore deliberately absent: including it would suppress
|
|
2169
|
+
// this fix during a revert, reintroducing the very misreport it removes.
|
|
2170
|
+
// `canScope` below keeps its narrower merge-only test on purpose — widening it
|
|
2171
|
+
// would change pre-existing cherry-pick behaviour, which is outside this fix.
|
|
2172
|
+
// Only the scoped, non-amend call can return through the guard below, so the
|
|
2173
|
+
// cherry-pick probe and the guard's own probes are gated on that — an
|
|
2174
|
+
// unscoped commit or an --amend would otherwise pay for git invocations whose
|
|
2175
|
+
// answer it can never use. The MERGE_HEAD probe above predates this fix and
|
|
2176
|
+
// stays unconditional: `canScope` needs it on every path.
|
|
2177
|
+
// A non-zero exit from either sequencer probe means "not in that state" AND
|
|
2178
|
+
// "the probe never answered" — `execGit` surfaces a spawn timeout as
|
|
2179
|
+
// `exitCode: 1` (`_spawnResult`: `result.status ?? 1`), which is the exact
|
|
2180
|
+
// code `rev-parse --verify` returns for a ref that does not exist. Conflating
|
|
2181
|
+
// them is the one path in this fix that does NOT fail toward the old
|
|
2182
|
+
// behaviour: a timeout during a real merge would leave `partialCommitRefused`
|
|
2183
|
+
// false, the guard would decide `nothing_to_commit` from a pathspec git will
|
|
2184
|
+
// not honour, and the merge would be silently abandoned where it previously
|
|
2185
|
+
// reported a loud `commit_failed`. So an unanswered probe is treated as
|
|
2186
|
+
// "assume the partial commit would be refused" — the conservative reading,
|
|
2187
|
+
// which falls through to `git commit` and lets git speak for itself.
|
|
2188
|
+
//
|
|
2189
|
+
// This is deliberately routed into `partialCommitRefused` ONLY, never into
|
|
2190
|
+
// `isMergeInProgress`: that flag also feeds the pre-existing `canScope` below,
|
|
2191
|
+
// where a spurious timeout would convert a scoped commit into a bare one and
|
|
2192
|
+
// record the whole index instead of the named paths. Suppressing a misreport
|
|
2193
|
+
// must not be paid for by committing content the caller never named.
|
|
2194
|
+
const guardApplies = explicitFiles && !amend;
|
|
2195
|
+
const cherryPickProbe = guardApplies
|
|
2196
|
+
? (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '-q', '--verify', 'CHERRY_PICK_HEAD'], { cwd })
|
|
2197
|
+
: null;
|
|
2198
|
+
const partialCommitRefused = isMergeInProgress
|
|
2199
|
+
|| (0, shell_command_projection_cjs_1.isSpawnTimeout)(mergeHeadProbe)
|
|
2200
|
+
|| (cherryPickProbe !== null
|
|
2201
|
+
&& (cherryPickProbe.exitCode === 0 || (0, shell_command_projection_cjs_1.isSpawnTimeout)(cherryPickProbe)));
|
|
2202
|
+
// `stagedPaths` records paths whose `git add` exited 0 — that is "did
|
|
2203
|
+
// staging succeed", not "is there anything to commit". Staging an
|
|
2204
|
+
// already-committed, unmodified file succeeds while contributing no diff, so
|
|
2205
|
+
// `length === 0` is reachable only when EVERY named path was missing from
|
|
2206
|
+
// disk. For the ordinary empty-diff case control fell through to `git commit`,
|
|
2207
|
+
// and the only thing converting that back to `nothing_to_commit` was the
|
|
2208
|
+
// string match on git's output below — which a rejecting pre-commit hook
|
|
2209
|
+
// pre-empts, because git runs the hook before it decides there is nothing to
|
|
2210
|
+
// commit. The caller was then handed `commit_failed` carrying a gate message
|
|
2211
|
+
// about a commit that had nothing to gate. Ask git whether the named paths
|
|
2212
|
+
// actually differ instead. Three things about that probe are load-bearing:
|
|
2213
|
+
// - it compares the WORKING TREE to HEAD (`diff HEAD`), not the index
|
|
2214
|
+
// (`diff --cached`). `git commit -- <paths>` is a partial commit: it takes
|
|
2215
|
+
// the working-tree content of those paths and ignores what is staged. A
|
|
2216
|
+
// probe against the index therefore answers a different question than the
|
|
2217
|
+
// commit asks, and a working-tree write landing between the `git add`
|
|
2218
|
+
// above and this line — another process in a shared checkout — would make
|
|
2219
|
+
// the index say "empty" while the commit would still have recorded the new
|
|
2220
|
+
// content. Driven: `diff --cached` rc 0 and `diff HEAD` rc 1 on the same
|
|
2221
|
+
// path, with `git commit -- <path>` then committing it.
|
|
2222
|
+
// - the `length === 0` short-circuit keeps the all-missing-paths case exact.
|
|
2223
|
+
// Spreading an empty array yields a pathspec-less `diff`, which tests the
|
|
2224
|
+
// WHOLE tree — unrelated work elsewhere would then suppress the guard and
|
|
2225
|
+
// regress the skip-missing contract (#2014).
|
|
2226
|
+
// It is deliberately NOT gated on `partialCommitRefused`, and gating it
|
|
2227
|
+
// would be a REGRESSION rather than a hardening. With every named path
|
|
2228
|
+
// missing, `stagedPaths` is empty, so `canScope` is false and the
|
|
2229
|
+
// fall-through reaches a BARE `git commit` — which git PERMITS during a
|
|
2230
|
+
// merge, and which then CONCLUDES that merge: rc 0, a two-parent merge
|
|
2231
|
+
// commit recording the entire index, under a message naming a path that
|
|
2232
|
+
// does not exist, reported to the caller as `committed: true` (driven).
|
|
2233
|
+
// Today's answer writes nothing at all. That is the same trade the timeout
|
|
2234
|
+
// routing above already refuses — a misreport must not be paid for by
|
|
2235
|
+
// committing content the caller never named — which is why the sequencer
|
|
2236
|
+
// states gate the DIFF branch only. The behaviour is also PRE-EXISTING and
|
|
2237
|
+
// unchanged by this fix: before it the identical short-circuit ran ABOVE
|
|
2238
|
+
// the MERGE_HEAD probe, so it never consulted the sequencer either. The
|
|
2239
|
+
// residual it leaves — a merge held open behind a `nothing_to_commit`
|
|
2240
|
+
// report — is offered as a separate issue with the other three, not folded
|
|
2241
|
+
// in here. Both sequencer shapes are pinned in
|
|
2242
|
+
// tests/commit-files-pathspec.test.cjs.
|
|
2243
|
+
// - `!partialCommitRefused`: see above — deciding "nothing to commit" from a
|
|
2244
|
+
// pathspec git will not honour would abandon an in-progress merge, so those
|
|
2245
|
+
// states keep their pre-existing behaviour untouched.
|
|
2246
|
+
// - the probe is pinned against user configuration that would make `git diff`
|
|
2247
|
+
// answer a DIFFERENT question than `git commit -- <paths>` asks. `git diff`
|
|
2248
|
+
// is porcelain and honours settings the commit does not, so without these
|
|
2249
|
+
// flags a caller's config decides whether the guard fires. Each vector
|
|
2250
|
+
// below was driven with the paired `git commit -- <path>` confirmed to
|
|
2251
|
+
// record the change the probe reported as absent:
|
|
2252
|
+
// `diff.ignoreSubmodules=all` -> a gitlink bump is invisible to the probe
|
|
2253
|
+
// `.gitmodules` `ignore = all` -> the same, and it needs NO local config:
|
|
2254
|
+
// it is checked in, so it arrives with a
|
|
2255
|
+
// clone
|
|
2256
|
+
// `diff=<driver>` + `textconv` -> two different blobs converge to one
|
|
2257
|
+
// text, so the probe sees no change at
|
|
2258
|
+
// all; no submodule involved
|
|
2259
|
+
// `--ignore-submodules=dirty` rather than `=none`, because `dirty` is what
|
|
2260
|
+
// a partial commit of a submodule path actually means: it records the
|
|
2261
|
+
// GITLINK, and the gitlink moves only when the submodule's HEAD does. Under
|
|
2262
|
+
// `=none` a merely dirty submodule WORKTREE reports a difference the commit
|
|
2263
|
+
// would not record, sending an empty call back to `git commit` — the #3776
|
|
2264
|
+
// misreport, re-entered from the other side. `dirty` still overrides both
|
|
2265
|
+
// `diff.ignoreSubmodules` and a checked-in `.gitmodules` `ignore`, so the
|
|
2266
|
+
// gitlink vectors above stay closed (driven: rc 1 under every one of them).
|
|
2267
|
+
// `--no-ext-diff` is deliberately absent: `--quiet` short-circuits ahead of
|
|
2268
|
+
// an external diff driver, so an external `diff.<driver>.command` cannot
|
|
2269
|
+
// invert the probe (driven: rc 1 with and without the flag).
|
|
2270
|
+
// Any other non-zero exit from the probe (a genuine git error, or an unborn
|
|
2271
|
+
// HEAD) leaves the guard shut and falls through to the commit — failing toward
|
|
2272
|
+
// today's path rather than manufacturing a no-op.
|
|
2273
|
+
// THE ONE STATE WHERE `git diff` AND `git commit -- <paths>` GENUINELY DISAGREE.
|
|
2274
|
+
// `--assume-unchanged` tells git to skip the worktree stat for a path, so
|
|
2275
|
+
// `git add` stages nothing and BOTH diff forms report no difference — while
|
|
2276
|
+
// `git commit -- <path>` reads the working tree directly and records it
|
|
2277
|
+
// (driven: probe rc 0, commit rc 0, new content in the tree). Left
|
|
2278
|
+
// to the diff probe alone the guard reports `nothing_to_commit` about content
|
|
2279
|
+
// the caller explicitly named in `--files` and git would have written. #3776
|
|
2280
|
+
// is a purely diagnostic bug — nothing is corrupted and no wrong commit is
|
|
2281
|
+
// made — so suppressing its misreport must not be paid for by dropping named
|
|
2282
|
+
// content. The same rule the timeout routing already follows one block up.
|
|
2283
|
+
//
|
|
2284
|
+
// `git ls-files -v` is the discriminator for the STATE: it tags an
|
|
2285
|
+
// assume-unchanged path with a LOWERCASE letter (`h`), where
|
|
2286
|
+
// `--skip-worktree` is an uppercase `S` and never reaches THIS branch:
|
|
2287
|
+
// `git add` exits 1 under it, so a present-but-modified skip-worktree path
|
|
2288
|
+
// fails closed as `staging_failed` above the guard. (An ABSENT one is skipped
|
|
2289
|
+
// before `git add` runs at all per #2014, and is answered by the
|
|
2290
|
+
// `stagedPaths.length === 0` arm above — correctly, and exactly as it was
|
|
2291
|
+
// pre-fix. Both shapes are pinned.)
|
|
2292
|
+
//
|
|
2293
|
+
// Then ASK GIT, rather than reconstructing its answer. `git commit --dry-run`
|
|
2294
|
+
// is the same decision the real commit makes, and `--no-verify` is what keeps
|
|
2295
|
+
// it a DECISION rather than an execution. git 2.54 already declines to run
|
|
2296
|
+
// `pre-commit` on a dry run (driven: a rejecting one neither fires nor writes
|
|
2297
|
+
// its marker), which is the property that matters here, because a firing
|
|
2298
|
+
// `pre-commit` is the whole of #3776 — but that is an observed behaviour of
|
|
2299
|
+
// one version, and the failure it would produce on a version that differs is
|
|
2300
|
+
// SILENT. A `pre-commit` that fires and rejects exits 1, the same code git
|
|
2301
|
+
// returns for `nothing to record`, so the closure below would read it as a
|
|
2302
|
+
// CONFIRMED empty answer, drop the content the caller named, and report
|
|
2303
|
+
// `nothing_to_commit` — #3776's exact shape, in #3776's exact configuration.
|
|
2304
|
+
// `--no-verify` forecloses that structurally instead of resting on the
|
|
2305
|
+
// version, and is behaviour-neutral where the version already agrees (driven:
|
|
2306
|
+
// rc 0 would-record / rc 1 nothing, identical with and without it). This is
|
|
2307
|
+
// VERSION-SENSITIVE reasoning, hence stated at the claim per the provenance
|
|
2308
|
+
// note above.
|
|
2309
|
+
//
|
|
2310
|
+
// It is still NOT hook-free in general, and `--no-verify` does not widen that
|
|
2311
|
+
// claim: `post-index-change` fires on this call with or without the flag
|
|
2312
|
+
// (driven both ways), so a repo using that hook sees TWO extra invocations
|
|
2313
|
+
// for the probe — git fires it twice per `commit --dry-run`, and twice again
|
|
2314
|
+
// for the real commit (driven: 2/2/2 across flagged probe, unflagged probe
|
|
2315
|
+
// and real commit). Stated rather than claimed away; the narrower
|
|
2316
|
+
// claim is the true one. `--porcelain` keeps the output to a couple
|
|
2317
|
+
// of machine-readable lines instead of a full status listing — the rc is
|
|
2318
|
+
// identical either way (driven: 0 would-record / 1 nothing), but the plain
|
|
2319
|
+
// form prints every untracked path, which on a large tree is output this
|
|
2320
|
+
// probe has no use for and `execGit` would have to buffer. rc 0 means the
|
|
2321
|
+
// commit would record something, so the guard must stand aside.
|
|
2322
|
+
//
|
|
2323
|
+
// Reconstructing it was tried and is WRONG in three measured ways, all of
|
|
2324
|
+
// them silent drops of named content. Comparing `git hash-object` against
|
|
2325
|
+
// `HEAD:<path>` misses a mode-only change (`chmod +x` leaves the blob
|
|
2326
|
+
// identical while `git commit -- <path>` records `100755`); it cannot hash a
|
|
2327
|
+
// submodule path at all (`fatal: Unable to hash sub`, while the commit
|
|
2328
|
+
// advances the gitlink); and the path it needs must be parsed out of
|
|
2329
|
+
// `ls-files` output, which `core.quotePath` renders as `"caf\303\251.md"`
|
|
2330
|
+
// by default, so the probe reads a filename that does not exist. Asking git
|
|
2331
|
+
// needs no path parsed and no case enumerated.
|
|
2332
|
+
//
|
|
2333
|
+
// Scoped to this branch on purpose. The diff probe above answers the ordinary
|
|
2334
|
+
// case cheaply and is pinned against the configuration vectors below; the
|
|
2335
|
+
// dry run is the heavier, exact answer, and it runs only when an
|
|
2336
|
+
// assume-unchanged path is actually present.
|
|
2337
|
+
//
|
|
2338
|
+
// The `ls-files` read is an OPTIMISATION, never a gate — so an unreadable one
|
|
2339
|
+
// must not decide anything. It exists only to keep the dry run off the hot
|
|
2340
|
+
// path when no assume-unchanged entry is present; when it cannot answer, the
|
|
2341
|
+
// dry run simply runs, because the dry run needs nothing from it. Both
|
|
2342
|
+
// failing-closed (drop the content) and failing-open (re-enter #3776) are
|
|
2343
|
+
// wrong answers to a question we can just ask directly.
|
|
2344
|
+
const assumeUnchangedWouldRecord = () => {
|
|
2345
|
+
const listed = (0, shell_command_projection_cjs_1.execGit)(['ls-files', '-v', '--', ...stagedPaths.map(asPathspec)], { cwd });
|
|
2346
|
+
// Only the TAG is read; the path is deliberately never parsed out — see the
|
|
2347
|
+
// `core.quotePath` note above, and the dry run below needs no path anyway.
|
|
2348
|
+
if (listed.exitCode === 0
|
|
2349
|
+
&& !listed.stdout.split('\n').some((line) => /^[a-z] /.test(line)))
|
|
2350
|
+
return false;
|
|
2351
|
+
const dryRun = (0, shell_command_projection_cjs_1.execGit)(['commit', '--dry-run', '--porcelain', '--no-verify', '-m', sanitizedMessage, '--', ...stagedPaths.map(asPathspec)], { cwd });
|
|
2352
|
+
// Only a CONFIRMED "nothing to record" closes the path: rc 1 from a git
|
|
2353
|
+
// that actually answered. This is the one probe in the guard whose rc 0
|
|
2354
|
+
// is the REASSURING answer, so it inverts the diff probe's safety: there
|
|
2355
|
+
// a timeout can only yield non-zero and reads as "not clean"; here
|
|
2356
|
+
// `execGit` collapses a spawn timeout (or any spawn error) to
|
|
2357
|
+
// `exitCode: 1` (`_spawnResult`: `result.status ?? 1`), byte-identical to
|
|
2358
|
+
// git's own "nothing to record" — and the guard then reports
|
|
2359
|
+
// `nothing_to_commit` about content it never asked git to write. Same
|
|
2360
|
+
// conflation the sequencer probes above defend against, same remedy: an
|
|
2361
|
+
// unanswered probe falls toward the commit, where git speaks for itself
|
|
2362
|
+
// (and a genuine error there is reported loudly, as it always was). rc 128
|
|
2363
|
+
// is likewise not an answer. Timeout kill of a dry run CAN leave a stale
|
|
2364
|
+
// `index.lock` behind (it refreshes the index); the real commit then
|
|
2365
|
+
// fails on it, loudly — never silently.
|
|
2366
|
+
if ((0, shell_command_projection_cjs_1.isSpawnTimeout)(dryRun) || dryRun.error !== null)
|
|
2367
|
+
return true;
|
|
2368
|
+
return dryRun.exitCode !== 1;
|
|
2369
|
+
};
|
|
2370
|
+
const nothingToCommit = guardApplies
|
|
2371
|
+
&& (stagedPaths.length === 0
|
|
2372
|
+
|| (!partialCommitRefused
|
|
2373
|
+
&& (0, shell_command_projection_cjs_1.execGit)(['diff', '--quiet', '--ignore-submodules=dirty', '--no-textconv', 'HEAD', '--', ...stagedPaths.map(asPathspec)], { cwd }).exitCode === 0
|
|
2374
|
+
&& !assumeUnchangedWouldRecord()));
|
|
2375
|
+
if (nothingToCommit) {
|
|
2376
|
+
// Nothing is being recorded, so any removal this call staged has no commit
|
|
2377
|
+
// to land in. Put it back before reporting no state change. Reachable on
|
|
2378
|
+
// two shapes, and keying on either one alone leaves the other broken:
|
|
2379
|
+
// an unborn HEAD (a removal never joins `stagedPaths`, so the pathspec is
|
|
2380
|
+
// empty), and a HEAD that simply does not carry the removed path -- an
|
|
2381
|
+
// index-only entry the caller `git add`ed but never committed, where the
|
|
2382
|
+
// `diff HEAD` probe reads clean because the path is absent on both sides.
|
|
2383
|
+
const rv = restoreRemovedEntries();
|
|
2384
|
+
if (rv !== 'restored') {
|
|
2385
|
+
output(removalsLeftStaged(rv), raw, 'failed');
|
|
2386
|
+
return;
|
|
2387
|
+
}
|
|
2388
|
+
// #4454: an explicit --files list where every named path was missing
|
|
2389
|
+
// reaches this branch via `stagedPaths.length === 0` above — surface
|
|
2390
|
+
// which path(s) were the reason, same as the success result below.
|
|
2391
|
+
const result = {
|
|
2392
|
+
committed: false,
|
|
2393
|
+
hash: null,
|
|
2394
|
+
reason: 'nothing_to_commit',
|
|
2395
|
+
...(skippedFiles.length > 0 ? { skipped_files: skippedFiles } : {}),
|
|
2396
|
+
};
|
|
1660
2397
|
output(result, raw, 'nothing');
|
|
1661
2398
|
return;
|
|
1662
2399
|
}
|
|
1663
|
-
const isMergeInProgress = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '-q', '--verify', 'MERGE_HEAD'], { cwd }).exitCode === 0;
|
|
1664
2400
|
const canScope = explicitFiles && stagedPaths.length > 0 && !amend
|
|
1665
2401
|
&& !isMergeInProgress;
|
|
1666
2402
|
const commitArgs = amend
|
|
@@ -1669,12 +2405,47 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
|
|
|
1669
2405
|
if (noVerify)
|
|
1670
2406
|
commitArgs.push('--no-verify');
|
|
1671
2407
|
if (canScope) {
|
|
1672
|
-
commitArgs.push('--', ...stagedPaths);
|
|
1673
|
-
}
|
|
2408
|
+
commitArgs.push('--', ...stagedPaths.map(asPathspec));
|
|
2409
|
+
}
|
|
2410
|
+
// #3859 follow-up: on git 2.39.5 (confirmed on the CI Linux bench image,
|
|
2411
|
+
// ghcr.io/open-gsd/gsd-tester-linux:v1.8.0-node24; NOT reproducible on git
|
|
2412
|
+
// 2.50.1) `git commit` itself — not just `git diff` — consults
|
|
2413
|
+
// `diff.ignoreSubmodules` when deciding whether there is anything to
|
|
2414
|
+
// record. With a local `diff.ignoreSubmodules=all` and a submodule gitlink
|
|
2415
|
+
// genuinely bumped, that git version silently REFUSES the commit (prints a
|
|
2416
|
+
// `git status`-style "Changes to be committed" dump and exits 1, having
|
|
2417
|
+
// written nothing) even though the diff probe above (already pinned with
|
|
2418
|
+
// its own `--ignore-submodules=dirty`) correctly reported the change as
|
|
2419
|
+
// present. The result was misclassified as generic `commit_failed` because
|
|
2420
|
+
// git's refusal text does not contain "nothing to commit".
|
|
2421
|
+
// Originally scoped to `canScope` on the assumption that only a
|
|
2422
|
+
// PATHSPEC-LIMITED `git commit -- <paths>` exercises this git internal
|
|
2423
|
+
// path. That assumption was wrong: reproduced directly against the pinned
|
|
2424
|
+
// v1.8.0-node24 tester image, a bare WHOLE-INDEX `git commit -m ...` (no
|
|
2425
|
+
// pathspec at all) is refused identically when the only staged change is a
|
|
2426
|
+
// submodule gitlink and `diff.ignoreSubmodules=all` — git's "nothing to
|
|
2427
|
+
// commit" check is a real diff (HEAD vs. index) honouring
|
|
2428
|
+
// `diff.ignoreSubmodules` regardless of whether a pathspec narrows it.
|
|
2429
|
+
// `--amend` is the one shape confirmed NOT to hit this: it always
|
|
2430
|
+
// recreates the commit from the current index and never runs the
|
|
2431
|
+
// empty-diff refusal a plain `git commit` does, override or not. The
|
|
2432
|
+
// override is therefore applied unconditionally here (not gated on
|
|
2433
|
+
// `canScope`) — it is a documented no-op everywhere it is not needed
|
|
2434
|
+
// (dry-run, git 2.50.1, and `--amend` already behave this way with or
|
|
2435
|
+
// without it; see `#3859 follow-up (canScope gap)` regression tests).
|
|
2436
|
+
// The override rides in via `GIT_CONFIG_*` env vars rather than a `-c`
|
|
2437
|
+
// argv flag so `commitArgs[0]` stays `'commit'` — several #3859 regression
|
|
2438
|
+
// tests assert on the raw argv captured at the `execGit` seam (e.g.
|
|
2439
|
+
// `gitCalls.some((a) => a[0] === 'commit')`), and a leading `-c` would shift
|
|
2440
|
+
// every element and break that pinning. Same override the probe already
|
|
2441
|
+
// carries, so the two can never disagree again.
|
|
2442
|
+
const commitEnv = {
|
|
2443
|
+
GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'diff.ignoreSubmodules', GIT_CONFIG_VALUE_0: 'dirty',
|
|
2444
|
+
};
|
|
1674
2445
|
// #3886: `git commit` runs pre-commit hooks (husky/lint-staged routinely
|
|
1675
2446
|
// idles ~4s on Windows before any task) — 10s is too tight, and a timeout
|
|
1676
2447
|
// kill is NOT an ordinary failure. Same band as the push call below.
|
|
1677
|
-
const commitResult = (0, shell_command_projection_cjs_1.execGit)(commitArgs, { cwd, timeout: COMMIT_TIMEOUT_MS });
|
|
2448
|
+
const commitResult = (0, shell_command_projection_cjs_1.execGit)(commitArgs, { cwd, env: commitEnv, timeout: COMMIT_TIMEOUT_MS });
|
|
1678
2449
|
if (commitResult.exitCode !== 0) {
|
|
1679
2450
|
// #3886: a SIGTERM'd git commit is a timeout, not commit_failed — the
|
|
1680
2451
|
// partial stderr it flushed (often incidental CRLF warnings) is noise,
|
|
@@ -1692,7 +2463,28 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
|
|
|
1692
2463
|
return;
|
|
1693
2464
|
}
|
|
1694
2465
|
if (commitResult.stdout.includes('nothing to commit') || commitResult.stderr.includes('nothing to commit')) {
|
|
1695
|
-
|
|
2466
|
+
// Same reading as the guard above: git recorded nothing, so a removal
|
|
2467
|
+
// this call staged must not be left behind under a `nothing_to_commit`
|
|
2468
|
+
// report. The failure exits below are deliberately NOT restored -- they
|
|
2469
|
+
// report a failure rather than "no state changed", and the addition side
|
|
2470
|
+
// leaves its own staged paths in place there too.
|
|
2471
|
+
const rv = restoreRemovedEntries();
|
|
2472
|
+
if (rv !== 'restored') {
|
|
2473
|
+
output(removalsLeftStaged(rv), raw, 'failed');
|
|
2474
|
+
return;
|
|
2475
|
+
}
|
|
2476
|
+
// #4454: this is the residual window the surrounding comments already
|
|
2477
|
+
// document (a partial skip + partialCommitRefused bypassing the diff
|
|
2478
|
+
// probe + git's own empty-commit refusal) — skippedFiles can be
|
|
2479
|
+
// non-empty here too, and omitting it would be the same misreport
|
|
2480
|
+
// this fix exists to close, just on the other branch that reaches
|
|
2481
|
+
// "nothing to commit".
|
|
2482
|
+
const result = {
|
|
2483
|
+
committed: false,
|
|
2484
|
+
hash: null,
|
|
2485
|
+
reason: 'nothing_to_commit',
|
|
2486
|
+
...(skippedFiles.length > 0 ? { skipped_files: skippedFiles } : {}),
|
|
2487
|
+
};
|
|
1696
2488
|
output(result, raw, 'nothing');
|
|
1697
2489
|
return;
|
|
1698
2490
|
}
|
|
@@ -1708,7 +2500,15 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
|
|
|
1708
2500
|
// Get short hash
|
|
1709
2501
|
const hashResult = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '--short', 'HEAD'], { cwd });
|
|
1710
2502
|
const hash = hashResult.exitCode === 0 ? hashResult.stdout : null;
|
|
1711
|
-
|
|
2503
|
+
// #4454: report explicit --files paths that were skipped as missing (the
|
|
2504
|
+
// #2014 guard above) so a caller can tell a partial commit from a complete
|
|
2505
|
+
// one, without changing the payload shape when nothing was skipped.
|
|
2506
|
+
const result = {
|
|
2507
|
+
committed: true,
|
|
2508
|
+
hash,
|
|
2509
|
+
reason: 'committed',
|
|
2510
|
+
...(skippedFiles.length > 0 ? { skipped_files: skippedFiles } : {}),
|
|
2511
|
+
};
|
|
1712
2512
|
output(result, raw, hash || 'committed');
|
|
1713
2513
|
}
|
|
1714
2514
|
/**
|
|
@@ -1747,7 +2547,7 @@ function groupFilesBySubrepo(files, subRepos) {
|
|
|
1747
2547
|
let matchLen = -1;
|
|
1748
2548
|
if (candidates) {
|
|
1749
2549
|
for (const repo of candidates) {
|
|
1750
|
-
if (file.startsWith(repo + '/')) {
|
|
2550
|
+
if (file.startsWith(repo + '/')) { // allow-handrolled-containment: sub-repo file grouping, not a safety decision
|
|
1751
2551
|
const repoLen = String(repo).length;
|
|
1752
2552
|
if (repoLen > matchLen) {
|
|
1753
2553
|
match = repo;
|
|
@@ -1830,7 +2630,12 @@ function cmdCommitToSubrepo(cwd, message, files, raw) {
|
|
|
1830
2630
|
const commitArgs = canScopeSub
|
|
1831
2631
|
? ['commit', '-m', message, '--', ...stagedRelPaths]
|
|
1832
2632
|
: ['commit', '-m', message];
|
|
1833
|
-
|
|
2633
|
+
// #3859 follow-up fix as cmdCommit above (line ~2081) — git 2.39.5 needs
|
|
2634
|
+
// this override for pathspec-scoped AND whole-index commits alike.
|
|
2635
|
+
const commitEnvSub = {
|
|
2636
|
+
GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'diff.ignoreSubmodules', GIT_CONFIG_VALUE_0: 'dirty',
|
|
2637
|
+
};
|
|
2638
|
+
const commitResult = (0, shell_command_projection_cjs_1.execGit)(commitArgs, { cwd: repoCwd, timeout: COMMIT_TIMEOUT_MS, env: commitEnvSub });
|
|
1834
2639
|
if (commitResult.exitCode !== 0) {
|
|
1835
2640
|
if ((0, shell_command_projection_cjs_1.isSpawnTimeout)(commitResult)) {
|
|
1836
2641
|
// #3886 (subrepo counterpart): timeout ≠ error; surface the stale-lock
|
|
@@ -1891,21 +2696,28 @@ function cmdPrSubrepo(cwd, repo, branch, commitMessage, raw) {
|
|
|
1891
2696
|
error(`Branch name must not start with '-': ${branch}`);
|
|
1892
2697
|
}
|
|
1893
2698
|
// 0. Security: validate repo path is contained within the workspace root.
|
|
1894
|
-
// Uses security.cjs
|
|
2699
|
+
// Uses security.cjs tryWithinRoot (symlink-safe realpathSync + startsWith guard)
|
|
1895
2700
|
// to reject ../escape, absolute paths, and symlink traversal.
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
if (!pathCheck.safe) {
|
|
1900
|
-
error(`Sub-repo path is unsafe: ${pathCheck.error}`);
|
|
2701
|
+
const repoContained = (0, security_cjs_1.tryWithinRoot)(repo, cwd);
|
|
2702
|
+
if (repoContained === null) {
|
|
2703
|
+
error(`Sub-repo path is unsafe: resolves outside the workspace root`);
|
|
1901
2704
|
}
|
|
1902
|
-
const repoCwd =
|
|
2705
|
+
const repoCwd = repoContained;
|
|
1903
2706
|
if (!node_fs_1.default.existsSync(repoCwd)) {
|
|
1904
2707
|
error(`Sub-repo not found: ${repoCwd}`);
|
|
1905
2708
|
}
|
|
1906
2709
|
// 1. Collect changed files via porcelain status — explicit, never git add -A.
|
|
1907
2710
|
// ?? (untracked) lines are excluded — only stage tracked modifications.
|
|
1908
|
-
|
|
2711
|
+
// #3859 follow-up: `git status --porcelain` honors `diff.ignoreSubmodules`
|
|
2712
|
+
// the same way the empty-diff probe fixed for cmdCommit did — under a local
|
|
2713
|
+
// `diff.ignoreSubmodules=all`, a genuinely bumped submodule gitlink is
|
|
2714
|
+
// invisible here too, so `changedFiles` comes back empty and the function
|
|
2715
|
+
// reports `nothing_to_commit` before ever reaching the (now-fixed) commit
|
|
2716
|
+
// call. `--ignore-submodules=dirty` pins this the same way, reported
|
|
2717
|
+
// verbatim: `git -C repo status --porcelain` (no flag) shows nothing for a
|
|
2718
|
+
// pure gitlink bump under `diff.ignoreSubmodules=all`, while
|
|
2719
|
+
// `--ignore-submodules=dirty` reports ` M nested` (reproduced directly).
|
|
2720
|
+
const statusResult = (0, shell_command_projection_cjs_1.execGit)(['-c', 'core.quotePath=false', 'status', '--porcelain', '--ignore-submodules=dirty'], { cwd: repoCwd });
|
|
1909
2721
|
if (statusResult.exitCode !== 0) {
|
|
1910
2722
|
error(`git status failed in ${repo}: ${statusResult.stderr}`);
|
|
1911
2723
|
}
|
|
@@ -1974,7 +2786,12 @@ function cmdPrSubrepo(cwd, repo, branch, commitMessage, raw) {
|
|
|
1974
2786
|
const commitArgs = canScopePr
|
|
1975
2787
|
? ['commit', '-m', commitMessage, '--', ...changedFiles]
|
|
1976
2788
|
: ['commit', '-m', commitMessage];
|
|
1977
|
-
|
|
2789
|
+
// #3859 follow-up fix as cmdCommit above (line ~2081) — git 2.39.5 needs
|
|
2790
|
+
// this override for pathspec-scoped AND whole-index commits alike.
|
|
2791
|
+
const commitEnvPr = {
|
|
2792
|
+
GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'diff.ignoreSubmodules', GIT_CONFIG_VALUE_0: 'dirty',
|
|
2793
|
+
};
|
|
2794
|
+
const commitResult = (0, shell_command_projection_cjs_1.execGit)(commitArgs, { cwd: repoCwd, timeout: COMMIT_TIMEOUT_MS, env: commitEnvPr });
|
|
1978
2795
|
if (commitResult.exitCode !== 0) {
|
|
1979
2796
|
rollback();
|
|
1980
2797
|
if ((0, shell_command_projection_cjs_1.isSpawnTimeout)(commitResult)) {
|
|
@@ -2229,9 +3046,7 @@ function cmdProgressRender(cwd, format, raw) {
|
|
|
2229
3046
|
: null;
|
|
2230
3047
|
if (format === 'table') {
|
|
2231
3048
|
// Render markdown table
|
|
2232
|
-
const
|
|
2233
|
-
const filled = percent === null ? 0 : Math.round((percent / 100) * barWidth);
|
|
2234
|
-
const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
|
|
3049
|
+
const bar = (0, phase_lifecycle_cjs_1.renderProgressBar)(percent, 10);
|
|
2235
3050
|
const percentSuffix = percent === null ? '' : ` (${percent}%)`;
|
|
2236
3051
|
let out = `# ${milestone?.version ?? ''} ${milestone?.name ?? ''}\n\n`;
|
|
2237
3052
|
out += `**Progress:** [${bar}] ${totalSummaries}/${totalPlans} plans${percentSuffix}\n\n`;
|
|
@@ -2243,9 +3058,7 @@ function cmdProgressRender(cwd, format, raw) {
|
|
|
2243
3058
|
output({ rendered: out }, raw, out);
|
|
2244
3059
|
}
|
|
2245
3060
|
else if (format === 'bar') {
|
|
2246
|
-
const
|
|
2247
|
-
const filled = percent === null ? 0 : Math.round((percent / 100) * barWidth);
|
|
2248
|
-
const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
|
|
3061
|
+
const bar = (0, phase_lifecycle_cjs_1.renderProgressBar)(percent, 20);
|
|
2249
3062
|
const percentSuffix = percent === null ? '' : ` (${percent}%)`;
|
|
2250
3063
|
const text = `[${bar}] ${totalSummaries}/${totalPlans} plans${percentSuffix}`;
|
|
2251
3064
|
output({ bar: text, percent, completed: totalSummaries, total: totalPlans }, raw, text);
|
|
@@ -2274,7 +3087,8 @@ function cmdTodoMatchPhase(cwd, phase, raw) {
|
|
|
2274
3087
|
if (!phase) {
|
|
2275
3088
|
error('phase required for todo match-phase');
|
|
2276
3089
|
}
|
|
2277
|
-
|
|
3090
|
+
// #4256: root-scoped todos read — see cmdListTodos.
|
|
3091
|
+
const pendingDir = node_path_1.default.join(todosDir(cwd), 'pending');
|
|
2278
3092
|
const todos = [];
|
|
2279
3093
|
// Load pending todos
|
|
2280
3094
|
try {
|
|
@@ -2375,24 +3189,113 @@ function cmdTodoMatchPhase(cwd, phase, raw) {
|
|
|
2375
3189
|
matches.sort((a, b) => b.score - a.score);
|
|
2376
3190
|
output({ phase, matches, todo_count: todos.length }, raw, undefined);
|
|
2377
3191
|
}
|
|
2378
|
-
|
|
3192
|
+
// #4096: upsert completion keys INSIDE the leading frontmatter block. Never a
|
|
3193
|
+
// bare prefix line above the opening `---` (that displaces the fence to line 2
|
|
3194
|
+
// and breaks every fence-locating reader). A file with no well-formed block
|
|
3195
|
+
// (absent, or an unterminated opening fence) gains a complete block.
|
|
3196
|
+
function upsertTodoCompletionFields(content, today) {
|
|
3197
|
+
const lines = content.split('\n');
|
|
3198
|
+
const fields = [`completed: ${today}`, 'status: completed'];
|
|
3199
|
+
const hasOpeningFence = lines[0] !== undefined && lines[0].trim() === '---';
|
|
3200
|
+
const closeIdx = hasOpeningFence ? lines.findIndex((l, i) => i > 0 && l.trim() === '---') : -1;
|
|
3201
|
+
if (!hasOpeningFence || closeIdx === -1) {
|
|
3202
|
+
// No parseable frontmatter: wrap the whole content in a complete block
|
|
3203
|
+
// rather than prefixing bare keys (#4096 fix 2).
|
|
3204
|
+
return `---\n${fields.join('\n')}\n---\n\n${content}`;
|
|
3205
|
+
}
|
|
3206
|
+
const block = lines.slice(1, closeIdx);
|
|
3207
|
+
for (const field of fields) {
|
|
3208
|
+
const key = `${field.slice(0, field.indexOf(':'))}:`;
|
|
3209
|
+
const idx = block.findIndex(l => l.startsWith(key));
|
|
3210
|
+
if (idx === -1) {
|
|
3211
|
+
block.push(field);
|
|
3212
|
+
}
|
|
3213
|
+
else {
|
|
3214
|
+
block[idx] = field;
|
|
3215
|
+
}
|
|
3216
|
+
}
|
|
3217
|
+
return [...lines.slice(0, 1), ...block, ...lines.slice(closeIdx)].join('\n');
|
|
3218
|
+
}
|
|
3219
|
+
function cmdTodoComplete(cwd, filename, options, raw) {
|
|
2379
3220
|
if (!filename) {
|
|
2380
3221
|
error('filename required for todo complete');
|
|
2381
3222
|
}
|
|
2382
|
-
|
|
2383
|
-
|
|
3223
|
+
// #4256: root-scoped todos read/write — see cmdListTodos. The pending and
|
|
3224
|
+
// completed halves of the move must resolve from the SAME root or the
|
|
3225
|
+
// completion would strand files where no reader looks.
|
|
3226
|
+
const todosRoot = todosDir(cwd);
|
|
3227
|
+
const pendingDir = node_path_1.default.join(todosRoot, 'pending');
|
|
3228
|
+
const completedDir = node_path_1.default.join(todosRoot, 'completed');
|
|
3229
|
+
// #4652: containment against todosRoot only rejects paths that leave the
|
|
3230
|
+
// root — it cannot express "a todo name is a basename, not a path" (see
|
|
3231
|
+
// #4327). `../sibling.md`, `a/../../b.md`, and `sub/name.md` all resolve
|
|
3232
|
+
// to a location inside todosRoot (or inside pending/) and would pass
|
|
3233
|
+
// containment, yet none of them is a bare filename. Reject on basename
|
|
3234
|
+
// shape FIRST, before any path is even joined — same predicate shape as
|
|
3235
|
+
// findPhaseArtifact in check-command-router.cts. Checking both `/` and
|
|
3236
|
+
// `\` explicitly (not just path.basename) matters on POSIX, where a
|
|
3237
|
+
// literal backslash is just an ordinary filename character to
|
|
3238
|
+
// path.basename but not to path.win32.basename or to the user's intent.
|
|
3239
|
+
const rawFilename = filename;
|
|
3240
|
+
if (rawFilename === '.' ||
|
|
3241
|
+
rawFilename === '..' ||
|
|
3242
|
+
rawFilename.includes('\0') ||
|
|
3243
|
+
rawFilename.includes('/') ||
|
|
3244
|
+
rawFilename.includes('\\') ||
|
|
3245
|
+
node_path_1.default.basename(rawFilename) !== rawFilename ||
|
|
3246
|
+
node_path_1.default.win32.basename(rawFilename) !== rawFilename) {
|
|
3247
|
+
error(`todo name must be a plain filename inside the pending directory, not a path: ${rawFilename}`, ERROR_REASON.USAGE);
|
|
3248
|
+
}
|
|
2384
3249
|
const sourcePath = node_path_1.default.join(pendingDir, filename);
|
|
2385
|
-
|
|
3250
|
+
const targetPath = node_path_1.default.join(completedDir, filename);
|
|
3251
|
+
const sourceContained = (0, security_cjs_1.tryWithinRoot)(sourcePath, todosRoot, security_cjs_1.PathAcceptance.AbsoluteInsideRoot);
|
|
3252
|
+
if (sourceContained === null) {
|
|
3253
|
+
error(`todo file escapes its allowed directory: ${filename}`, ERROR_REASON.USAGE);
|
|
3254
|
+
}
|
|
3255
|
+
const targetContained = (0, security_cjs_1.tryWithinRoot)(targetPath, todosRoot, security_cjs_1.PathAcceptance.AbsoluteInsideRoot);
|
|
3256
|
+
if (targetContained === null) {
|
|
3257
|
+
error(`todo file escapes its allowed directory: ${filename}`, ERROR_REASON.USAGE);
|
|
3258
|
+
}
|
|
3259
|
+
const resolvedSource = sourceContained;
|
|
3260
|
+
const resolvedTarget = targetContained;
|
|
3261
|
+
if (!node_fs_1.default.existsSync(resolvedSource)) {
|
|
2386
3262
|
error(`Todo not found: ${filename}`);
|
|
2387
3263
|
}
|
|
2388
|
-
//
|
|
2389
|
-
|
|
2390
|
-
//
|
|
2391
|
-
|
|
3264
|
+
// #4652: a name that IS a bare basename can still resolve to something that
|
|
3265
|
+
// is not a regular file — a directory, symlink-to-directory, FIFO or socket
|
|
3266
|
+
// sitting in pending/ under an ordinary-looking name. `.` and `..` no longer
|
|
3267
|
+
// reach here (the basename guard above rejects them first), so this is not
|
|
3268
|
+
// about traversal; it stops fs.readFileSync from throwing an uncaught EISDIR
|
|
3269
|
+
// with an absolute-path stack trace where every sibling case gives a clean
|
|
3270
|
+
// USAGE rejection.
|
|
3271
|
+
if (!node_fs_1.default.statSync(resolvedSource).isFile()) {
|
|
3272
|
+
error(`todo name is not a file: ${filename}`, ERROR_REASON.USAGE);
|
|
3273
|
+
}
|
|
3274
|
+
const content = node_fs_1.default.readFileSync(resolvedSource, 'utf-8');
|
|
2392
3275
|
const today = clock_cjs_1.realClock.localToday();
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
3276
|
+
// #4096: --dry-run mirrors `milestone complete --dry-run` (#2118) — every
|
|
3277
|
+
// existence check above still runs, nothing below mutates, and the payload
|
|
3278
|
+
// is preview-shaped (`dry_run`/`would_*`), never `completed: true`.
|
|
3279
|
+
if (options.dryRun) {
|
|
3280
|
+
output({
|
|
3281
|
+
dry_run: true,
|
|
3282
|
+
would_complete: true,
|
|
3283
|
+
file: filename,
|
|
3284
|
+
date: today,
|
|
3285
|
+
would_move: {
|
|
3286
|
+
source: node_path_1.default.relative(cwd, resolvedSource).split(node_path_1.default.sep).join('/'),
|
|
3287
|
+
target: node_path_1.default.relative(cwd, resolvedTarget).split(node_path_1.default.sep).join('/'),
|
|
3288
|
+
},
|
|
3289
|
+
would_set: { completed: today, status: 'completed' },
|
|
3290
|
+
}, raw);
|
|
3291
|
+
return;
|
|
3292
|
+
}
|
|
3293
|
+
// Ensure completed directory exists (only on the real run — a dry run
|
|
3294
|
+
// creates nothing).
|
|
3295
|
+
(0, shell_command_projection_cjs_1.platformEnsureDir)(completedDir);
|
|
3296
|
+
const completedContent = upsertTodoCompletionFields(content, today);
|
|
3297
|
+
(0, shell_command_projection_cjs_1.platformWriteSync)(resolvedTarget, completedContent);
|
|
3298
|
+
node_fs_1.default.unlinkSync(resolvedSource);
|
|
2396
3299
|
output({ completed: true, file: filename, date: today }, raw, 'completed');
|
|
2397
3300
|
}
|
|
2398
3301
|
function cmdScaffold(cwd, type, options, raw) {
|
|
@@ -2602,9 +3505,7 @@ function cmdStats(cwd, format, raw) {
|
|
|
2602
3505
|
phase_scope: phaseScope,
|
|
2603
3506
|
};
|
|
2604
3507
|
if (format === 'table') {
|
|
2605
|
-
const
|
|
2606
|
-
const filled = percent === null ? 0 : Math.round((percent / 100) * barWidth);
|
|
2607
|
-
const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
|
|
3508
|
+
const bar = (0, phase_lifecycle_cjs_1.renderProgressBar)(percent, 10);
|
|
2608
3509
|
let out = `# ${milestone?.version ?? ''} ${milestone?.name ?? ''} — Statistics\n\n`;
|
|
2609
3510
|
const percentSuffix = percent === null ? '' : ` (${percent}%)`;
|
|
2610
3511
|
out += `**Progress:** [${bar}] ${completedPhases}/${phases.length} phases${percentSuffix}\n`;
|
|
@@ -2865,6 +3766,7 @@ function cmdCommitDocsGuardDisable(cwd, raw) {
|
|
|
2865
3766
|
output({ disabled: true, action: 'removed', path: hookPath }, raw, 'disabled');
|
|
2866
3767
|
}
|
|
2867
3768
|
module.exports = {
|
|
3769
|
+
effortSurfaceForHost,
|
|
2868
3770
|
groupFilesBySubrepo,
|
|
2869
3771
|
determinePhaseStatus,
|
|
2870
3772
|
foldPhaseStatus,
|