claude-dev-env 2.7.1 → 2.8.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 (85) hide show
  1. package/_shared/CLAUDE.md +1 -0
  2. package/_shared/advisor/advisor-protocol.md +19 -9
  3. package/_shared/pr-loop/audit-contract.md +4 -4
  4. package/_shared/pr-loop/precatch-rubric.md +2 -2
  5. package/_shared/process-tree/CLAUDE.md +41 -0
  6. package/_shared/process-tree/scripts/config/process_tree_scripts_constants/__init__.py +1 -0
  7. package/_shared/process-tree/scripts/config/process_tree_scripts_constants/process_tree_kill_constants.py +27 -0
  8. package/_shared/process-tree/scripts/process_tree_kill.py +141 -0
  9. package/_shared/process-tree/scripts/pyproject.toml +16 -0
  10. package/_shared/process-tree/scripts/test_process_tree_kill.py +278 -0
  11. package/agents/code-quality-agent.md +6 -5
  12. package/agents/deep-research.md +7 -24
  13. package/agents/docs-agent.md +1 -27
  14. package/agents/issue-tracker.md +1 -7
  15. package/agents/skill-writer-agent.md +1 -2
  16. package/agents/test_agent_frontmatter.py +309 -12
  17. package/hooks/blocking/CLAUDE.md +2 -0
  18. package/hooks/blocking/fable_spawn_gate.py +187 -0
  19. package/hooks/blocking/piped_pytest_blocker.py +1223 -0
  20. package/hooks/blocking/plain_language_blocker.py +287 -15
  21. package/hooks/blocking/test_fable_spawn_gate.py +374 -0
  22. package/hooks/blocking/test_piped_pytest_blocker.py +587 -0
  23. package/hooks/blocking/test_plain_language_blocker.py +277 -2
  24. package/hooks/blocking/test_pre_tool_use_dispatcher.py +34 -2
  25. package/hooks/git-hooks/CLAUDE.md +2 -2
  26. package/hooks/git-hooks/git_hooks_constants/__init__.py +28 -0
  27. package/hooks/git-hooks/pre_push.py +343 -54
  28. package/hooks/git-hooks/test_pre_push.py +852 -6
  29. package/hooks/hooks.json +9 -19
  30. package/hooks/hooks_constants/CLAUDE.md +2 -0
  31. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +1 -0
  32. package/hooks/hooks_constants/fable_spawn_gate_constants.py +62 -0
  33. package/hooks/hooks_constants/piped_pytest_blocker_constants.py +360 -0
  34. package/hooks/hooks_constants/plain_language_blocker_constants.py +64 -1
  35. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +10 -0
  36. package/hooks/hooks_constants/shell_command_segments.py +1 -1
  37. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +1 -0
  38. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +27 -0
  39. package/hooks/hooks_constants/test_prose_metrics_parity.py +124 -0
  40. package/package.json +1 -1
  41. package/rules/CLAUDE.md +1 -0
  42. package/rules/ask-user-question-required.md +26 -0
  43. package/rules/claims-as-quotes.md +65 -0
  44. package/scripts/CLAUDE.md +1 -1
  45. package/scripts/_code_review_test_support.py +6 -0
  46. package/scripts/check.ps1 +18 -5
  47. package/scripts/claude_chain_runner.py +203 -31
  48. package/scripts/codec_forwarding_test_support.py +2 -0
  49. package/scripts/dev_env_scripts_constants/CLAUDE.md +4 -4
  50. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +38 -0
  51. package/scripts/dev_env_scripts_constants/code_review_constants.py +403 -2
  52. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +28 -24
  53. package/scripts/grok_headless_runner.py +16 -83
  54. package/scripts/spawn_grok_batch.py +38 -1
  55. package/scripts/test_claude_chain_runner.py +358 -0
  56. package/scripts/test_grok_headless_runner.py +18 -63
  57. package/scripts/test_invoke_code_review.py +298 -0
  58. package/scripts/test_resolve_worker_spawn.py +6 -0
  59. package/scripts/test_spawn_grok_batch.py +101 -0
  60. package/scripts/tests/CLAUDE.md +1 -0
  61. package/scripts/tests/test_grok_worker_constants.py +59 -0
  62. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +46 -0
  63. package/skills/autoconverge/workflow/converge.contract.test.mjs +105 -2
  64. package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +34 -1
  65. package/skills/autoconverge/workflow/converge.mjs +66 -33
  66. package/skills/codex-review/scripts/codex_review_scripts_constants/codex_usage_probe_constants.py +0 -4
  67. package/skills/codex-review/scripts/codex_usage_probe.py +20 -33
  68. package/skills/codex-review/scripts/run_codex_review.py +16 -64
  69. package/skills/codex-review/scripts/test_codex_usage_probe.py +46 -41
  70. package/skills/codex-review/scripts/test_run_codex_review.py +1 -33
  71. package/skills/e-code-review/SKILL.md +9 -8
  72. package/skills/e-code-review/reference/fix.md +29 -7
  73. package/skills/e-code-review/reference/loop.md +230 -14
  74. package/skills/e-code-review/reference/low.md +33 -15
  75. package/skills/e-code-review/reference/medium.md +55 -21
  76. package/skills/e-code-review/reference/xhigh.md +30 -12
  77. package/skills/fresh-branch/CLAUDE.md +5 -5
  78. package/skills/fresh-branch/SKILL.md +14 -6
  79. package/skills/fresh-branch/scripts/create_fresh_branch.py +122 -39
  80. package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +10 -3
  81. package/skills/fresh-branch/scripts/test_create_fresh_branch.py +251 -0
  82. package/skills/grok-spawn/SKILL.md +5 -0
  83. package/skills/orchestrator/SKILL.md +4 -1
  84. package/skills/orchestrator-refresh/SKILL.md +5 -1
  85. package/skills/team-advisor/SKILL.md +4 -1
