claude-dev-env 7.0.0 → 7.2.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.
Files changed (210) hide show
  1. package/.agents/skills/pull-request/scripts/pull_request.py +2 -0
  2. package/.agents/skills/pull-request/scripts/test_pull_request.py +34 -0
  3. package/.agents/skills/session-continuity/AGENTS.md +1 -0
  4. package/.agents/skills/session-continuity/CLAUDE.md +1 -0
  5. package/.agents/skills/session-continuity/README.md +213 -0
  6. package/.agents/skills/session-continuity/SKILL.md +125 -0
  7. package/.agents/skills/session-continuity/continuity.mjs +447 -0
  8. package/.agents/skills/session-continuity/continuity.test.mjs +373 -0
  9. package/.agents/skills/syncing-submodules/scripts/test_sync_parent_pointer.py +0 -7
  10. package/.agents/skills/windows-scheduled-task/SKILL.md +153 -0
  11. package/bin/cde.mjs +36 -8
  12. package/bin/cde.test.mjs +48 -0
  13. package/bin/ever-shipped-skills.mjs +2 -20
  14. package/bin/ever-shipped-skills.test.mjs +8 -0
  15. package/bin/expand_home_directory_tokens.mjs +4 -5
  16. package/bin/expand_home_directory_tokens.test.mjs +128 -0
  17. package/bin/git_hooks_installer.mjs +22 -3
  18. package/bin/git_hooks_installer.test.mjs +159 -5
  19. package/bin/install-session-continuity.mjs +88 -0
  20. package/bin/install-session-continuity.test.mjs +55 -0
  21. package/bin/install.codex-rules.test.mjs +5 -2
  22. package/bin/install.cursor-rules.test.mjs +5 -2
  23. package/bin/install.mjs +478 -63
  24. package/bin/install.policy-lint.test.mjs +56 -4
  25. package/bin/install.profiles.test.mjs +10 -0
  26. package/bin/install.prune.test.mjs +227 -17
  27. package/bin/install.test.mjs +500 -120
  28. package/bin/install.transaction.test.mjs +3 -3
  29. package/docs/CODE_RULES.md +1 -1
  30. package/hooks/blocking/code_rules_banned_identifiers.py +3 -0
  31. package/hooks/blocking/precommit_code_rules_gate.py +5 -8
  32. package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +7 -52
  33. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +0 -69
  34. package/hooks/blocking/test_code_rules_banned_identifiers.py +64 -0
  35. package/hooks/blocking/test_pre_tool_use_dispatcher.py +10 -244
  36. package/hooks/blocking/test_shared_stdin_adoption.py +1 -47
  37. package/hooks/git-hooks/git_hooks_constants/verification_notice_constants.py +116 -0
  38. package/hooks/git-hooks/post_commit.py +33 -0
  39. package/hooks/git-hooks/pre_commit.py +18 -90
  40. package/hooks/git-hooks/pre_push.py +45 -541
  41. package/hooks/git-hooks/test_installed_policy_lint.py +55 -6
  42. package/hooks/git-hooks/test_native_hook_support.py +129 -0
  43. package/hooks/git-hooks/test_native_policy_commit.py +39 -6
  44. package/hooks/git-hooks/test_post_commit.py +43 -0
  45. package/hooks/git-hooks/test_pre_commit.py +36 -392
  46. package/hooks/git-hooks/test_pre_push.py +48 -1631
  47. package/hooks/git-hooks/test_verification_notice.py +60 -0
  48. package/hooks/git-hooks/test_verification_start.py +173 -0
  49. package/hooks/git-hooks/verification_notice.py +192 -0
  50. package/hooks/git-hooks/verification_notice_context.py +253 -0
  51. package/hooks/git-hooks/verification_notice_state.py +237 -0
  52. package/hooks/git-hooks/verification_start.py +100 -0
  53. package/hooks/hooks.json +0 -119
  54. package/hooks/hooks_constants/banned_identifiers_constants.py +4 -0
  55. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +1 -15
  56. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +0 -4
  57. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +0 -50
  58. package/hooks/hooks_constants/stop_dispatcher_constants.py +1 -4
  59. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +3 -66
  60. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +42 -185
  61. package/hooks/hooks_constants/test_stop_dispatcher_constants.py +3 -25
  62. package/hooks/validation/test_post_tool_use_dispatcher.py +35 -298
  63. package/hooks/validators/run_all_validators.py +5 -1
  64. package/hooks/validators/security_checks.py +57 -2
  65. package/hooks/validators/test_hook_timing_harness.py +2 -5
  66. package/hooks/validators/test_run_all_validators_mutation_tool_parity.py +3 -11
  67. package/hooks/validators/test_run_all_validators_policy_lint.py +36 -0
  68. package/hooks/validators/test_security_checks.py +57 -0
  69. package/package.json +1 -1
  70. package/rules/confirm-implementation-forks.md +2 -0
  71. package/rules/git-workflow.md +12 -0
  72. package/rules/pstack-host-mapping.md +48 -0
  73. package/rules/vault-context.md +3 -5
  74. package/rules/verify-runtime-state.md +7 -0
  75. package/scripts/audit-hooks-cli.mjs +5 -0
  76. package/scripts/audit-hooks.mjs +72 -16
  77. package/scripts/automatic_advisory/__init__.py +1 -0
  78. package/scripts/automatic_advisory/checkout.py +51 -0
  79. package/scripts/automatic_advisory/cli.py +334 -0
  80. package/scripts/automatic_advisory/config/__init__.py +1 -0
  81. package/scripts/automatic_advisory/config/constants.py +93 -0
  82. package/scripts/automatic_advisory/config/timing.py +4 -0
  83. package/scripts/automatic_advisory/configuration.py +197 -0
  84. package/scripts/automatic_advisory/execution.py +350 -0
  85. package/scripts/automatic_advisory/git.py +69 -0
  86. package/scripts/automatic_advisory/model.py +107 -0
  87. package/scripts/automatic_advisory/process_host.py +32 -0
  88. package/scripts/automatic_advisory/publisher.py +84 -0
  89. package/scripts/automatic_advisory/runner.py +399 -0
  90. package/scripts/automatic_advisory/state.py +116 -0
  91. package/scripts/automatic_advisory/window_flags.py +41 -0
  92. package/scripts/automatic_advisory/windows_job.py +122 -0
  93. package/scripts/codex_compat_materializer.py +99 -13
  94. package/scripts/durable_post_lint.py +30 -2
  95. package/scripts/durable_post_lint_config/config/constants.py +10 -0
  96. package/scripts/durable_post_lint_config/config/test_constants.py +13 -0
  97. package/scripts/local_report_cli.py +184 -0
  98. package/scripts/local_report_core.py +247 -0
  99. package/scripts/local_report_publisher.py +12 -0
  100. package/scripts/local_report_validation.py +357 -0
  101. package/scripts/local_verification/__init__.py +23 -0
  102. package/scripts/local_verification/check_runner.py +364 -0
  103. package/scripts/local_verification/check_support.py +99 -0
  104. package/scripts/local_verification/cli.py +158 -0
  105. package/scripts/local_verification/command_runner.py +84 -0
  106. package/scripts/local_verification/config/__init__.py +1 -0
  107. package/scripts/local_verification/config/constants.py +89 -0
  108. package/scripts/local_verification/config/timing.py +1 -0
  109. package/scripts/local_verification/git_state.py +146 -0
  110. package/scripts/local_verification/manifest.py +224 -0
  111. package/scripts/local_verification/model.py +174 -0
  112. package/scripts/local_verification/runner.py +234 -0
  113. package/scripts/policy_lint/adapter_pairing.py +58 -3
  114. package/scripts/policy_lint/config/approved_test_pairs.py +323 -0
  115. package/scripts/pr_verification/__init__.py +1 -0
  116. package/scripts/pr_verification/branch_refs.py +114 -0
  117. package/scripts/pr_verification/config/__init__.py +1 -0
  118. package/scripts/pr_verification/config/constants.py +84 -0
  119. package/scripts/pr_verification/config/timing.py +5 -0
  120. package/scripts/pr_verification/github.py +137 -0
  121. package/scripts/pr_verification/github_api.py +376 -0
  122. package/scripts/pr_verification/github_labels.py +88 -0
  123. package/scripts/pr_verification/github_parsing.py +182 -0
  124. package/scripts/pr_verification/github_transport.py +91 -0
  125. package/scripts/pr_verification/lock.py +122 -0
  126. package/scripts/pr_verification/model.py +43 -0
  127. package/scripts/pr_verification/requirements.txt +1 -0
  128. package/scripts/test_durable_post_lint.py +97 -0
  129. package/scripts/tests/test-audit-hooks-cli.mjs +150 -0
  130. package/scripts/tests/test-audit-hooks.mjs +94 -2
  131. package/scripts/tests/test_automatic_advisory.py +376 -0
  132. package/scripts/tests/test_automatic_advisory_job_cleanup.py +71 -0
  133. package/scripts/tests/test_automatic_advisory_polling.py +216 -0
  134. package/scripts/tests/test_automatic_advisory_recovery.py +320 -0
  135. package/scripts/tests/test_automatic_advisory_repairs.py +400 -0
  136. package/scripts/tests/test_automatic_advisory_settings.py +48 -0
  137. package/scripts/tests/test_automatic_advisory_window_flags.py +163 -0
  138. package/scripts/tests/test_closed_pr_label.py +157 -0
  139. package/scripts/tests/test_codex_compat_materializer.py +189 -6
  140. package/scripts/tests/test_github_labels.py +101 -0
  141. package/scripts/tests/test_github_transport.py +125 -0
  142. package/scripts/tests/test_local_report_publisher.py +396 -0
  143. package/scripts/tests/test_local_report_publisher_cli.py +85 -0
  144. package/scripts/tests/test_local_verification.py +348 -0
  145. package/scripts/tests/test_local_verification_cli.py +98 -0
  146. package/scripts/tests/test_policy_lint_rules_pairing.py +163 -0
  147. package/scripts/tests/test_pr_verification_github.py +392 -0
  148. package/scripts/tests/test_pr_verification_lock.py +168 -0
  149. package/scripts/tests/test_verification_notice.py +347 -0
  150. package/hooks/blocking/ask_user_question_shape_blocker.py +0 -83
  151. package/hooks/blocking/bot_mention_comment_blocker.py +0 -76
  152. package/hooks/blocking/claude_md_orphan_file_blocker.py +0 -129
  153. package/hooks/blocking/cursor_cli_python_misfire_blocker.py +0 -108
  154. package/hooks/blocking/destructive_command_blocker.py +0 -2073
  155. package/hooks/blocking/fable_spawn_gate.py +0 -187
  156. package/hooks/blocking/luna_fast_mode_gate.py +0 -160
  157. package/hooks/blocking/nas_ssh_binary_enforcer.py +0 -163
  158. package/hooks/blocking/orchestrator_refresh_reschedule_gate.py +0 -260
  159. package/hooks/blocking/package_inventory_stale_blocker.py +0 -107
  160. package/hooks/blocking/pii_prevention_blocker.py +0 -365
  161. package/hooks/blocking/piped_pytest_blocker.py +0 -1223
  162. package/hooks/blocking/question_to_user_enforcer.py +0 -130
  163. package/hooks/blocking/send_user_file_open_locally_blocker.py +0 -71
  164. package/hooks/blocking/sensitive_file_protector.py +0 -167
  165. package/hooks/blocking/session_edit_stage_gate.py +0 -684
  166. package/hooks/blocking/session_handoff_blocker.py +0 -182
  167. package/hooks/blocking/shell_substitution_blocker.py +0 -350
  168. package/hooks/blocking/test_ask_user_question_shape.py +0 -69
  169. package/hooks/blocking/test_bot_mention_comment_blocker.py +0 -131
  170. package/hooks/blocking/test_claude_md_orphan_file_blocker.py +0 -1277
  171. package/hooks/blocking/test_cursor_cli_python_misfire_blocker.py +0 -208
  172. package/hooks/blocking/test_destructive_command_blocker.py +0 -2024
  173. package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +0 -105
  174. package/hooks/blocking/test_destructive_command_blocker_no_verify.py +0 -122
  175. package/hooks/blocking/test_destructive_command_blocker_patterns.py +0 -133
  176. package/hooks/blocking/test_fable_spawn_gate.py +0 -381
  177. package/hooks/blocking/test_inventory_deadlock_resolution.py +0 -154
  178. package/hooks/blocking/test_luna_fast_mode_gate.py +0 -211
  179. package/hooks/blocking/test_nas_ssh_binary_enforcer.py +0 -225
  180. package/hooks/blocking/test_orchestrator_refresh_reschedule_gate.py +0 -231
  181. package/hooks/blocking/test_package_inventory_stale_blocker.py +0 -382
  182. package/hooks/blocking/test_pii_prevention_blocker.py +0 -1222
  183. package/hooks/blocking/test_pii_prevention_windows_git_path.py +0 -41
  184. package/hooks/blocking/test_pii_write_surface_ephemeral.py +0 -221
  185. package/hooks/blocking/test_piped_pytest_blocker.py +0 -587
  186. package/hooks/blocking/test_precommit_code_rules_gate_native_owner.py +0 -233
  187. package/hooks/blocking/test_question_to_user_enforcer.py +0 -169
  188. package/hooks/blocking/test_send_user_file_open_locally_blocker.py +0 -118
  189. package/hooks/blocking/test_sensitive_file_protector.py +0 -365
  190. package/hooks/blocking/test_session_edit_stage_gate.py +0 -537
  191. package/hooks/blocking/test_session_edit_stage_gate_staging.py +0 -64
  192. package/hooks/blocking/test_session_edit_stage_gate_support.py +0 -204
  193. package/hooks/blocking/test_session_edit_stage_gate_tracker.py +0 -116
  194. package/hooks/blocking/test_session_handoff_blocker.py +0 -318
  195. package/hooks/blocking/test_shell_substitution_blocker.py +0 -298
  196. package/hooks/blocking/test_unscoped_search_blocker.py +0 -102
  197. package/hooks/blocking/test_write_existing_file_blocker.py +0 -122
  198. package/hooks/blocking/tests/test_pii_prevention_blocker.py +0 -259
  199. package/hooks/blocking/unscoped_search_blocker.py +0 -391
  200. package/hooks/blocking/write_existing_file_blocker.py +0 -124
  201. package/hooks/hooks_constants/bot_mention_comment_blocker_constants.py +0 -19
  202. package/hooks/hooks_constants/cursor_cli_python_misfire_blocker_constants.py +0 -79
  203. package/hooks/hooks_constants/fable_spawn_gate_constants.py +0 -61
  204. package/hooks/hooks_constants/luna_fast_mode_gate_constants.py +0 -45
  205. package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +0 -20
  206. package/hooks/hooks_constants/orchestrator_refresh_reschedule_gate_constants.py +0 -57
  207. package/hooks/hooks_constants/send_user_file_open_locally_blocker_constants.py +0 -17
  208. package/hooks/hooks_constants/sensitive_file_protector_constants.py +0 -40
  209. package/hooks/hooks_constants/session_handoff_blocker_constants.py +0 -10
  210. package/hooks/hooks_constants/shell_substitution_blocker_constants.py +0 -80
