@deftai/directive-content 0.58.0 → 0.60.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/.githooks/pre-push +10 -9
- package/Taskfile.yml +57 -67
- package/UPGRADING.md +1 -1
- package/docs/assets/directive-lifecycle-diagram.png +0 -0
- package/docs/directive-lifecycle.md +73 -0
- package/docs/getting-started.md +5 -1
- package/package.json +3 -3
- package/packs/rules/rules-pack-0.1.json +3 -3
- package/packs/skills/skills-pack-0.1.json +22 -22
- package/scm/github.md +20 -2
- package/tasks/change.yml +16 -31
- package/tasks/ci.yml +8 -0
- package/tasks/commit.yml +12 -19
- package/tasks/core.yml +10 -0
- package/tasks/engine.yml +42 -0
- package/tasks/framework.yml +3 -0
- package/tasks/install.yml +20 -19
- package/tasks/migrate.yml +26 -15
- package/tasks/project.yml +16 -0
- package/tasks/relocate.yml +18 -48
- package/tasks/toolchain.yml +15 -5
- package/tasks/vbrief.yml +4 -3
- package/tasks/verify.yml +12 -14
- package/templates/agents-entry.md +1 -2
- package/scripts/_agents_md.py +0 -494
- package/scripts/_cache_fetch.py +0 -635
- package/scripts/_cache_quota.py +0 -529
- package/scripts/_cache_refresh.py +0 -163
- package/scripts/_cache_validate.py +0 -209
- package/scripts/_content_root.py +0 -42
- package/scripts/_doctor_state.py +0 -277
- package/scripts/_event_detect.py +0 -305
- package/scripts/_events.py +0 -514
- package/scripts/_lifecycle_hygiene.py +0 -568
- package/scripts/_pathspec.py +0 -91
- package/scripts/_policy_show_cli.py +0 -266
- package/scripts/_precutover.py +0 -92
- package/scripts/_project_context.py +0 -224
- package/scripts/_project_definition_io.py +0 -164
- package/scripts/_relocate_snapshot.py +0 -209
- package/scripts/_relocate_states.py +0 -343
- package/scripts/_resolve_preflight_path.py +0 -152
- package/scripts/_safe_subprocess.py +0 -167
- package/scripts/_session_start_hook.py +0 -205
- package/scripts/_sor_gate_diff.py +0 -365
- package/scripts/_stdio_utf8.py +0 -59
- package/scripts/_triage_bootstrap_gitignore.py +0 -904
- package/scripts/_triage_classify_cli.py +0 -122
- package/scripts/_triage_queue_cli.py +0 -625
- package/scripts/_triage_scope_cli.py +0 -343
- package/scripts/_triage_scope_drift_cli.py +0 -121
- package/scripts/_triage_scope_ignores.py +0 -286
- package/scripts/_triage_scope_milestone.py +0 -432
- package/scripts/_triage_scope_mutations.py +0 -337
- package/scripts/_triage_scope_renderers.py +0 -207
- package/scripts/_triage_smoketest_stages.py +0 -674
- package/scripts/_triage_subscribe_cli.py +0 -140
- package/scripts/_triage_welcome_cli.py +0 -421
- package/scripts/_vbrief_build.py +0 -239
- package/scripts/_vbrief_fidelity.py +0 -479
- package/scripts/_vbrief_legacy.py +0 -589
- package/scripts/_vbrief_reconciliation.py +0 -883
- package/scripts/_vbrief_routing.py +0 -277
- package/scripts/_vbrief_safety.py +0 -778
- package/scripts/_vbrief_sources.py +0 -312
- package/scripts/_vbrief_speckit.py +0 -262
- package/scripts/_vbrief_story_quality.py +0 -353
- package/scripts/_vbrief_validation.py +0 -299
- package/scripts/build_dist.py +0 -412
- package/scripts/cache.py +0 -1078
- package/scripts/cache_scanner.py +0 -745
- package/scripts/candidates_log.py +0 -432
- package/scripts/capacity_backfill.py +0 -680
- package/scripts/capacity_show.py +0 -653
- package/scripts/ci_local.py +0 -689
- package/scripts/code_structure_validate.py +0 -765
- package/scripts/codebase_default_extractor.py +0 -495
- package/scripts/codebase_map.py +0 -304
- package/scripts/codebase_map_fresh.py +0 -104
- package/scripts/codebase_projection_registry.py +0 -94
- package/scripts/codebase_provider.py +0 -582
- package/scripts/doctor.py +0 -2551
- package/scripts/framework_commands.py +0 -505
- package/scripts/gh_rest.py +0 -882
- package/scripts/github_auth_modes.py +0 -437
- package/scripts/github_body.py +0 -292
- package/scripts/ip_risk.py +0 -531
- package/scripts/issue_emit.py +0 -670
- package/scripts/issue_ingest.py +0 -1064
- package/scripts/migrate_preflight.py +0 -418
- package/scripts/migrate_vbrief.py +0 -2677
- package/scripts/monitor_pr.py +0 -401
- package/scripts/pack_migrate_lessons.py +0 -336
- package/scripts/pack_migrate_patterns.py +0 -254
- package/scripts/pack_migrate_rules.py +0 -350
- package/scripts/pack_migrate_skills.py +0 -423
- package/scripts/pack_migrate_strategies.py +0 -311
- package/scripts/pack_migrate_swarm_spec.py +0 -250
- package/scripts/pack_render.py +0 -434
- package/scripts/packs_slice.py +0 -712
- package/scripts/platform_capabilities.py +0 -336
- package/scripts/policy.py +0 -2826
- package/scripts/policy_set.py +0 -324
- package/scripts/pr_check_closing_keywords.py +0 -524
- package/scripts/pr_check_protected_issues.py +0 -267
- package/scripts/pr_merge_readiness.py +0 -1004
- package/scripts/pr_wait_mergeable.py +0 -669
- package/scripts/prd_render.py +0 -159
- package/scripts/preflight_architecture_sor.py +0 -974
- package/scripts/preflight_branch.py +0 -289
- package/scripts/preflight_cache.py +0 -974
- package/scripts/preflight_gh.py +0 -721
- package/scripts/preflight_implementation.py +0 -272
- package/scripts/preflight_story_start.py +0 -838
- package/scripts/preflight_wip_cap.py +0 -149
- package/scripts/probe_session.py +0 -545
- package/scripts/project_render.py +0 -293
- package/scripts/quarantine_ext.py +0 -237
- package/scripts/reconcile_issues.py +0 -1442
- package/scripts/refresh-path.ps1 +0 -107
- package/scripts/release.py +0 -2030
- package/scripts/release_e2e.py +0 -1011
- package/scripts/release_publish.py +0 -486
- package/scripts/release_rollback.py +0 -980
- package/scripts/relocate.py +0 -1034
- package/scripts/resolve_changelog_unreleased.py +0 -667
- package/scripts/resolve_version.py +0 -490
- package/scripts/resume_conditions.py +0 -706
- package/scripts/ritual_sentinel.py +0 -609
- package/scripts/roadmap_render.py +0 -635
- package/scripts/rule_ownership_lint.py +0 -325
- package/scripts/scm.py +0 -591
- package/scripts/scope_audit_log.py +0 -387
- package/scripts/scope_decompose.py +0 -654
- package/scripts/scope_demote.py +0 -509
- package/scripts/scope_lifecycle.py +0 -1126
- package/scripts/scope_undo.py +0 -772
- package/scripts/session_start.py +0 -406
- package/scripts/setup_ghx.py +0 -339
- package/scripts/setup_windows.ps1 +0 -220
- package/scripts/slice_audit.py +0 -585
- package/scripts/slice_record.py +0 -530
- package/scripts/slice_record_existing.py +0 -692
- package/scripts/slug_normalize.py +0 -178
- package/scripts/spec_render.py +0 -477
- package/scripts/spec_validate.py +0 -238
- package/scripts/subagent_monitor.py +0 -658
- package/scripts/swarm_complete_cohort.py +0 -644
- package/scripts/swarm_launch.py +0 -1206
- package/scripts/swarm_readiness.py +0 -554
- package/scripts/swarm_verify_review_clean.py +0 -438
- package/scripts/swarm_worktrees.py +0 -497
- package/scripts/toolchain-check.py +0 -52
- package/scripts/triage_actions.py +0 -871
- package/scripts/triage_bootstrap.py +0 -1153
- package/scripts/triage_bulk.py +0 -630
- package/scripts/triage_classify.py +0 -932
- package/scripts/triage_help.py +0 -1685
- package/scripts/triage_queue.py +0 -1944
- package/scripts/triage_reconcile.py +0 -581
- package/scripts/triage_refresh.py +0 -643
- package/scripts/triage_scope.py +0 -999
- package/scripts/triage_scope_drift.py +0 -575
- package/scripts/triage_smoketest.py +0 -396
- package/scripts/triage_subscribe.py +0 -399
- package/scripts/triage_summary.py +0 -1011
- package/scripts/triage_welcome.py +0 -1178
- package/scripts/ts_check_lane.py +0 -86
- package/scripts/validate-links.py +0 -64
- package/scripts/validate_strategy_output.py +0 -212
- package/scripts/vbrief_activate.py +0 -228
- package/scripts/vbrief_migrate_conformance.py +0 -368
- package/scripts/vbrief_reconcile_graph.py +0 -306
- package/scripts/vbrief_reconcile_labels.py +0 -460
- package/scripts/vbrief_reconcile_umbrellas.py +0 -741
- package/scripts/vbrief_validate.py +0 -1144
- package/scripts/verify-stubs.py +0 -61
- package/scripts/verify_capacity.py +0 -160
- package/scripts/verify_encoding.py +0 -699
- package/scripts/verify_hooks_installed.py +0 -206
- package/scripts/verify_investigation.py +0 -360
- package/scripts/verify_judgment_gates.py +0 -827
- package/scripts/verify_no_task_runtime.py +0 -171
- package/scripts/verify_scm_boundary.py +0 -509
- package/scripts/verify_session_ritual.py +0 -389
- package/scripts/verify_tools.py +0 -426
- package/scripts/verify_vbrief_conformance.py +0 -478
package/scripts/preflight_gh.py
DELETED
|
@@ -1,721 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""preflight_gh.py -- detection-bound gate for destructive ``gh`` verbs (#1019).
|
|
3
|
-
|
|
4
|
-
Pure stdlib, cross-platform. Mirrors :mod:`preflight_branch` (#747) in shape:
|
|
5
|
-
|
|
6
|
-
- ``0`` -- allowed: command is not destructive, OR an env-var bypass /
|
|
7
|
-
typed policy flag explicitly authorizes the destructive verb.
|
|
8
|
-
- ``1`` -- blocked: command classified as one of the destructive categories
|
|
9
|
-
defined by this gate and no bypass is active.
|
|
10
|
-
- ``2`` -- config error: malformed input, missing required argument, or
|
|
11
|
-
the operator passed a self-test fixture that disagrees with the
|
|
12
|
-
classifier (the latter is a hard failure for the ``--self-test`` mode).
|
|
13
|
-
|
|
14
|
-
Destructive categories detected by this gate (see #1019 vBRIEF for
|
|
15
|
-
rationale and the real-world recurrence pattern that motivates each):
|
|
16
|
-
|
|
17
|
-
- ``delete_repo`` -- ``gh repo delete <repo>`` and
|
|
18
|
-
``gh api -X DELETE repos/<owner>/<repo>[/...]``. Irreversible repo
|
|
19
|
-
deletion via the GitHub API. This is the failure mode that prompted
|
|
20
|
-
the issue (a peer-tool agent deleted every company repo before
|
|
21
|
-
apologising).
|
|
22
|
-
- ``force_push_default`` -- ``git push --force`` and
|
|
23
|
-
``git push --force-with-lease`` to the default branch (master/main).
|
|
24
|
-
The #747 branch gate refuses *commits* to master/main, but a force-push
|
|
25
|
-
from a feature branch that targets ``master`` was uncovered. This gate
|
|
26
|
-
closes the loop.
|
|
27
|
-
- ``admin_merge`` -- ``gh pr merge --admin`` (bypass of branch protection
|
|
28
|
-
required reviews). Document the rationale + escape hatch rather than
|
|
29
|
-
silently allow.
|
|
30
|
-
|
|
31
|
-
Intended call surfaces:
|
|
32
|
-
|
|
33
|
-
- ``.githooks/pre-push`` -- the hook reads its per-ref stdin lines and
|
|
34
|
-
dispatches to ``preflight_gh.py --pre-push-stdin`` so force-pushes
|
|
35
|
-
targeting the default branch are refused at the git layer before the
|
|
36
|
-
network call.
|
|
37
|
-
- ``task verify:destructive-gh-verbs`` -- aggregated into ``task check``.
|
|
38
|
-
Runs ``--self-test`` so a future edit to the classifier table that
|
|
39
|
-
introduces a false negative / false positive fails CI immediately.
|
|
40
|
-
- Agent pre-execution hooks -- ``preflight_gh.py --command "<full
|
|
41
|
-
command string>"`` returns three-state exit so an orchestrator can
|
|
42
|
-
refuse the verb before invoking ``gh`` (out of scope for v1; the
|
|
43
|
-
CLI surface is provided so the wiring can land additively).
|
|
44
|
-
|
|
45
|
-
Escape hatch (operator-implemented): set
|
|
46
|
-
``DEFT_ALLOW_DESTRUCTIVE_GH_VERBS=1`` to bypass the gate for a single
|
|
47
|
-
shell. Symmetric with ``DEFT_ALLOW_DEFAULT_BRANCH_COMMIT`` (#747).
|
|
48
|
-
"""
|
|
49
|
-
|
|
50
|
-
from __future__ import annotations
|
|
51
|
-
|
|
52
|
-
import argparse
|
|
53
|
-
import os
|
|
54
|
-
import re
|
|
55
|
-
import shlex
|
|
56
|
-
import sys
|
|
57
|
-
from collections.abc import Iterable
|
|
58
|
-
from dataclasses import dataclass
|
|
59
|
-
from pathlib import Path
|
|
60
|
-
|
|
61
|
-
#: Environment variable that lets the operator bypass the destructive-verb
|
|
62
|
-
#: gate WITHOUT editing the typed flag. Documented in #1019 as the
|
|
63
|
-
#: explicit emergency-escape hatch (e.g. authorised release-cycle
|
|
64
|
-
#: ``gh release delete`` runs, hot-fix admin merges). When set to a
|
|
65
|
-
#: truthy value, this gate exits 0 with an explicit "policy bypassed"
|
|
66
|
-
#: message so the bypass is auditable.
|
|
67
|
-
ENV_BYPASS = "DEFT_ALLOW_DESTRUCTIVE_GH_VERBS"
|
|
68
|
-
|
|
69
|
-
#: Recognised truthy strings for the env-var bypass. Identical to the
|
|
70
|
-
#: surface used by :mod:`scripts.policy` for parity with #747.
|
|
71
|
-
_TRUTHY = frozenset({"1", "true", "yes", "on"})
|
|
72
|
-
|
|
73
|
-
#: Default-branch refs treated as protected against force-push by the
|
|
74
|
-
#: ``force_push_default`` category. Mirrors the
|
|
75
|
-
#: :mod:`scripts.preflight_branch` default-branch set so the two gates
|
|
76
|
-
#: agree on what "default" means.
|
|
77
|
-
DEFAULT_BRANCHES = frozenset({"master", "main"})
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
@dataclass(frozen=True)
|
|
81
|
-
class Verdict:
|
|
82
|
-
"""Result of classifying a candidate command.
|
|
83
|
-
|
|
84
|
-
``category`` is ``None`` when the command is not destructive.
|
|
85
|
-
``detail`` is a short human-readable reason; ``recovery`` is the
|
|
86
|
-
follow-up text the gate prints to stderr on a block so the operator
|
|
87
|
-
knows how to proceed.
|
|
88
|
-
"""
|
|
89
|
-
|
|
90
|
-
allowed: bool
|
|
91
|
-
category: str | None
|
|
92
|
-
detail: str
|
|
93
|
-
recovery: str = ""
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
_OK_VERDICT = Verdict(allowed=True, category=None, detail="not destructive")
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
# ---------------------------------------------------------------------------
|
|
100
|
-
# Classifier
|
|
101
|
-
# ---------------------------------------------------------------------------
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
def _env_bypass_active() -> bool:
|
|
105
|
-
"""True when ``DEFT_ALLOW_DESTRUCTIVE_GH_VERBS`` is set to a truthy value."""
|
|
106
|
-
raw = os.environ.get(ENV_BYPASS, "")
|
|
107
|
-
return raw.strip().lower() in _TRUTHY
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
def _tokens_from_string(command: str) -> list[str]:
|
|
111
|
-
"""Tokenise a candidate command string into argv-like tokens.
|
|
112
|
-
|
|
113
|
-
Uses :func:`shlex.split` with ``posix=True`` -- the gate is invoked
|
|
114
|
-
on the operator side BEFORE the verb executes, so we can assume
|
|
115
|
-
POSIX-style quoting from the dispatching agent. Windows agents
|
|
116
|
-
that pass a literal cmd.exe string get a degraded but conservative
|
|
117
|
-
tokenisation: ``shlex`` falls back to whitespace splits when the
|
|
118
|
-
string contains no quotes, which is the common case for
|
|
119
|
-
``gh repo delete owner/repo`` style invocations.
|
|
120
|
-
"""
|
|
121
|
-
try:
|
|
122
|
-
return shlex.split(command, posix=True)
|
|
123
|
-
except ValueError:
|
|
124
|
-
# Mismatched quotes; treat as raw whitespace tokens so we still
|
|
125
|
-
# match the destructive sub-strings rather than failing open.
|
|
126
|
-
return command.split()
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
def _is_delete_repo(tokens: list[str]) -> Verdict | None:
|
|
130
|
-
"""Detect ``gh repo delete ...`` and ``gh api -X DELETE repos/...``.
|
|
131
|
-
|
|
132
|
-
Both forms are irreversible. ``gh api -X DELETE`` is the surface a
|
|
133
|
-
bypass-conscious agent might reach for when ``gh repo delete`` is
|
|
134
|
-
intercepted, so it MUST be detected even when the args are split
|
|
135
|
-
by the equals form (``-X=DELETE`` / ``--method=DELETE``).
|
|
136
|
-
"""
|
|
137
|
-
if len(tokens) < 2:
|
|
138
|
-
return None
|
|
139
|
-
head = tokens[0].lower()
|
|
140
|
-
if head not in {"gh", "ghx"}:
|
|
141
|
-
return None
|
|
142
|
-
|
|
143
|
-
# gh repo delete <owner/repo>
|
|
144
|
-
if tokens[1].lower() == "repo" and len(tokens) >= 3 and tokens[2].lower() == "delete":
|
|
145
|
-
target = tokens[3] if len(tokens) >= 4 else "<unspecified>"
|
|
146
|
-
return Verdict(
|
|
147
|
-
allowed=False,
|
|
148
|
-
category="delete_repo",
|
|
149
|
-
detail=f"gh repo delete {target}",
|
|
150
|
-
recovery=(
|
|
151
|
-
" Repo deletion is irreversible. If this is intentional:\n"
|
|
152
|
-
f" • set the env-var bypass for this shell: {ENV_BYPASS}=1\n"
|
|
153
|
-
" • or run the deletion via the GitHub web UI so the\n"
|
|
154
|
-
" reversible-archive prompt fires (preferred)."
|
|
155
|
-
),
|
|
156
|
-
)
|
|
157
|
-
|
|
158
|
-
# gh api ... DELETE repos/<owner>/<repo>...
|
|
159
|
-
if tokens[1].lower() == "api" and _api_invocation_is_delete(tokens[2:]):
|
|
160
|
-
endpoint = _api_endpoint(tokens[2:])
|
|
161
|
-
if endpoint and _endpoint_is_repo_root(endpoint):
|
|
162
|
-
return Verdict(
|
|
163
|
-
allowed=False,
|
|
164
|
-
category="delete_repo",
|
|
165
|
-
detail=f"gh api -X DELETE {endpoint}",
|
|
166
|
-
recovery=(
|
|
167
|
-
" Repo / repo-subresource deletion via the API is\n"
|
|
168
|
-
" irreversible. If this is intentional:\n"
|
|
169
|
-
f" • set the env-var bypass for this shell: {ENV_BYPASS}=1"
|
|
170
|
-
),
|
|
171
|
-
)
|
|
172
|
-
|
|
173
|
-
return None
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
def _api_invocation_is_delete(tokens: Iterable[str]) -> bool:
|
|
177
|
-
"""True iff the gh-api token list specifies a DELETE method.
|
|
178
|
-
|
|
179
|
-
Handles ``-X DELETE``, ``-XDELETE``, ``--method DELETE``,
|
|
180
|
-
``-X=DELETE``, ``--method=DELETE`` and equivalent case-insensitive
|
|
181
|
-
forms.
|
|
182
|
-
"""
|
|
183
|
-
items = list(tokens)
|
|
184
|
-
for idx, tok in enumerate(items):
|
|
185
|
-
low = tok.lower()
|
|
186
|
-
if (
|
|
187
|
-
low in {"-x", "--method"}
|
|
188
|
-
and idx + 1 < len(items)
|
|
189
|
-
and items[idx + 1].upper() == "DELETE"
|
|
190
|
-
):
|
|
191
|
-
return True
|
|
192
|
-
if low.startswith(("-x=", "--method=")):
|
|
193
|
-
value = tok.split("=", 1)[1]
|
|
194
|
-
if value.upper() == "DELETE":
|
|
195
|
-
return True
|
|
196
|
-
# -XDELETE / -Xdelete (combined short-flag form)
|
|
197
|
-
if low.startswith("-x") and len(low) > 2 and low[2:].upper() == "DELETE":
|
|
198
|
-
return True
|
|
199
|
-
return False
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
def _api_endpoint(tokens: Iterable[str]) -> str | None:
|
|
203
|
-
"""Return the positional endpoint argument (the path after ``gh api``).
|
|
204
|
-
|
|
205
|
-
Skips both flag tokens (``-X`` / ``--method`` / ``-H`` / ``--header`` /
|
|
206
|
-
``-F`` / ``-f`` / ``--field`` / ``--input``) AND the value that
|
|
207
|
-
follows them when the value is passed space-separated. The set
|
|
208
|
-
enumerated here is the closed set of ``gh api`` flags that take an
|
|
209
|
-
argument; anything else with a leading ``-`` is treated as a boolean
|
|
210
|
-
flag and skipped without consuming the next token.
|
|
211
|
-
"""
|
|
212
|
-
# Note: ``gh api`` short flag for ``--raw-field`` is ``-F`` (uppercase).
|
|
213
|
-
# Because the caller lower-cases the token before lookup, ``-F`` is
|
|
214
|
-
# already covered implicitly by the ``-f`` entry, but enumerating ``-F``
|
|
215
|
-
# explicitly makes the contract self-documenting and avoids a duplicate-
|
|
216
|
-
# item ruff finding (B033) when both forms collapse to the same key.
|
|
217
|
-
value_taking = {
|
|
218
|
-
"-x", "--method",
|
|
219
|
-
"-h", "--header",
|
|
220
|
-
"-f", "--field",
|
|
221
|
-
"-F", "--raw-field",
|
|
222
|
-
"--input",
|
|
223
|
-
"--jq", "-q",
|
|
224
|
-
"--template", "-t",
|
|
225
|
-
"--hostname",
|
|
226
|
-
"--cache",
|
|
227
|
-
}
|
|
228
|
-
items = list(tokens)
|
|
229
|
-
idx = 0
|
|
230
|
-
while idx < len(items):
|
|
231
|
-
tok = items[idx]
|
|
232
|
-
low = tok.lower()
|
|
233
|
-
if not tok.startswith("-"):
|
|
234
|
-
return tok
|
|
235
|
-
# Flag-with-attached-value form: -X=DELETE / --method=DELETE -- skip.
|
|
236
|
-
if "=" in tok:
|
|
237
|
-
idx += 1
|
|
238
|
-
continue
|
|
239
|
-
# Combined short-flag form: -XDELETE -- skip the whole token.
|
|
240
|
-
if low.startswith("-x") and len(low) > 2:
|
|
241
|
-
idx += 1
|
|
242
|
-
continue
|
|
243
|
-
# Space-separated value form: consume the next token as the value
|
|
244
|
-
# so it is not mistaken for the endpoint positional.
|
|
245
|
-
if low in value_taking and idx + 1 < len(items):
|
|
246
|
-
idx += 2
|
|
247
|
-
continue
|
|
248
|
-
idx += 1
|
|
249
|
-
return None
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
def _endpoint_is_repo_root(endpoint: str) -> bool:
|
|
253
|
-
"""True when the endpoint targets ``repos/<owner>/<repo>`` or a child.
|
|
254
|
-
|
|
255
|
-
Both ``repos/foo/bar`` and ``/repos/foo/bar/contents`` qualify --
|
|
256
|
-
DELETE on any repo-scoped resource is destructive enough that the
|
|
257
|
-
gate refuses it. The narrower case where DELETE on a label or comment
|
|
258
|
-
is legitimate can be escape-hatched via the env-var bypass; the
|
|
259
|
-
default-deny stance mirrors the #747 branch-gate disposition.
|
|
260
|
-
"""
|
|
261
|
-
normalised = endpoint.lstrip("/").lower()
|
|
262
|
-
return normalised.startswith("repos/")
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
def _is_force_push_default(tokens: list[str], default_branches: frozenset[str]) -> Verdict | None:
|
|
266
|
-
"""Detect ``git push --force[-with-lease]`` targeting the default branch.
|
|
267
|
-
|
|
268
|
-
The default-branch detection looks for ``<remote> <branch>`` form
|
|
269
|
-
(e.g. ``git push origin master --force``) as well as
|
|
270
|
-
``HEAD:<branch>`` refspecs and ``+<branch>`` "force" refspecs.
|
|
271
|
-
"""
|
|
272
|
-
if len(tokens) < 2:
|
|
273
|
-
return None
|
|
274
|
-
if tokens[0].lower() != "git" or tokens[1].lower() != "push":
|
|
275
|
-
return None
|
|
276
|
-
|
|
277
|
-
args = tokens[2:]
|
|
278
|
-
is_force = False
|
|
279
|
-
force_flag = ""
|
|
280
|
-
for tok in args:
|
|
281
|
-
low = tok.lower()
|
|
282
|
-
if low in {"-f", "--force"}:
|
|
283
|
-
is_force = True
|
|
284
|
-
force_flag = tok
|
|
285
|
-
break
|
|
286
|
-
if low.startswith("--force-with-lease"):
|
|
287
|
-
is_force = True
|
|
288
|
-
force_flag = tok
|
|
289
|
-
break
|
|
290
|
-
|
|
291
|
-
# ``+refspec`` is the "force" form of a refspec. It is destructive
|
|
292
|
-
# in the same way ``--force`` is, so we treat it as force as well.
|
|
293
|
-
plus_refspec_target: str | None = None
|
|
294
|
-
for tok in args:
|
|
295
|
-
if tok.startswith("+") and not tok.startswith("--"):
|
|
296
|
-
plus_refspec_target = tok[1:]
|
|
297
|
-
is_force = True
|
|
298
|
-
if not force_flag:
|
|
299
|
-
force_flag = "+<refspec>"
|
|
300
|
-
break
|
|
301
|
-
|
|
302
|
-
if not is_force:
|
|
303
|
-
return None
|
|
304
|
-
|
|
305
|
-
# Resolve target branch(es) referenced by the push.
|
|
306
|
-
targets = _resolve_push_targets(args, plus_refspec_target)
|
|
307
|
-
hit = next(
|
|
308
|
-
(b for b in targets if b.lower() in {x.lower() for x in default_branches}),
|
|
309
|
-
None,
|
|
310
|
-
)
|
|
311
|
-
if hit is None:
|
|
312
|
-
return None
|
|
313
|
-
|
|
314
|
-
return Verdict(
|
|
315
|
-
allowed=False,
|
|
316
|
-
category="force_push_default",
|
|
317
|
-
detail=f"git push {force_flag} -> default branch '{hit}'",
|
|
318
|
-
recovery=(
|
|
319
|
-
" Force-pushing the default branch overwrites shared history\n"
|
|
320
|
-
" and can lose commits. If this is genuinely necessary:\n"
|
|
321
|
-
f" • set the env-var bypass for this shell: {ENV_BYPASS}=1\n"
|
|
322
|
-
" • or push to a feature branch and open a PR.\n"
|
|
323
|
-
" See scm/github.md (## Destructive gh verbs (#1019))."
|
|
324
|
-
),
|
|
325
|
-
)
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
def _resolve_push_targets(args: list[str], plus_refspec: str | None) -> list[str]:
|
|
329
|
-
"""Extract the destination ref(s) named by a ``git push`` invocation.
|
|
330
|
-
|
|
331
|
-
Recognises three shapes:
|
|
332
|
-
|
|
333
|
-
- ``git push <remote> <branch> [...]`` -- positional branch arg
|
|
334
|
-
- ``git push <remote> HEAD:<branch>`` / ``<src>:<dst>`` refspecs
|
|
335
|
-
- ``git push <remote> +<src>:<dst>`` -- the explicit force refspec
|
|
336
|
-
"""
|
|
337
|
-
targets: list[str] = []
|
|
338
|
-
positional = [t for t in args if not t.startswith("-")]
|
|
339
|
-
if plus_refspec and plus_refspec not in positional:
|
|
340
|
-
positional.append(plus_refspec)
|
|
341
|
-
|
|
342
|
-
if len(positional) >= 2:
|
|
343
|
-
# positional[0] is the remote; positional[1:] are refspecs / branches.
|
|
344
|
-
for token in positional[1:]:
|
|
345
|
-
if token.startswith("+"):
|
|
346
|
-
token = token[1:]
|
|
347
|
-
if ":" in token:
|
|
348
|
-
# src:dst -> we want dst
|
|
349
|
-
_, dst = token.split(":", 1)
|
|
350
|
-
targets.append(dst.removeprefix("refs/heads/"))
|
|
351
|
-
else:
|
|
352
|
-
targets.append(token.removeprefix("refs/heads/"))
|
|
353
|
-
return targets
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
def _is_admin_merge(tokens: list[str]) -> Verdict | None:
|
|
357
|
-
"""Detect ``gh pr merge --admin`` (bypasses branch protection)."""
|
|
358
|
-
if len(tokens) < 3:
|
|
359
|
-
return None
|
|
360
|
-
if tokens[0].lower() not in {"gh", "ghx"}:
|
|
361
|
-
return None
|
|
362
|
-
if tokens[1].lower() != "pr" or tokens[2].lower() != "merge":
|
|
363
|
-
return None
|
|
364
|
-
if not any(tok.lower() == "--admin" for tok in tokens[3:]):
|
|
365
|
-
return None
|
|
366
|
-
return Verdict(
|
|
367
|
-
allowed=False,
|
|
368
|
-
category="admin_merge",
|
|
369
|
-
detail="gh pr merge --admin",
|
|
370
|
-
recovery=(
|
|
371
|
-
" --admin bypasses branch-protection required reviews.\n"
|
|
372
|
-
" If this is intentional (release-cycle hot-fix, agreed\n"
|
|
373
|
-
" rollback, etc.):\n"
|
|
374
|
-
f" • set the env-var bypass for this shell: {ENV_BYPASS}=1\n"
|
|
375
|
-
" Otherwise: request review through the normal flow."
|
|
376
|
-
),
|
|
377
|
-
)
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
def classify_command(
|
|
381
|
-
command: str,
|
|
382
|
-
*,
|
|
383
|
-
default_branches: frozenset[str] = DEFAULT_BRANCHES,
|
|
384
|
-
) -> Verdict:
|
|
385
|
-
"""Classify a candidate command string. Pure function (no env reads).
|
|
386
|
-
|
|
387
|
-
Callers that want env-var bypass semantics should consult
|
|
388
|
-
:func:`evaluate_command` (which wraps this) -- this primitive is
|
|
389
|
-
bypass-blind so unit tests can drive every classifier branch
|
|
390
|
-
without juggling environment state.
|
|
391
|
-
"""
|
|
392
|
-
tokens = _tokens_from_string(command)
|
|
393
|
-
if not tokens:
|
|
394
|
-
return _OK_VERDICT
|
|
395
|
-
|
|
396
|
-
for detector in (
|
|
397
|
-
_is_delete_repo,
|
|
398
|
-
_is_admin_merge,
|
|
399
|
-
):
|
|
400
|
-
verdict = detector(tokens)
|
|
401
|
-
if verdict is not None:
|
|
402
|
-
return verdict
|
|
403
|
-
|
|
404
|
-
force_push = _is_force_push_default(tokens, default_branches)
|
|
405
|
-
if force_push is not None:
|
|
406
|
-
return force_push
|
|
407
|
-
|
|
408
|
-
return _OK_VERDICT
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
def evaluate_command(
|
|
412
|
-
command: str,
|
|
413
|
-
*,
|
|
414
|
-
default_branches: frozenset[str] = DEFAULT_BRANCHES,
|
|
415
|
-
) -> tuple[int, str]:
|
|
416
|
-
"""Evaluate a candidate command and produce (exit_code, message).
|
|
417
|
-
|
|
418
|
-
Honours ``DEFT_ALLOW_DESTRUCTIVE_GH_VERBS`` as the env-var bypass:
|
|
419
|
-
when active, a destructive verdict is downgraded to exit 0 with a
|
|
420
|
-
visible "policy bypassed" message so the operator can audit the
|
|
421
|
-
bypass after the fact.
|
|
422
|
-
"""
|
|
423
|
-
if not command.strip():
|
|
424
|
-
return 2, (
|
|
425
|
-
"❌ deft destructive-gh-verb gate: empty command string passed.\n"
|
|
426
|
-
" Usage: preflight_gh.py --command \"<command>\""
|
|
427
|
-
)
|
|
428
|
-
|
|
429
|
-
verdict = classify_command(command, default_branches=default_branches)
|
|
430
|
-
if verdict.allowed:
|
|
431
|
-
return 0, (
|
|
432
|
-
f"✓ deft destructive-gh-verb gate: '{command}' is not "
|
|
433
|
-
"destructive -- proceeding."
|
|
434
|
-
)
|
|
435
|
-
|
|
436
|
-
if _env_bypass_active():
|
|
437
|
-
return 0, (
|
|
438
|
-
f"⚠ deft destructive-gh-verb gate: '{verdict.detail}' "
|
|
439
|
-
f"classified as {verdict.category}, but {ENV_BYPASS}=1 is "
|
|
440
|
-
"set -- policy bypassed for this session."
|
|
441
|
-
)
|
|
442
|
-
|
|
443
|
-
msg_lines = [
|
|
444
|
-
f"❌ deft destructive-gh-verb gate: refusing to execute "
|
|
445
|
-
f"({verdict.category}).",
|
|
446
|
-
"",
|
|
447
|
-
f" Detail: {verdict.detail}",
|
|
448
|
-
]
|
|
449
|
-
if verdict.recovery:
|
|
450
|
-
msg_lines.extend(["", verdict.recovery])
|
|
451
|
-
return 1, "\n".join(msg_lines)
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
# ---------------------------------------------------------------------------
|
|
455
|
-
# Pre-push hook mode -- parse stdin per-ref lines
|
|
456
|
-
# ---------------------------------------------------------------------------
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
def _parse_pre_push_stdin(stream) -> list[tuple[str, str, str, str]]:
|
|
460
|
-
"""Yield ``(local_ref, local_oid, remote_ref, remote_oid)`` tuples.
|
|
461
|
-
|
|
462
|
-
Tolerant of trailing whitespace and empty lines. Git's pre-push hook
|
|
463
|
-
feeds one such line per ref being pushed.
|
|
464
|
-
"""
|
|
465
|
-
out: list[tuple[str, str, str, str]] = []
|
|
466
|
-
for raw in stream:
|
|
467
|
-
line = raw.strip()
|
|
468
|
-
if not line:
|
|
469
|
-
continue
|
|
470
|
-
parts = line.split()
|
|
471
|
-
if len(parts) != 4:
|
|
472
|
-
continue
|
|
473
|
-
out.append((parts[0], parts[1], parts[2], parts[3]))
|
|
474
|
-
return out
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
_ZERO_OID_RE = re.compile(r"^0+$")
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
def _is_zero_oid(oid: str) -> bool:
|
|
481
|
-
return bool(_ZERO_OID_RE.match(oid))
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
def evaluate_pre_push(
|
|
485
|
-
refs: list[tuple[str, str, str, str]],
|
|
486
|
-
*,
|
|
487
|
-
default_branches: frozenset[str] = DEFAULT_BRANCHES,
|
|
488
|
-
) -> tuple[int, str]:
|
|
489
|
-
"""Evaluate the per-ref data from git pre-push stdin.
|
|
490
|
-
|
|
491
|
-
Refuses any push that touches the default branch (creation or
|
|
492
|
-
update). Deletion of the default branch is independently destructive
|
|
493
|
-
and is also refused. Non-default branches are out of scope -- a
|
|
494
|
-
force-push to a feature branch is a normal rebase workflow.
|
|
495
|
-
"""
|
|
496
|
-
if not refs:
|
|
497
|
-
return 0, (
|
|
498
|
-
"✓ deft destructive-gh-verb gate (pre-push): no refs in "
|
|
499
|
-
"stdin -- nothing to gate."
|
|
500
|
-
)
|
|
501
|
-
|
|
502
|
-
blocked: list[str] = []
|
|
503
|
-
for local_ref, local_oid, remote_ref, remote_oid in refs:
|
|
504
|
-
branch = remote_ref.removeprefix("refs/heads/")
|
|
505
|
-
if branch.lower() not in {b.lower() for b in default_branches}:
|
|
506
|
-
continue
|
|
507
|
-
# Touching the default branch from a hook context. The branch
|
|
508
|
-
# gate (#747) already refuses commits while on the default
|
|
509
|
-
# branch, but force-push from a feature branch targeting
|
|
510
|
-
# master is the case this gate exists to cover.
|
|
511
|
-
if _is_zero_oid(remote_oid):
|
|
512
|
-
blocked.append(f"create {branch} (local={local_ref})")
|
|
513
|
-
elif _is_zero_oid(local_oid):
|
|
514
|
-
# Deletion: local OID is zero (per-ref, not per-batch).
|
|
515
|
-
blocked.append(f"delete {branch}")
|
|
516
|
-
else:
|
|
517
|
-
blocked.append(f"update {branch} (local={local_ref})")
|
|
518
|
-
|
|
519
|
-
if not blocked:
|
|
520
|
-
return 0, (
|
|
521
|
-
"✓ deft destructive-gh-verb gate (pre-push): no pushes to "
|
|
522
|
-
"default branches detected -- proceeding."
|
|
523
|
-
)
|
|
524
|
-
|
|
525
|
-
if _env_bypass_active():
|
|
526
|
-
return 0, (
|
|
527
|
-
"⚠ deft destructive-gh-verb gate (pre-push): default-branch "
|
|
528
|
-
f"push detected ({'; '.join(blocked)}) but {ENV_BYPASS}=1 "
|
|
529
|
-
"is set -- policy bypassed for this session."
|
|
530
|
-
)
|
|
531
|
-
|
|
532
|
-
msg = (
|
|
533
|
-
"❌ deft destructive-gh-verb gate (pre-push): refusing to push "
|
|
534
|
-
"directly to the default branch.\n"
|
|
535
|
-
f" Detail: {'; '.join(blocked)}\n\n"
|
|
536
|
-
" How to proceed:\n"
|
|
537
|
-
" • push to a feature branch and open a PR\n"
|
|
538
|
-
f" • or set the env-var bypass for this shell: {ENV_BYPASS}=1\n"
|
|
539
|
-
" See scm/github.md (## Destructive gh verbs (#1019))."
|
|
540
|
-
)
|
|
541
|
-
return 1, msg
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
# ---------------------------------------------------------------------------
|
|
545
|
-
# Self-test mode -- aggregated into ``task verify:destructive-gh-verbs``
|
|
546
|
-
# ---------------------------------------------------------------------------
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
#: Built-in fixture table. ``expected_category=None`` means "allowed";
|
|
550
|
-
#: anything else is a destructive category we expect the classifier to
|
|
551
|
-
#: flag. Mutating this table is the load-bearing contract -- any change
|
|
552
|
-
#: should be paired with a regression test in ``tests/cli/test_preflight_gh.py``.
|
|
553
|
-
_SELF_TEST_CASES: tuple[tuple[str, str | None], ...] = (
|
|
554
|
-
# delete_repo positives
|
|
555
|
-
("gh repo delete deftai/directive", "delete_repo"),
|
|
556
|
-
("gh repo delete deftai/directive --yes", "delete_repo"),
|
|
557
|
-
("gh api -X DELETE repos/deftai/directive", "delete_repo"),
|
|
558
|
-
("gh api --method DELETE repos/deftai/directive/contents/README.md", "delete_repo"),
|
|
559
|
-
("gh api -XDELETE repos/deftai/directive", "delete_repo"),
|
|
560
|
-
# admin_merge positives
|
|
561
|
-
("gh pr merge 123 --admin", "admin_merge"),
|
|
562
|
-
("gh pr merge --admin --squash 123", "admin_merge"),
|
|
563
|
-
# force_push_default positives
|
|
564
|
-
("git push --force origin master", "force_push_default"),
|
|
565
|
-
("git push origin --force-with-lease main", "force_push_default"),
|
|
566
|
-
("git push origin +master", "force_push_default"),
|
|
567
|
-
("git push --force origin HEAD:master", "force_push_default"),
|
|
568
|
-
# Negatives -- benign commands MUST classify as allowed.
|
|
569
|
-
("gh pr merge 123 --squash", None),
|
|
570
|
-
("gh repo view deftai/directive", None),
|
|
571
|
-
("gh api repos/deftai/directive", None),
|
|
572
|
-
("gh api -X PATCH repos/deftai/directive/issues/1", None),
|
|
573
|
-
("git push origin feat/my-branch", None),
|
|
574
|
-
("git push --force origin feat/my-branch", None),
|
|
575
|
-
("git push --force-with-lease origin feat/my-branch", None),
|
|
576
|
-
("git push", None),
|
|
577
|
-
("gh pr create --title Test --body foo", None),
|
|
578
|
-
)
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
def run_self_test() -> tuple[int, str]:
|
|
582
|
-
"""Drive every fixture through the classifier; report disagreements.
|
|
583
|
-
|
|
584
|
-
Returns (exit_code, message). Exit code is 0 when every fixture
|
|
585
|
-
matches, 2 (config error) when any disagreement is found -- a
|
|
586
|
-
disagreement means the classifier table has drifted from the
|
|
587
|
-
contract and a real-world destructive verb might pass the gate.
|
|
588
|
-
"""
|
|
589
|
-
failures: list[str] = []
|
|
590
|
-
for command, expected in _SELF_TEST_CASES:
|
|
591
|
-
verdict = classify_command(command)
|
|
592
|
-
observed = None if verdict.allowed else verdict.category
|
|
593
|
-
if observed != expected:
|
|
594
|
-
failures.append(
|
|
595
|
-
f" ✗ {command!r} -- expected category={expected!r} but "
|
|
596
|
-
f"got category={observed!r} (detail={verdict.detail!r})"
|
|
597
|
-
)
|
|
598
|
-
|
|
599
|
-
if failures:
|
|
600
|
-
return 2, (
|
|
601
|
-
"❌ deft destructive-gh-verb gate (self-test): classifier "
|
|
602
|
-
f"disagreement on {len(failures)}/{len(_SELF_TEST_CASES)} "
|
|
603
|
-
"fixture(s).\n" + "\n".join(failures)
|
|
604
|
-
)
|
|
605
|
-
return 0, (
|
|
606
|
-
f"✓ deft destructive-gh-verb gate (self-test): "
|
|
607
|
-
f"{len(_SELF_TEST_CASES)}/{len(_SELF_TEST_CASES)} fixtures "
|
|
608
|
-
"classified as expected."
|
|
609
|
-
)
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
# ---------------------------------------------------------------------------
|
|
613
|
-
# CLI
|
|
614
|
-
# ---------------------------------------------------------------------------
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
def _build_parser() -> argparse.ArgumentParser:
|
|
618
|
-
parser = argparse.ArgumentParser(
|
|
619
|
-
prog="preflight_gh.py",
|
|
620
|
-
description=(
|
|
621
|
-
"Detection-bound gate for destructive gh verbs (#1019). "
|
|
622
|
-
"Classifies a candidate command string as one of "
|
|
623
|
-
"delete_repo / admin_merge / force_push_default, or allowed."
|
|
624
|
-
),
|
|
625
|
-
)
|
|
626
|
-
mode = parser.add_mutually_exclusive_group(required=False)
|
|
627
|
-
mode.add_argument(
|
|
628
|
-
"--command",
|
|
629
|
-
help="Classify a single candidate command string and exit.",
|
|
630
|
-
)
|
|
631
|
-
mode.add_argument(
|
|
632
|
-
"--pre-push-stdin",
|
|
633
|
-
action="store_true",
|
|
634
|
-
help=(
|
|
635
|
-
"Read git pre-push hook per-ref lines from stdin and refuse "
|
|
636
|
-
"pushes touching the default branch."
|
|
637
|
-
),
|
|
638
|
-
)
|
|
639
|
-
mode.add_argument(
|
|
640
|
-
"--self-test",
|
|
641
|
-
action="store_true",
|
|
642
|
-
help=(
|
|
643
|
-
"Run the built-in fixture table through the classifier and "
|
|
644
|
-
"exit 0 / 2. Used by `task verify:destructive-gh-verbs`."
|
|
645
|
-
),
|
|
646
|
-
)
|
|
647
|
-
parser.add_argument(
|
|
648
|
-
"--default-branch",
|
|
649
|
-
action="append",
|
|
650
|
-
default=None,
|
|
651
|
-
help=(
|
|
652
|
-
"Override the default-branch list. Pass multiple times to add. "
|
|
653
|
-
"Defaults to master + main."
|
|
654
|
-
),
|
|
655
|
-
)
|
|
656
|
-
parser.add_argument(
|
|
657
|
-
"--quiet",
|
|
658
|
-
action="store_true",
|
|
659
|
-
help="Suppress the OK message (errors still print).",
|
|
660
|
-
)
|
|
661
|
-
parser.add_argument(
|
|
662
|
-
"--project-root",
|
|
663
|
-
default=".",
|
|
664
|
-
help=argparse.SUPPRESS, # accepted for parity with preflight_branch.py
|
|
665
|
-
)
|
|
666
|
-
return parser
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
def main(argv: list[str] | None = None, *, stdin=None) -> int:
|
|
670
|
-
# #814: Force UTF-8 stdout/stderr at hook-script entry. Mirrors
|
|
671
|
-
# ``scripts/preflight_branch.main`` -- see that module for the
|
|
672
|
-
# rationale (Windows git-hook invocations default to cp1252 which
|
|
673
|
-
# has no glyph for U+2713 / U+274C / U+26A0).
|
|
674
|
-
if hasattr(sys.stdout, "reconfigure"):
|
|
675
|
-
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
676
|
-
if hasattr(sys.stderr, "reconfigure"):
|
|
677
|
-
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
|
678
|
-
|
|
679
|
-
parser = _build_parser()
|
|
680
|
-
args = parser.parse_args(argv)
|
|
681
|
-
default_branches = (
|
|
682
|
-
frozenset(args.default_branch) if args.default_branch else DEFAULT_BRANCHES
|
|
683
|
-
)
|
|
684
|
-
|
|
685
|
-
if args.self_test:
|
|
686
|
-
code, msg = run_self_test()
|
|
687
|
-
elif args.pre_push_stdin:
|
|
688
|
-
refs = _parse_pre_push_stdin(stdin or sys.stdin)
|
|
689
|
-
code, msg = evaluate_pre_push(refs, default_branches=default_branches)
|
|
690
|
-
elif args.command is not None:
|
|
691
|
-
code, msg = evaluate_command(args.command, default_branches=default_branches)
|
|
692
|
-
else:
|
|
693
|
-
parser.error("one of --command / --pre-push-stdin / --self-test required")
|
|
694
|
-
return 2 # unreachable; argparse exits via SystemExit(2)
|
|
695
|
-
|
|
696
|
-
if code == 0:
|
|
697
|
-
if not args.quiet:
|
|
698
|
-
print(msg)
|
|
699
|
-
else:
|
|
700
|
-
print(msg, file=sys.stderr)
|
|
701
|
-
return code
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
# Public API surface -- the names the test module imports.
|
|
705
|
-
__all__ = [
|
|
706
|
-
"DEFAULT_BRANCHES",
|
|
707
|
-
"ENV_BYPASS",
|
|
708
|
-
"Verdict",
|
|
709
|
-
"classify_command",
|
|
710
|
-
"evaluate_command",
|
|
711
|
-
"evaluate_pre_push",
|
|
712
|
-
"main",
|
|
713
|
-
"run_self_test",
|
|
714
|
-
]
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
if __name__ == "__main__":
|
|
718
|
-
# Pure path-mod for hook invocation: make ``scripts`` importable
|
|
719
|
-
# when this file is run directly (mirrors preflight_branch.py).
|
|
720
|
-
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
721
|
-
sys.exit(main())
|