@@ -15,20 +15,31 @@ branch is blocked before the gate runs. This catches a branch that tracks
15
15
  resolves to `main`. The guard runs whether or not the CODE_RULES gate is
16
16
  installed; deletions and same-name pushes pass.
17
17
 
18
- Gate base: the first non-zero remote-sha is used as the gate `--base`, so
19
- violations are scoped to commits that are not already on the remote. When
20
- every remote object name is zero (new branch) or stdin is empty, the gate
21
- falls back to the default branch of the remote git names in argv: its
22
- symbolic ref first, then the first candidate default branch that exists
23
- under that remote.
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 is unknown, so CODE_RULES enforcement is skipped with
26
+ a printed reason rather than scoped to the stdin remote object name, which on
27
+ a rebased tip would re-flag replayed commits.
28
+
29
+ Gate scope: the gate process this hook launches diffs its `--base` against the
30
+ current checkout's HEAD, so the surface is the commits between that base and
31
+ HEAD. A push whose pushed object is HEAD reads as the branch's own work; a
32
+ push of any other object is still scoped to HEAD.
24
33
 
25
34
  Exit codes:
26
35
  0 - the push destination is allowed and its commits pass the gate (or
27
- the gate is not installed).
36
+ the gate is not installed, or CODE_RULES is skipped because a
37
+ default-branch ref resolved and git still reported no merge base).
28
38
  1 - the push would land a non-protected local branch onto a protected
29
39
  remote branch, or a commit introduces a blocking violation.