@@ -1,580 +1,84 @@
1
1
  #!/usr/bin/env python3
2
- """Git pre-push hook: guard the push destination, then run the CODE_RULES gate.
3
-
4
- Installed to the user's shared git-hooks directory via the claude-dev-env
5
- installer; git invokes this file as `pre-push` (the installer strips the
6
- `_` and `.py` suffix when copying into the live hooks path).
7
-
8
- Protocol: git pre-push provides remote name and URL as argv, then writes
9
- `<local-ref> <local-sha> <remote-ref> <remote-sha>` lines on stdin.
10
-
11
- Destination guard: any line that pushes a local branch onto a protected
12
- remote branch (`main` or `master`) whose name differs from the local
13
- branch is blocked before the gate runs. This catches a branch that tracks
14
- `origin/main` under `push.default=upstream`, where a bare `git push`
15
- resolves to `main`. The guard runs whether or not the CODE_RULES gate is
16
- installed; deletions and same-name pushes pass.
17
-
18
- Gate base: a branch push takes its `--base` from the merge base between the
19
- pushed object and the remote default branch, so a rebase onto the default
20
- branch leaves the gate reading only the branch's own work. The stdin remote
21
- object name is the base when no default-branch ref resolves and when the
22
- pushed branch is the default branch. When every remote object name is zero
23
- (new branch) or stdin is empty, the gate falls back to the remote's default
24
- branch symbolic ref. When a default-branch ref resolves and git still reports
25
- no merge base, the base stays pending and the hook reports the infrastructure
26
- status with a printed reason, preserving the scope boundary for rebased tips.
27
-
28
- Gate scope: the gate process this hook launches diffs its `--base` against the
29
- current checkout's HEAD, so the surface is the commits between that base and
30
- HEAD. A push whose pushed object is HEAD reads as the branch's own work; a
31
- push of any other object is still scoped to HEAD.
32
-
33
- Exit codes:
34
- 0 - the push destination is allowed and its commits pass the gate, or the
35
- gate is not installed.
36
- 1 - the push would land a non-protected local branch onto a protected
37
- remote branch, or a commit introduces a blocking violation.
38
- 2 - infrastructure attention is required because a subprocess could not
39
- launch, stdin carried no parseable line, a base could not be resolved,
40
- or a usable commit name could not be produced.
41
- """
2
+ """Show the local verification reminder before a native Git push."""
42
3
 
43
4
  from __future__ import annotations
44
5
 
45
- import subprocess
46
6
  import sys
47
7
  from pathlib import Path
48
- from typing import NamedTuple
49
8
 
50
- from gate_utils import is_safe_regular_file, resolve_gate_script_path
51
9
  from git_hooks_constants import (
52
- ALL_FALLBACK_REMOTE_DEFAULT_BRANCH_NAMES,
53
- ALL_GIT_MERGE_BASE_COMMAND_PREFIX,
54
- ALL_GIT_SYMBOLIC_REFERENCE_COMMAND_PREFIX,
55
- ALL_GIT_VERIFY_REFERENCE_COMMAND_PREFIX,
56
10
  ALL_PROTECTED_BRANCH_PUSH_NAMES,
57
11
  ALL_ZEROS_OBJECT_NAME_CHARACTER,
58
- BASE_REFERENCE_ARGUMENT,
59
- DEFAULT_REMOTE_BASE_REFERENCE,
60
- DEFAULT_REMOTE_NAME,
61
- GATE_INFRASTRUCTURE_FAILURE_EXIT_CODE,
62
- GIT_COMMAND_TIMEOUT_SECONDS,
63
- GIT_COMMAND_UNAVAILABLE_MESSAGE,
64
- GIT_EXECUTABLE_NAME,
65
- GIT_OUTPUT_DECODE_ERRORS_POLICY,
66
- GIT_OUTPUT_ENCODING_NAME,
67
- GIT_REFERENCE_QUERY_TIMEOUT_SECONDS,
68
- INVOKE_GATE_FAILURE_MESSAGE,
69
12
  LOCAL_BRANCH_REFERENCE_PREFIX,
70
13
  LOCAL_REFERENCE_FIELD_INDEX,
71
14
  LOCAL_SHA_FIELD_INDEX,
72
- MALFORMED_STDIN_LINE_MESSAGE,
73
- NO_PARSEABLE_STDIN_LINES_MESSAGE,
74
- NO_PARSEABLE_STDIN_LINES_SENTINEL,
75
- PRE_PUSH_GATE_SCRIPT_NOT_FOUND_MESSAGE,
76
- PROTECTED_BRANCH_PUSH_BLOCK_EXIT_CODE,
77
- PROTECTED_BRANCH_PUSH_BLOCK_MESSAGE,
78
- REMOTE_BRANCH_REFERENCE_TEMPLATE,
79
15
  REMOTE_REFERENCE_FIELD_INDEX,
80
16
  STDIN_LINE_FIELD_COUNT,
81
- STDIN_READ_FAILURE_MESSAGE,
82
- STDIN_REMOTE_OBJECT_FIELD_INDEX,
83
- UNRESOLVABLE_MERGE_BASE_MESSAGE,
84
- UNRESOLVABLE_MERGE_BASE_SENTINEL,
85
17
  )