30
- 2 - unexpected invocation failure (e.g., subprocess could not launch), or
31
- no gate base that git resolves.
40
+ 2 - unexpected invocation failure (e.g., subprocess could not launch),
41
+ stdin carried no parseable line, or a new-branch/empty-stdin base
42
+ could not be turned into a usable commit name.
32
43
  """
33
44
 
34
45
  from __future__ import annotations
@@ -38,9 +49,13 @@ import os
38
49
  import subprocess
39
50
  import sys
40
51
  from pathlib import Path
52
+ from typing import NamedTuple
41
53
 
42
- from gate_utils import is_safe_regular_file, resolve_gate_script_path
43
54
  from git_hooks_constants import (
55
+ ALL_DEFAULT_BRANCH_FALLBACK_REFERENCES,
56
+ ALL_GIT_MERGE_BASE_COMMAND_PREFIX,
57
+ ALL_GIT_SYMBOLIC_REFERENCE_COMMAND_PREFIX,
58
+ ALL_GIT_VERIFY_REFERENCE_COMMAND_PREFIX,
44
59
  ALL_PROTECTED_BRANCH_PUSH_NAMES,
45
60
  ALL_ZEROS_OBJECT_NAME_CHARACTER,
46
61
  BASE_REFERENCE_ARGUMENT,
@@ -57,6 +72,7 @@ from git_hooks_constants import (
57
72
  GIT_EXECUTABLE_NAME,
58
73
  GIT_OUTPUT_DECODE_ERRORS_POLICY,
59
74
  GIT_OUTPUT_ENCODING_NAME,
75
+ GIT_REFERENCE_QUERY_TIMEOUT_SECONDS,
60
76
  INVOKE_GATE_FAILURE_MESSAGE,
61
77
  LOCAL_BRANCH_REFERENCE_PREFIX,
62
78
  LOCAL_REFERENCE_FIELD_INDEX,
@@ -64,6 +80,8 @@ from git_hooks_constants import (
64
80
  MALFORMED_STDIN_LINE_MESSAGE,
65
81
  NO_PARSEABLE_STDIN_LINES_MESSAGE,
66
82
  NO_PARSEABLE_STDIN_LINES_SENTINEL,
83
+ ORIGIN_HEAD_SYMBOLIC_REFERENCE,
84
+ ORIGIN_REMOTE_TRACKING_REFERENCE_PREFIX,
67
85
  PRE_PUSH_GATE_SCRIPT_NOT_FOUND_MESSAGE,
68
86
  PROTECTED_BRANCH_PUSH_BLOCK_EXIT_CODE,
69
87
  PROTECTED_BRANCH_PUSH_BLOCK_MESSAGE,
@@ -71,7 +89,10 @@ from git_hooks_constants import (
71
89
  STDIN_LINE_FIELD_COUNT,
72
90
  STDIN_READ_FAILURE_MESSAGE,
73
91
  STDIN_REMOTE_OBJECT_FIELD_INDEX,
92
+ UNRESOLVABLE_MERGE_BASE_MESSAGE,
93
+ UNRESOLVABLE_MERGE_BASE_SENTINEL,
74
94
  )
95
+ from gate_utils import is_safe_regular_file, resolve_gate_script_path
75
96
  from pre_push_base_reference import (
76
97
  resolve_remote_name_from_arguments,
77
98
  resolve_usable_base_reference,
@@ -139,71 +160,331 @@ def is_all_zeros_object_name(object_name: str) -> bool:
139
160
  )
140
161
 
141
162
 
142
- def resolve_base_reference_from_stdin(stdin_text: str) -> str | None:
143
- stdin_line_field_count = STDIN_LINE_FIELD_COUNT
144
- stdin_remote_object_field_index = STDIN_REMOTE_OBJECT_FIELD_INDEX
145
- local_sha_field_index = LOCAL_SHA_FIELD_INDEX
146
- default_remote_base_reference = DEFAULT_REMOTE_BASE_REFERENCE
147
- malformed_stdin_line_message = MALFORMED_STDIN_LINE_MESSAGE
148
- has_seen_any_valid_line = False
149
- is_all_valid_lines_deletions = True
163
+ class PushLine(NamedTuple):
164
+ """One parsed ``<local-ref> <local-sha> <remote-ref> <remote-sha>`` stdin line."""
165
+
166
+ local_branch_name: str
167
+ local_object_name: str
168
+ remote_branch_name: str
169
+ remote_object_name: str
170
+
171
+
172
+ def parse_push_line(stripped_line: str) -> PushLine | None:
173
+ """Return the four fields of one push line, or None when the line is malformed.
174
+
175
+ Args:
176
+ stripped_line: One whitespace-stripped stdin line.
177
+
178
+ Returns:
179
+ The parsed line with both ref names reduced to branch names, or None
180
+ when the line carries fewer than the four fields git writes.
181
+ """
182
+ fields = stripped_line.split()
183
+ if len(fields) < STDIN_LINE_FIELD_COUNT:
184
+ return None
185
+ return PushLine(
186
+ local_branch_name=fields[LOCAL_REFERENCE_FIELD_INDEX].removeprefix(
187
+ LOCAL_BRANCH_REFERENCE_PREFIX
188
+ ),
189
+ local_object_name=fields[LOCAL_SHA_FIELD_INDEX],
190
+ remote_branch_name=fields[REMOTE_REFERENCE_FIELD_INDEX].removeprefix(
191
+ LOCAL_BRANCH_REFERENCE_PREFIX
192
+ ),
193
+ remote_object_name=fields[STDIN_REMOTE_OBJECT_FIELD_INDEX],
194
+ )
195
+
196
+
197
+ def is_branch_update(push_line: PushLine) -> bool:
198
+ """Report whether a push line moves a branch the remote already holds.
199
+
200
+ Args:
201
+ push_line: One parsed push line.
202
+
203
+ Returns:
204
+ True when the local and the remote object names are both non-zero.
205
+ """
206
+ if is_all_zeros_object_name(push_line.local_object_name):
207
+ return False
208
+ return not is_all_zeros_object_name(push_line.remote_object_name)
209
+
210
+
211
+ class ParsedPushStdin(NamedTuple):
212
+ """Every well-formed push line from one stdin payload, and whether text arrived."""
213
+
214
+ all_push_lines: tuple[PushLine, ...]
215
+ has_stdin_content: bool
216
+
217
+
218
+ def _parse_push_stdin(stdin_text: str) -> ParsedPushStdin:
219
+ """Parse the whole stdin payload once, warning on each malformed line.
220
+
221
+ Args:
222
+ stdin_text: The pre-push stdin payload.
223
+
224
+ Returns:
225
+ Every well-formed line in stdin order, paired with whether stdin
226
+ carried any non-blank text at all.
227
+ """
228
+ all_push_lines: list[PushLine] = []
150
229
  has_stdin_content = False
151
230
  for each_line in stdin_text.splitlines():
152
231
  stripped_line = each_line.strip()
153
232
  if not stripped_line:
154
233
  continue
155
234
  has_stdin_content = True
156
- fields = stripped_line.split()
157
- if len(fields) < stdin_line_field_count:
235
+ push_line = parse_push_line(stripped_line)
236
+ if push_line is None:
158
237
  print(
159
- malformed_stdin_line_message.format(line=stripped_line),
238
+ MALFORMED_STDIN_LINE_MESSAGE.format(line=stripped_line),
160
239
  file=sys.stderr,
161
240
  )
162
241
  continue
163
- has_seen_any_valid_line = True
164
- if is_all_zeros_object_name(fields[local_sha_field_index]):
242
+ all_push_lines.append(push_line)
243
+ return ParsedPushStdin(tuple(all_push_lines), has_stdin_content)
244
+
245
+
246
+ def _resolve_base_reference_from_lines(parsed_stdin: ParsedPushStdin) -> str | None:
247
+ """Return the stdin-derived base reference for already-parsed lines.
248
+
249
+ Args:
250
+ parsed_stdin: The parsed stdin payload.
251
+
252
+ Returns:
253
+ The first branch update's remote object name, the no-parseable-lines
254
+ sentinel, None for a deletion-only push, or the remote default branch
255
+ reference.
256
+ """
257
+ is_all_valid_lines_deletions = True
258
+ for each_push_line in parsed_stdin.all_push_lines:
259
+ if is_all_zeros_object_name(each_push_line.local_object_name):
165
260
  continue
166
261
  is_all_valid_lines_deletions = False
167
- remote_object_name = fields[stdin_remote_object_field_index]
168
- if not is_all_zeros_object_name(remote_object_name):
169
- return remote_object_name
170
- if has_stdin_content and not has_seen_any_valid_line:
262
+ if is_branch_update(each_push_line):
263
+ return each_push_line.remote_object_name
264
+ if parsed_stdin.has_stdin_content and not parsed_stdin.all_push_lines:
171
265
  return NO_PARSEABLE_STDIN_LINES_SENTINEL
172
- if has_seen_any_valid_line and is_all_valid_lines_deletions:
266
+ if parsed_stdin.all_push_lines and is_all_valid_lines_deletions:
173
267
  return None
174
- return default_remote_base_reference
268
+ return DEFAULT_REMOTE_BASE_REFERENCE
175
269
 
176
270
 
177
- def find_protected_branch_push_violation(stdin_text: str) -> tuple[str, str] | None:
178
- stdin_line_field_count = STDIN_LINE_FIELD_COUNT
179
- local_reference_field_index = LOCAL_REFERENCE_FIELD_INDEX
180
- local_sha_field_index = LOCAL_SHA_FIELD_INDEX
181
- remote_reference_field_index = REMOTE_REFERENCE_FIELD_INDEX
182
- local_branch_reference_prefix = LOCAL_BRANCH_REFERENCE_PREFIX
183
- protected_branch_push_names = ALL_PROTECTED_BRANCH_PUSH_NAMES
184
- for each_line in stdin_text.splitlines():
185
- stripped_line = each_line.strip()
186
- if not stripped_line:
187
- continue
188
- fields = stripped_line.split()
189
- if len(fields) < stdin_line_field_count:
190
- continue
191
- if is_all_zeros_object_name(fields[local_sha_field_index]):
192
- continue
193
- local_branch_name = fields[local_reference_field_index].removeprefix(
194
- local_branch_reference_prefix
271
+ def resolve_base_reference_from_stdin(stdin_text: str) -> str | None:
272
+ return _resolve_base_reference_from_lines(_parse_push_stdin(stdin_text))
273
+
274
+
275
+ def run_git_reference_query(all_git_arguments: tuple[str, ...]) -> str | None:
276
+ """Return the stripped stdout of a read-only git query.
277
+
278
+ Output decodes with the same replacing policy as ``run_git_text_command``,
279
+ so locale-invalid bytes become a marked string instead of crashing the hook.
280
+
281
+ Args:
282
+ all_git_arguments: The full git argv, including the ``git`` word.
283
+
284
+ Returns:
285
+ The stripped stdout, or None when git exits non-zero, prints nothing,
286
+ or cannot run.
287
+ """
288
+ try:
289
+ completion = subprocess.run(
290
+ list(all_git_arguments),
291
+ capture_output=True,
292
+ check=False,
293
+ timeout=GIT_REFERENCE_QUERY_TIMEOUT_SECONDS,
195
294
  )
196
- remote_branch_name = fields[remote_reference_field_index].removeprefix(
197
- local_branch_reference_prefix
295
+ except (OSError, subprocess.SubprocessError):
296
+ return None
297
+ if completion.returncode != 0:
298
+ return None
299
+ decoded_output = completion.stdout.decode(
300
+ GIT_OUTPUT_ENCODING_NAME, errors=GIT_OUTPUT_DECODE_ERRORS_POLICY
301
+ )
302
+ return decoded_output.strip() or None
303
+
304
+
305
+ def resolve_default_branch_reference() -> str | None:
306
+ """Return the remote-tracking ref name of the remote's default branch.
307
+
308
+ The remote's HEAD symbolic ref answers first. When it is unset, each
309
+ candidate default-branch remote-tracking ref answers in turn.
310
+
311
+ Resolution reads ``origin`` alone and never the remote name git passes as
312
+ argv[1], and its unset-``origin/HEAD`` fallback prefers ``origin/main``
313
+ over ``origin/master``, so a repo whose default is another branch beside a
314
+ legacy ``main`` resolves the wrong base ΓÇö the assumption set tracked at
315
+ ~/.claude/orchestrator-runs/falsify-first/parked-items.md row 3.
316
+
317
+ Returns:
318
+ A remote-tracking ref name, or None when no default-branch ref
319
+ resolves.
320
+ """
321
+ symbolic_reference = run_git_reference_query(
322
+ (*ALL_GIT_SYMBOLIC_REFERENCE_COMMAND_PREFIX, ORIGIN_HEAD_SYMBOLIC_REFERENCE)
323
+ )
324
+ if symbolic_reference is not None:
325
+ verified_symbolic_target = run_git_reference_query(
326
+ (*ALL_GIT_VERIFY_REFERENCE_COMMAND_PREFIX, symbolic_reference)
327
+ )
328
+ if verified_symbolic_target is not None:
329
+ return symbolic_reference
330
+ for each_candidate_reference in ALL_DEFAULT_BRANCH_FALLBACK_REFERENCES:
331
+ verified_reference = run_git_reference_query(
332
+ (*ALL_GIT_VERIFY_REFERENCE_COMMAND_PREFIX, each_candidate_reference)
333
+ )
334
+ if verified_reference is not None:
335
+ return each_candidate_reference
336
+ return None
337
+
338
+
339
+ def _find_branch_update_in_lines(
340
+ all_push_lines: tuple[PushLine, ...],
341
+ ) -> PushLine | None:
342
+ """Return the first already-parsed line that moves an existing remote branch.
343
+
344
+ Args:
345
+ all_push_lines: Well-formed push lines in stdin order.
346
+
347
+ Returns:
348
+ The first branch-update line, or None when no line updates a branch
349
+ the remote already holds.
350
+ """
351
+ for each_push_line in all_push_lines:
352
+ if is_branch_update(each_push_line):
353
+ return each_push_line
354
+ return None
355
+
356
+
357
+ def find_branch_update_push(stdin_text: str) -> PushLine | None:
358
+ """Return the first stdin line that moves a branch the remote already holds.
359
+
360
+ Args:
361
+ stdin_text: The pre-push stdin payload.
362
+
363
+ Returns:
364
+ The parsed branch-update line, or None when no line updates a branch
365
+ the remote already holds.
366
+ """
367
+ return _find_branch_update_in_lines(_parse_push_stdin(stdin_text).all_push_lines)
368
+
369
+
370
+ def resolve_default_branch_merge_base(
371
+ remote_branch_name: str, pushed_object_name: str
372
+ ) -> str | None:
373
+ """Return the merge base of the pushed object and the default branch.
374
+
375
+ The remote branch the push updates decides whether a merge base applies,
376
+ so ``git push origin release-candidate:develop`` reads as an update of
377
+ ``develop`` whatever the local branch is called.
378
+
379
+ Args:
380
+ remote_branch_name: The remote branch the push updates.
381
+ pushed_object_name: The object name the push carries.
382
+
383
+ Returns:
384
+ The merge-base object name; None when no default-branch ref resolves or
385
+ when the push updates the default branch, which are the two cases where
386
+ no merge base applies; or the unresolvable-merge-base sentinel when a
387
+ default-branch ref resolves and git still reports no merge base.
388
+ """
389
+ default_branch_reference = resolve_default_branch_reference()
390
+ if default_branch_reference is None:
391
+ return None
392
+ default_branch_name = default_branch_reference.removeprefix(
393
+ ORIGIN_REMOTE_TRACKING_REFERENCE_PREFIX
394
+ )
395
+ if remote_branch_name == default_branch_name:
396
+ return None
397
+ # Anchors to the pushed object; deferring to the gate's HEAD-based merge-base would change behavior on non-HEAD pushes.
398
+ merge_base_object_name = run_git_reference_query(
399
+ (
400
+ *ALL_GIT_MERGE_BASE_COMMAND_PREFIX,
401
+ pushed_object_name,
402
+ default_branch_reference,
198
403
  )
404
+ )
405
+ if merge_base_object_name is None:
406
+ return UNRESOLVABLE_MERGE_BASE_SENTINEL
407
+ return merge_base_object_name
408
+
409
+
410
+ def resolve_gate_base_reference(stdin_text: str) -> str | None:
411
+ """Return the reference the gate scopes its changed lines to.
412
+
413
+ A branch update reads from the merge base with the remote default branch,
414
+ and every other push reads from the stdin remote object name::
415
+
416
+ branch update, default branch resolved -> merge-base(pushed, default)
417
+ branch update, no merge base found -> unresolvable-merge-base sentinel
418
+ branch update, no default branch ref -> stdin remote object name
419
+ default-branch push -> stdin remote object name
420
+ new branch or empty stdin -> remote default branch ref
421
+ deletion -> None
422
+
423
+ Args:
424
+ stdin_text: The pre-push stdin payload.
425
+
426
+ Returns:
427
+ The gate's base reference, the no-parseable-lines sentinel, the
428
+ unresolvable-merge-base sentinel, or None when the push only deletes
429
+ remote branches.
430
+ """
431
+ return _resolve_gate_base_from_parsed(_parse_push_stdin(stdin_text))
432
+
433
+
434
+ def _resolve_gate_base_from_parsed(parsed_stdin: ParsedPushStdin) -> str | None:
435
+ """Return the gate's base reference for an already-parsed stdin payload.
436
+
437
+ Args:
438
+ parsed_stdin: The parsed stdin payload.
439
+
440
+ Returns:
441
+ The merge base with the remote default branch for a branch update, the
442
+ branch update's remote object name when no merge base applies, the
443
+ unresolvable-merge-base sentinel when a default-branch ref resolves and
444
+ git reports no merge base anyway, and otherwise whatever the
445
+ stdin-derived base reference reports.
446
+ """
447
+ branch_update = _find_branch_update_in_lines(parsed_stdin.all_push_lines)
448
+ if branch_update is None:
449
+ return _resolve_base_reference_from_lines(parsed_stdin)
450
+ merge_base_object_name = resolve_default_branch_merge_base(
451
+ branch_update.remote_branch_name, branch_update.local_object_name
452
+ )
453
+ if merge_base_object_name is None:
454
+ return branch_update.remote_object_name
455
+ return merge_base_object_name
456
+
457
+
458
+ def _find_protected_violation_in_lines(
459
+ all_push_lines: tuple[PushLine, ...],
460
+ ) -> tuple[str, str] | None:
461
+ """Return the first already-parsed line that pushes onto a protected branch.
462
+
463
+ Args:
464
+ all_push_lines: Well-formed push lines in stdin order.
465
+
466
+ Returns:
467
+ The local and protected remote branch names, or None when no line
468
+ lands a differently named branch on a protected one.
469
+ """
470
+ for each_push_line in all_push_lines:
471
+ if is_all_zeros_object_name(each_push_line.local_object_name):
472
+ continue
199
473
  if (
200
- remote_branch_name in protected_branch_push_names
201
- and local_branch_name != remote_branch_name
474
+ each_push_line.remote_branch_name in ALL_PROTECTED_BRANCH_PUSH_NAMES
475
+ and each_push_line.local_branch_name != each_push_line.remote_branch_name
202
476
  ):
203
- return (local_branch_name, remote_branch_name)
477
+ return (
478
+ each_push_line.local_branch_name,
479
+ each_push_line.remote_branch_name,
480
+ )
204
481
  return None
205
482
 
206
483
 
484
+ def find_protected_branch_push_violation(stdin_text: str) -> tuple[str, str] | None:
485
+ return _find_protected_violation_in_lines(_parse_push_stdin(stdin_text).all_push_lines)
486
+
487
+
207
488
  def invoke_gate(gate_script_path: Path, base_reference: str) -> int:
208
489
  base_reference_argument = BASE_REFERENCE_ARGUMENT
209
490
  invoke_gate_failure_message = INVOKE_GATE_FAILURE_MESSAGE
@@ -310,6 +591,8 @@ def main() -> int:
310
591
  pre_push_gate_script_not_found_message = PRE_PUSH_GATE_SCRIPT_NOT_FOUND_MESSAGE
311
592
  no_parseable_stdin_lines_message = NO_PARSEABLE_STDIN_LINES_MESSAGE
312
593
  no_parseable_stdin_lines_sentinel = NO_PARSEABLE_STDIN_LINES_SENTINEL
594
+ unresolvable_merge_base_message = UNRESOLVABLE_MERGE_BASE_MESSAGE
595
+ unresolvable_merge_base_sentinel = UNRESOLVABLE_MERGE_BASE_SENTINEL
313
596
  protected_branch_push_block_message = PROTECTED_BRANCH_PUSH_BLOCK_MESSAGE
314
597
  protected_branch_push_block_exit_code = PROTECTED_BRANCH_PUSH_BLOCK_EXIT_CODE
315
598
  try:
@@ -320,7 +603,10 @@ def main() -> int:
320
603
  file=sys.stderr,
321
604
  )
322
605
  return gate_infrastructure_failure_exit_code
323
- protected_branch_push_violation = find_protected_branch_push_violation(stdin_text)
606
+ parsed_stdin = _parse_push_stdin(stdin_text)
607
+ protected_branch_push_violation = _find_protected_violation_in_lines(
608
+ parsed_stdin.all_push_lines
609
+ )
324
610
  if protected_branch_push_violation is not None:
325
611
  local_branch_name, remote_branch_name = protected_branch_push_violation
326
612
  print(
@@ -338,12 +624,15 @@ def main() -> int:
338
624
  file=sys.stderr,
339
625
  )
340
626
  return code_review_stamp_block_exit_code()
341
- base_reference = resolve_base_reference_from_stdin(stdin_text)
627
+ base_reference = _resolve_gate_base_from_parsed(parsed_stdin)
342
628
  if base_reference is None:
343
629
  return 0
344
630
  if base_reference == no_parseable_stdin_lines_sentinel:
345
631
  print(no_parseable_stdin_lines_message, file=sys.stderr)
346
632
  return gate_infrastructure_failure_exit_code
633
+ if base_reference == unresolvable_merge_base_sentinel:
634
+ print(unresolvable_merge_base_message, file=sys.stderr)
635
+ return code_review_stamp_block_exit_code()
347
636
  remote_name = resolve_remote_name_from_arguments(sys.argv)
348
637
  try:
349
638
  usable_base_reference = resolve_usable_base_reference(