86
- from pre_push_base_reference import (
87
- resolve_remote_name_from_arguments,
88
- resolve_usable_base_reference,
18
+ from git_hooks_constants.verification_notice_constants import (
19
+ NOTICE_EVENT_PUSH,
20
+ PROTECTED_BRANCH_PUSH_ADVISORY_MESSAGE,
21
+ REPOSITORY_ARGUMENT,
89
22
  )
90
- from pull_request_handoff import build_pull_request_reminder, get_pull_request_url
91
-
92
-
93
- def _report_unavailable_git(launch_error: Exception) -> int:
94
- """Report a git that would not run, and hand back the exit code to use."""
95
- git_command_unavailable_message = GIT_COMMAND_UNAVAILABLE_MESSAGE
96
- sys.stderr.write(git_command_unavailable_message.format(error=launch_error) + "\n")
97
- return GATE_INFRASTRUCTURE_FAILURE_EXIT_CODE
98
-
99
-
100
- class GitCommandUnavailable(RuntimeError):
101
- """Git itself could not run: it failed to launch, or it timed out.
102
-
103
- A git that runs and reports nothing is ordinary absence, which stays a
104
- plain return. Separating the two keeps a broken toolchain from reading
105
- as a repository that simply has no default branch.
106
- """
107
-
108
-
109
- def run_git_text_command(all_command_arguments: list[str]) -> tuple[int, str]:
110
- """Ask git a question and read back its answer.
111
-
112
- Output decodes with a replacing policy, so a reference whose bytes are
113
- invalid in this encoding becomes a marked string and matches nothing.
114
-
115
- Args:
116
- all_command_arguments: The git arguments following the executable name.
23
+ from verification_notice import main as verification_notice_main
117
24
 
118
- Returns:
119
- The exit code paired with the trimmed standard output.
120
-
121
- Raises:
122
- GitCommandUnavailable: Git failed to launch or exceeded its timeout.
123
- """
124
- git_executable_name = GIT_EXECUTABLE_NAME
125
- git_command_timeout_seconds = GIT_COMMAND_TIMEOUT_SECONDS
126
- try:
127
- completion = subprocess.run(
128
- [git_executable_name, *all_command_arguments],
129
- check=False,
130
- capture_output=True,
131
- timeout=git_command_timeout_seconds,
132
- )
133
- except (OSError, subprocess.SubprocessError) as launch_error:
134
- raise GitCommandUnavailable(str(launch_error)) from launch_error
135
- decoded_output = completion.stdout.decode(
136
- GIT_OUTPUT_ENCODING_NAME, errors=GIT_OUTPUT_DECODE_ERRORS_POLICY
137
- )
138
- return completion.returncode, decoded_output.strip()
139
-
140
-
141
- def is_all_zeros_object_name(object_name: str) -> bool:
142
- all_zeros_object_name_character = ALL_ZEROS_OBJECT_NAME_CHARACTER
143
- stripped_object_name = object_name.strip()
144
- if not stripped_object_name:
145
- return True
146
- return all(
147
- each_character == all_zeros_object_name_character for each_character in stripped_object_name
148
- )
149
-
150
-
151
- class PushLine(NamedTuple):
152
- """One parsed ``<local-ref> <local-sha> <remote-ref> <remote-sha>`` stdin line."""
153
-
154
- local_branch_name: str
155
- local_object_name: str
156
- remote_branch_name: str
157
- remote_object_name: str
158
-
159
-
160
- def parse_push_line(stripped_line: str) -> PushLine | None:
161
- """Return the four fields of one push line, or None when the line is malformed.
162
-
163
- Args:
164
- stripped_line: One whitespace-stripped stdin line.
165
-
166
- Returns:
167
- The parsed line with both ref names reduced to branch names, or None
168
- when the line carries fewer than the four fields git writes.
169
- """
170
- fields = stripped_line.split()
171
- if len(fields) < STDIN_LINE_FIELD_COUNT:
172
- return None
173
- return PushLine(
174
- local_branch_name=fields[LOCAL_REFERENCE_FIELD_INDEX].removeprefix(
175
- LOCAL_BRANCH_REFERENCE_PREFIX
176
- ),
177
- local_object_name=fields[LOCAL_SHA_FIELD_INDEX],
178
- remote_branch_name=fields[REMOTE_REFERENCE_FIELD_INDEX].removeprefix(
179
- LOCAL_BRANCH_REFERENCE_PREFIX
180
- ),
181
- remote_object_name=fields[STDIN_REMOTE_OBJECT_FIELD_INDEX],
182
- )
183
25
 
184
-
185
- def is_branch_update(push_line: PushLine) -> bool:
186
- """Report whether a push line moves a branch the remote already holds.
187
-
188
- Args:
189
- push_line: One parsed push line.
190
-
191
- Returns:
192
- True when the local and the remote object names are both non-zero.
193
- """
194
- if is_all_zeros_object_name(push_line.local_object_name):
195
- return False
196
- return not is_all_zeros_object_name(push_line.remote_object_name)
197
-
198
-
199
- class ParsedPushStdin(NamedTuple):
200
- """Every well-formed push line from one stdin payload, and whether text arrived."""
201
-
202
- all_push_lines: tuple[PushLine, ...]
203
- has_stdin_content: bool
204
-
205
-
206
- def _parse_push_stdin(stdin_text: str) -> ParsedPushStdin:
207
- """Parse the whole stdin payload once, warning on each malformed line.
208
-
209
- Args:
210
- stdin_text: The pre-push stdin payload.
211
-
212
- Returns:
213
- Every well-formed line in stdin order, paired with whether stdin
214
- carried any non-blank text at all.
215
- """
216
- all_push_lines: list[PushLine] = []
217
- has_stdin_content = False
26
+ def find_protected_branch_push_violation(
27
+ stdin_text: str,
28
+ ) -> tuple[str, str] | None:
218
29
  for each_line in stdin_text.splitlines():
219
- stripped_line = each_line.strip()
220
- if not stripped_line:
221
- continue
222
- has_stdin_content = True
223
- push_line = parse_push_line(stripped_line)
224
- if push_line is None:
225
- print(
226
- MALFORMED_STDIN_LINE_MESSAGE.format(line=stripped_line),
227
- file=sys.stderr,
228
- )
229
- continue
230
- all_push_lines.append(push_line)
231
- return ParsedPushStdin(tuple(all_push_lines), has_stdin_content)
232
-
233
-
234
- def _resolve_base_reference_from_lines(parsed_stdin: ParsedPushStdin) -> str | None:
235
- """Return the stdin-derived base reference for already-parsed lines.
236
-
237
- Args:
238
- parsed_stdin: The parsed stdin payload.
239
-
240
- Returns:
241
- The first branch update's remote object name, the no-parseable-lines
242
- sentinel, None for a deletion-only push, or the remote default branch
243
- reference.
244
- """
245
- is_all_valid_lines_deletions = True
246
- for each_push_line in parsed_stdin.all_push_lines:
247
- if is_all_zeros_object_name(each_push_line.local_object_name):
30
+ all_fields = each_line.split()
31
+ if len(all_fields) < STDIN_LINE_FIELD_COUNT:
248
32
  continue
249
- is_all_valid_lines_deletions = False
250
- if is_branch_update(each_push_line):
251
- return each_push_line.remote_object_name
252
- if parsed_stdin.has_stdin_content and not parsed_stdin.all_push_lines:
253
- return NO_PARSEABLE_STDIN_LINES_SENTINEL
254
- if parsed_stdin.all_push_lines and is_all_valid_lines_deletions:
255
- return None
256
- return DEFAULT_REMOTE_BASE_REFERENCE
257
-
258
-
259
- def resolve_base_reference_from_stdin(stdin_text: str) -> str | None:
260
- return _resolve_base_reference_from_lines(_parse_push_stdin(stdin_text))
261
-
262
-
263
- def run_git_reference_query(all_git_arguments: tuple[str, ...]) -> str | None:
264
- """Return the stripped stdout of a read-only git query.
265
-
266
- Output decodes with the same replacing policy as ``run_git_text_command``,
267
- so locale-invalid bytes become a marked string instead of crashing the hook.
268
-
269
- Args:
270
- all_git_arguments: The full git argv, including the ``git`` word.
271
-
272
- Returns:
273
- The stripped stdout, or None when git exits non-zero, prints nothing,
274
- or cannot run.
275
- """
276
- try:
277
- completion = subprocess.run(
278
- list(all_git_arguments),
279
- capture_output=True,
280
- check=False,
281
- timeout=GIT_REFERENCE_QUERY_TIMEOUT_SECONDS,
282
- )
283
- except (OSError, subprocess.SubprocessError):
284
- return None
285
- if completion.returncode != 0:
286
- return None
287
- decoded_output = completion.stdout.decode(
288
- GIT_OUTPUT_ENCODING_NAME, errors=GIT_OUTPUT_DECODE_ERRORS_POLICY
289
- )
290
- return decoded_output.strip() or None
291
-
292
-
293
- def resolve_default_branch_reference(remote_name: str = DEFAULT_REMOTE_NAME) -> str | None:
294
- """Return the remote-tracking ref name of the remote's default branch.
295
-
296
- The remote's HEAD symbolic ref answers first. When it is unset, the
297
- supported default-branch tracking refs answer in turn.
298
-
299
- Args:
300
- remote_name: Remote whose default branch provides the gate base.
301
-
302
- Returns:
303
- A remote-tracking ref name, or None when no default-branch ref
304
- resolves.
305
- """
306
- remote_head_reference = REMOTE_BRANCH_REFERENCE_TEMPLATE.format(
307
- remote=remote_name, branch="HEAD"
308
- )
309
- symbolic_reference = run_git_reference_query(
310
- (*ALL_GIT_SYMBOLIC_REFERENCE_COMMAND_PREFIX, remote_head_reference)
311
- )
312
- if symbolic_reference is not None:
313
- verified_symbolic_target = run_git_reference_query(
314
- (*ALL_GIT_VERIFY_REFERENCE_COMMAND_PREFIX, symbolic_reference)
315
- )
316
- if verified_symbolic_target is not None:
317
- return symbolic_reference
318
- for each_branch_name in ALL_FALLBACK_REMOTE_DEFAULT_BRANCH_NAMES:
319
- each_candidate_reference = REMOTE_BRANCH_REFERENCE_TEMPLATE.format(
320
- remote=remote_name, branch=each_branch_name
321
- )
322
- verified_reference = run_git_reference_query(
323
- (*ALL_GIT_VERIFY_REFERENCE_COMMAND_PREFIX, each_candidate_reference)
33
+ local_object_name = all_fields[LOCAL_SHA_FIELD_INDEX]
34
+ local_branch_name = all_fields[LOCAL_REFERENCE_FIELD_INDEX].removeprefix(
35
+ LOCAL_BRANCH_REFERENCE_PREFIX
324
36
  )
325
- if verified_reference is not None:
326
- return each_candidate_reference
327
- return None
328
-
329
-
330
- def _find_branch_update_in_lines(
331
- all_push_lines: tuple[PushLine, ...],
332
- ) -> PushLine | None:
333
- """Return the first already-parsed line that moves an existing remote branch.
334
-
335
- Args:
336
- all_push_lines: Well-formed push lines in stdin order.
337
-
338
- Returns:
339
- The first branch-update line, or None when no line updates a branch
340
- the remote already holds.
341
- """
342
- for each_push_line in all_push_lines:
343
- if is_branch_update(each_push_line):
344
- return each_push_line
345
- return None
346
-
347
-
348
- def find_branch_update_push(stdin_text: str) -> PushLine | None:
349
- """Return the first stdin line that moves a branch the remote already holds.
350
-
351
- Args:
352
- stdin_text: The pre-push stdin payload.
353
-
354
- Returns:
355
- The parsed branch-update line, or None when no line updates a branch
356
- the remote already holds.
357
- """
358
- return _find_branch_update_in_lines(_parse_push_stdin(stdin_text).all_push_lines)
359
-
360
-
361
- def resolve_default_branch_merge_base(
362
- remote_branch_name: str,
363
- pushed_object_name: str,
364
- remote_name: str = DEFAULT_REMOTE_NAME,
365
- ) -> str | None:
366
- """Return the merge base of the pushed object and the default branch.
367
-
368
- The remote branch the push updates decides whether a merge base applies,
369
- so ``git push origin release-candidate:develop`` reads as an update of
370
- ``develop`` whatever the local branch is called.
371
-
372
- Args:
373
- remote_branch_name: The remote branch the push updates.
374
- pushed_object_name: The object name the push carries.
375
- remote_name: The remote whose default branch provides the gate base.
376
-
377
- Returns:
378
- The merge-base object name; None when no default-branch ref resolves or
379
- when the push updates the default branch, which are the two cases where
380
- no merge base applies; or the unresolvable-merge-base sentinel when a
381
- default-branch ref resolves and git still reports no merge base.
382
- """
383
- default_branch_reference = resolve_default_branch_reference(remote_name)
384
- if default_branch_reference is None:
385
- return None
386
- default_branch_prefix = REMOTE_BRANCH_REFERENCE_TEMPLATE.format(remote=remote_name, branch="")
387
- default_branch_name = default_branch_reference.removeprefix(default_branch_prefix)
388
- if remote_branch_name == default_branch_name:
389
- return None
390
- # Anchors to the pushed object; deferring to the gate's HEAD-based merge-base would change behavior on non-HEAD pushes.
391
- merge_base_object_name = run_git_reference_query(
392
- (
393
- *ALL_GIT_MERGE_BASE_COMMAND_PREFIX,
394
- pushed_object_name,
395
- default_branch_reference,
37
+ remote_branch_name = all_fields[REMOTE_REFERENCE_FIELD_INDEX].removeprefix(
38
+ LOCAL_BRANCH_REFERENCE_PREFIX
396
39
  )
397
- )
398
- if merge_base_object_name is None:
399
- return UNRESOLVABLE_MERGE_BASE_SENTINEL
400
- return merge_base_object_name
401
-
402
-
403
- def resolve_gate_base_reference(
404
- stdin_text: str, remote_name: str = DEFAULT_REMOTE_NAME
405
- ) -> str | None:
406
- """Return the reference the gate scopes its changed lines to.
407
-
408
- A branch update reads from the merge base with the remote default branch,
409
- and every other push reads from the stdin remote object name::
410
-
411
- branch update, default branch resolved -> merge-base(pushed, default)
412
- branch update, no merge base found -> unresolvable-merge-base sentinel
413
- branch update, no default branch ref -> stdin remote object name
414
- default-branch push -> stdin remote object name
415
- new branch or empty stdin -> remote default branch ref
416
- deletion -> None
417
-
418
- Args:
419
- stdin_text: The pre-push stdin payload.
420
- remote_name: The remote whose default branch provides the gate base.
421
-
422
- Returns:
423
- The gate's base reference, the no-parseable-lines sentinel, the
424
- unresolvable-merge-base sentinel, or None when the push only deletes
425
- remote branches.
426
- """
427
- return _resolve_gate_base_from_parsed(_parse_push_stdin(stdin_text), remote_name)
428
-
429
-
430
- def _resolve_gate_base_from_parsed(
431
- parsed_stdin: ParsedPushStdin, remote_name: str = DEFAULT_REMOTE_NAME
432
- ) -> str | None:
433
- """Return the gate's base reference for an already-parsed stdin payload.
434
-
435
- Args:
436
- parsed_stdin: The parsed stdin payload.
437
- remote_name: The remote whose default branch provides the gate base.
438
-
439
- Returns:
440
- The merge base with the remote default branch for a branch update, the
441
- branch update's remote object name when no merge base applies, the
442
- unresolvable-merge-base sentinel when a default-branch ref resolves and
443
- git reports no merge base anyway, and otherwise whatever the
444
- stdin-derived base reference reports.
445
- """
446
- branch_update = _find_branch_update_in_lines(parsed_stdin.all_push_lines)
447
- if branch_update is None:
448
- return _resolve_base_reference_from_lines(parsed_stdin)
449
- merge_base_object_name = resolve_default_branch_merge_base(
450
- branch_update.remote_branch_name,
451
- branch_update.local_object_name,
452
- remote_name,
453
- )
454
- if merge_base_object_name is None:
455
- return branch_update.remote_object_name
456
- return merge_base_object_name
457
-
458
-
459
- def _find_protected_violation_in_lines(
460
- all_push_lines: tuple[PushLine, ...],
461
- ) -> tuple[str, str] | None:
462
- """Return the first already-parsed line that pushes onto a protected branch.
463
-
464
- Args:
465
- all_push_lines: Well-formed push lines in stdin order.
466
-
467
- Returns:
468
- The local and protected remote branch names, or None when no line
469
- lands a differently named branch on a protected one.
470
- """
471
- for each_push_line in all_push_lines:
472
- if is_all_zeros_object_name(each_push_line.local_object_name):
40
+ if not local_object_name.strip(ALL_ZEROS_OBJECT_NAME_CHARACTER):
473
41
  continue
474
42
  if (
475
- each_push_line.remote_branch_name in ALL_PROTECTED_BRANCH_PUSH_NAMES
476
- and each_push_line.local_branch_name != each_push_line.remote_branch_name
43
+ remote_branch_name in ALL_PROTECTED_BRANCH_PUSH_NAMES
44
+ and local_branch_name != remote_branch_name
477
45
  ):
478
- return (
479
- each_push_line.local_branch_name,
480
- each_push_line.remote_branch_name,
481
- )
46
+ return local_branch_name, remote_branch_name
482
47
  return None
483
48
 
484
49
 
485
- def find_protected_branch_push_violation(stdin_text: str) -> tuple[str, str] | None:
486
- return _find_protected_violation_in_lines(_parse_push_stdin(stdin_text).all_push_lines)
487
-
488
-
489
- def invoke_gate(gate_script_path: Path, base_reference: str) -> int:
490
- base_reference_argument = BASE_REFERENCE_ARGUMENT
491
- invoke_gate_failure_message = INVOKE_GATE_FAILURE_MESSAGE
492
- gate_infrastructure_failure_exit_code = GATE_INFRASTRUCTURE_FAILURE_EXIT_CODE
493
- try:
494
- resolved_gate_path = gate_script_path.resolve(strict=True)
495
- completion = subprocess.run(
496
- [
497
- sys.executable,
498
- str(resolved_gate_path),
499
- base_reference_argument,
500
- base_reference,
501
- ],
502
- check=False,
503
- )
504
- except OSError as launch_error:
505
- print(
506
- invoke_gate_failure_message.format(error=launch_error),
507
- file=sys.stderr,
508
- )
509
- return gate_infrastructure_failure_exit_code
510
- return completion.returncode
511
-
512
-
513
50
  def main() -> int:
514
- stdin_read_failure_message = STDIN_READ_FAILURE_MESSAGE
515
- gate_infrastructure_failure_exit_code = GATE_INFRASTRUCTURE_FAILURE_EXIT_CODE
516
- pre_push_gate_script_not_found_message = PRE_PUSH_GATE_SCRIPT_NOT_FOUND_MESSAGE
517
- no_parseable_stdin_lines_message = NO_PARSEABLE_STDIN_LINES_MESSAGE
518
- no_parseable_stdin_lines_sentinel = NO_PARSEABLE_STDIN_LINES_SENTINEL
519
- unresolvable_merge_base_message = UNRESOLVABLE_MERGE_BASE_MESSAGE
520
- unresolvable_merge_base_sentinel = UNRESOLVABLE_MERGE_BASE_SENTINEL
521
- protected_branch_push_block_message = PROTECTED_BRANCH_PUSH_BLOCK_MESSAGE
522
- protected_branch_push_block_exit_code = PROTECTED_BRANCH_PUSH_BLOCK_EXIT_CODE
523
51
  try:
524
52
  stdin_text = sys.stdin.read()
525
- except OSError as read_error:
526
- print(
527
- stdin_read_failure_message.format(error=read_error),
528
- file=sys.stderr,
529
- )
530
- return gate_infrastructure_failure_exit_code
531
- parsed_stdin = _parse_push_stdin(stdin_text)
532
- protected_branch_push_violation = _find_protected_violation_in_lines(
533
- parsed_stdin.all_push_lines
534
- )
535
- if protected_branch_push_violation is not None:
536
- local_branch_name, remote_branch_name = protected_branch_push_violation
537
- print(
538
- protected_branch_push_block_message.format(
539
- local_branch=local_branch_name,
540
- remote_branch=remote_branch_name,
541
- ),
542
- file=sys.stderr,
543
- )
544
- return protected_branch_push_block_exit_code
545
- gate_script_path, exact_allowed_path = resolve_gate_script_path()
546
- if not is_safe_regular_file(gate_script_path, exact_allowed_path):
547
- print(
548
- pre_push_gate_script_not_found_message.format(path=gate_script_path),
549
- file=sys.stderr,
53
+ branch_violation = find_protected_branch_push_violation(stdin_text)
54
+ if branch_violation is not None:
55
+ local_branch_name, remote_branch_name = branch_violation
56
+ _write_advisory(
57
+ PROTECTED_BRANCH_PUSH_ADVISORY_MESSAGE.format(
58
+ local_branch=local_branch_name,
59
+ remote_branch=remote_branch_name,
60
+ )
61
+ )
62
+ verification_notice_main(
63
+ [
64
+ "--event",
65
+ NOTICE_EVENT_PUSH,
66
+ REPOSITORY_ARGUMENT,
67
+ str(Path.cwd()),
68
+ ],
69
+ stdout=sys.stdout,
550
70
  )
551
71
  return 0
552
- remote_name = resolve_remote_name_from_arguments(sys.argv)
553
- base_reference = _resolve_gate_base_from_parsed(parsed_stdin, remote_name)
554
- if base_reference is None:
72
+ except (OSError, UnicodeError, ValueError):
555
73
  return 0
556
- if base_reference == no_parseable_stdin_lines_sentinel:
557
- print(no_parseable_stdin_lines_message, file=sys.stderr)
558
- return gate_infrastructure_failure_exit_code
559
- if base_reference == unresolvable_merge_base_sentinel:
560
- print(unresolvable_merge_base_message, file=sys.stderr)
561
- return gate_infrastructure_failure_exit_code
74
+
75
+
76
+ def _write_advisory(advisory_text: str) -> None:
562
77
  try:
563
- usable_base_reference = resolve_usable_base_reference(
564
- base_reference, remote_name, run_git_text_command
565
- )
566
- except GitCommandUnavailable as unavailable_error:
567
- return _report_unavailable_git(unavailable_error)
568
- if usable_base_reference is None:
569
- return gate_infrastructure_failure_exit_code
570
- code_rules_exit_code = invoke_gate(gate_script_path, usable_base_reference)
571
- if code_rules_exit_code != 0:
572
- return code_rules_exit_code
573
- pull_request_url = get_pull_request_url(Path.cwd())
574
- if pull_request_url:
575
- print(build_pull_request_reminder(pull_request_url))
576
- return 0
78
+ sys.stderr.write(advisory_text + "\n")
79
+ except UnicodeError:
80
+ return
577
81
 
578
82
 
579
83
  if __name__ == "__main__":
580
- sys.exit(main())
84
+ raise SystemExit(main())