claude-dev-env 2.5.0 → 2.7.1

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 (153) hide show
  1. package/CLAUDE.md +20 -57
  2. package/_shared/pr-loop/scripts/code_rules_gate.py +2 -1
  3. package/_shared/pr-loop/scripts/code_rules_gate_parts/CLAUDE.md +12 -2
  4. package/_shared/pr-loop/scripts/code_rules_gate_parts/baseline_import_isolation.py +309 -0
  5. package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_regression.py +540 -0
  6. package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_running.py +206 -70
  7. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/__init__.py +1 -0
  8. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/_repo_test_helpers.py +76 -0
  9. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_baseline_import_isolation.py +248 -0
  10. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_regression.py +309 -0
  11. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_running.py +91 -58
  12. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +202 -0
  13. package/_shared/pr-loop/worker-spawn.md +3 -1
  14. package/agents/CLAUDE.md +1 -1
  15. package/agents/code-verifier.md +36 -7
  16. package/bin/codex-compat.mjs +104 -0
  17. package/bin/codex-compat.test.mjs +51 -0
  18. package/codex-capability-map.json +13 -0
  19. package/docs/CODE_RULES.md +2 -0
  20. package/docs/codex-compatibility.md +25 -0
  21. package/docs/nas-ssh-invocation.md +96 -12
  22. package/docs/references/code-review-enforcement.md +31 -6
  23. package/hooks/blocking/CLAUDE.md +3 -0
  24. package/hooks/blocking/config/code_review_enforcement_constants.py +40 -10
  25. package/hooks/blocking/config/test_code_review_enforcement_constants.py +56 -3
  26. package/hooks/blocking/eli11_reply_enforcer.py +479 -0
  27. package/hooks/blocking/gh_body_arg_blocker.py +1 -1
  28. package/hooks/blocking/nas_ssh_binary_enforcer.py +8 -46
  29. package/hooks/blocking/shell_substitution_blocker.py +129 -0
  30. package/hooks/blocking/state_description_blocker.py +1 -1
  31. package/hooks/blocking/stop_dispatcher.py +1 -1
  32. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +2 -3
  33. package/hooks/blocking/test_eli11_reply_enforcer.py +457 -0
  34. package/hooks/blocking/test_shell_substitution_blocker.py +124 -0
  35. package/hooks/blocking/test_stop_dispatcher.py +23 -0
  36. package/hooks/blocking/test_unscoped_search_blocker.py +102 -0
  37. package/hooks/blocking/test_verdict_directory_write_blocker.py +4 -8
  38. package/hooks/blocking/unscoped_search_blocker.py +391 -0
  39. package/hooks/git-hooks/CLAUDE.md +3 -0
  40. package/hooks/git-hooks/conftest.py +30 -0
  41. package/hooks/git-hooks/gate_utils.py +2 -2
  42. package/hooks/git-hooks/git_hooks_constants/__init__.py +41 -2
  43. package/hooks/git-hooks/pre_push.py +75 -4
  44. package/hooks/git-hooks/pre_push_base_reference.py +166 -0
  45. package/hooks/git-hooks/test_config.py +0 -15
  46. package/hooks/git-hooks/test_gate_utils.py +3 -15
  47. package/hooks/git-hooks/test_pre_commit.py +1 -15
  48. package/hooks/git-hooks/test_pre_push.py +236 -27
  49. package/hooks/git-hooks/test_pre_push_base_reference.py +339 -0
  50. package/hooks/hooks.json +0 -12
  51. package/hooks/hooks_constants/CLAUDE.md +5 -1
  52. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +4 -4
  53. package/hooks/hooks_constants/eli11_reply_enforcer_constants.py +101 -0
  54. package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +2 -8
  55. package/hooks/hooks_constants/shell_command_segments.py +82 -0
  56. package/hooks/hooks_constants/shell_substitution_blocker_constants.py +67 -0
  57. package/hooks/hooks_constants/stop_dispatcher_constants.py +1 -0
  58. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +5 -6
  59. package/hooks/hooks_constants/test_stop_dispatcher_constants.py +1 -0
  60. package/hooks/hooks_constants/unscoped_search_blocker_constants.py +153 -0
  61. package/package.json +4 -2
  62. package/rules/CLAUDE.md +17 -23
  63. package/rules/agent-spawn-protocol.md +6 -6
  64. package/rules/anti-corollary-tests.md +1 -1
  65. package/rules/bdd.md +1 -1
  66. package/rules/cleanup-temp-files.md +10 -4
  67. package/rules/code-standards.md +7 -0
  68. package/rules/conservative-action.md +1 -5
  69. package/rules/context7.md +0 -4
  70. package/rules/destructive-commands.md +47 -0
  71. package/rules/doc-inventory-integrity.md +48 -0
  72. package/rules/doc-prose-cuts.md +58 -0
  73. package/rules/docstring-prose-matches-implementation.md +10 -2
  74. package/rules/durable-post-artifacts.md +0 -4
  75. package/rules/eli11-replies.md +31 -0
  76. package/rules/explore-thoroughly.md +4 -4
  77. package/rules/falsify-before-green.md +68 -0
  78. package/rules/file-global-constants.md +1 -1
  79. package/rules/filesystem-search.md +51 -0
  80. package/rules/gh-cli-conventions.md +27 -0
  81. package/rules/git-workflow.md +26 -0
  82. package/rules/hedging-claims.md +9 -0
  83. package/rules/long-horizon-autonomy.md +0 -4
  84. package/rules/measurement-denominators.md +48 -0
  85. package/rules/nas-ssh-invocation.md +23 -5
  86. package/rules/parallel-tools.md +2 -2
  87. package/rules/plain-illustrative-docstrings.md +3 -7
  88. package/rules/plain-language.md +2 -0
  89. package/rules/proof-of-work-pr-comments.md +0 -4
  90. package/rules/re-stage-before-commit.md +2 -0
  91. package/rules/research-mode.md +10 -0
  92. package/rules/shell-invocation.md +21 -0
  93. package/rules/testing.md +4 -0
  94. package/rules/verified-commit-gate-skip.md +3 -27
  95. package/rules/verify-before-asking.md +5 -0
  96. package/rules/windows-filesystem-safe.md +1 -1
  97. package/rules/workers-done-before-complete.md +4 -0
  98. package/scripts/CLAUDE.md +3 -3
  99. package/scripts/Migrate-ShellPolicy.ps1 +1 -1
  100. package/scripts/codex_capability_bridge.py +171 -0
  101. package/scripts/codex_compat_materializer.py +1087 -0
  102. package/scripts/codex_compat_watcher.py +502 -0
  103. package/scripts/dev_env_scripts_constants/CLAUDE.md +1 -1
  104. package/scripts/dev_env_scripts_constants/code_review_constants.py +37 -0
  105. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +79 -13
  106. package/scripts/grok_headless_runner.py +213 -16
  107. package/scripts/invoke_code_review.py +11 -4
  108. package/scripts/resolve_worker_spawn.py +56 -10
  109. package/scripts/spawn_grok_batch.py +43 -22
  110. package/scripts/sync_to_cursor/rules.py +0 -10
  111. package/scripts/test_grok_headless_runner.py +592 -10
  112. package/scripts/test_invoke_code_review.py +143 -0
  113. package/scripts/test_invoke_code_review_chain.py +1 -1
  114. package/scripts/test_invoke_code_review_contract.py +1 -1
  115. package/scripts/test_resolve_worker_spawn.py +179 -15
  116. package/scripts/test_spawn_grok_batch.py +225 -22
  117. package/scripts/tests/test_code_review_constants.py +80 -0
  118. package/scripts/tests/test_codex_capability_bridge.py +91 -0
  119. package/scripts/tests/test_codex_compat_materializer.py +632 -0
  120. package/scripts/tests/test_codex_compat_watcher.py +599 -0
  121. package/scripts/tests/test_sync_to_cursor.py +0 -1
  122. package/skills/autoconverge/workflow/converge.contract.test.mjs +28 -6
  123. package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +73 -0
  124. package/skills/autoconverge/workflow/converge.mjs +64 -15
  125. package/skills/bugteam/reference/copilot-gap-analysis.md +1 -1
  126. package/skills/condensing-instructions/SKILL.md +42 -51
  127. package/skills/fresh-branch/CLAUDE.md +1 -1
  128. package/skills/fresh-branch/SKILL.md +5 -6
  129. package/skills/fresh-branch/scripts/create_fresh_branch.py +42 -24
  130. package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +1 -3
  131. package/skills/fresh-branch/scripts/test_create_fresh_branch.py +30 -126
  132. package/skills/grok-spawn/SKILL.md +5 -3
  133. package/skills/grok-spawn/reference/flag-profiles.md +3 -1
  134. package/skills/orchestrator/SKILL.md +23 -9
  135. package/skills/orchestrator-refresh/SKILL.md +20 -1
  136. package/skills/privacy-hygiene/reference/sweep-procedure.md +1 -1
  137. package/skills/session-log/SKILL.md +1 -1
  138. package/rules/claude-md-orphan-file.md +0 -28
  139. package/rules/cleanup-command-forms.md +0 -23
  140. package/rules/code-reviews.md +0 -11
  141. package/rules/env-var-table-code-drift.md +0 -10
  142. package/rules/gh-body-file.md +0 -5
  143. package/rules/gh-paginate.md +0 -3
  144. package/rules/hook-prose-matches-detector.md +0 -15
  145. package/rules/no-historical-clutter.md +0 -26
  146. package/rules/no-inline-destructive-literals.md +0 -9
  147. package/rules/no-justification-noise.md +0 -61
  148. package/rules/package-inventory-stale-entry.md +0 -25
  149. package/rules/right-sized-engineering.md +0 -28
  150. package/rules/self-contained-docs.md +0 -17
  151. package/rules/shell-invocation-policy.md +0 -5
  152. package/rules/state-what-is.md +0 -25
  153. package/rules/tdd.md +0 -7
@@ -1,40 +1,219 @@
1
1
  from __future__ import annotations
2
2
 
3
- import importlib
4
3
  import io
4
+ import subprocess
5
5
  import sys
6
+ from collections.abc import Callable
6
7
  from pathlib import Path
7
8
 
8
9
  import pytest
9
10
 
11
+ import git_hooks_constants
12
+ import pre_push
13
+
10
14
 
11
15
  SCRIPT_DIRECTORY = Path(__file__).resolve().parent
12
- _git_hooks_directory_string = str(SCRIPT_DIRECTORY)
13
- while _git_hooks_directory_string in sys.path:
14
- sys.path.remove(_git_hooks_directory_string)
15
- sys.path.insert(0, _git_hooks_directory_string)
16
- for each_module_name in list(sys.modules):
17
- if each_module_name == "config" or each_module_name.startswith("config."):
18
- del sys.modules[each_module_name]
19
- importlib.invalidate_caches()
20
16
 
21
- import pre_push
22
- import git_hooks_constants
17
+ REAL_RUN_GIT_TEXT_COMMAND = pre_push.run_git_text_command
23
18
 
24
19
 
20
+ CODE_REVIEW_ENFORCEMENT_ENV_VAR: str = "CLAUDE_CODE_REVIEW_ENFORCEMENT"
21
+ ENFORCEMENT_CONSTANTS_MODULE_NAME: str = "config.code_review_enforcement_constants"
25
22
  ALL_ZEROS_OBJECT_NAME: str = "0" * 40
26
23
  NON_ZERO_LOCAL_SHA: str = "a" * 40
27
24
  NON_ZERO_REMOTE_SHA_ONE: str = "1" * 40
28
25
  NON_ZERO_REMOTE_SHA_TWO: str = "2" * 40
29
26
 
30
27
 
28
+ GIT_RESOLVED_EXIT_CODE: int = 0
29
+ RESOLVED_COMMIT_OBJECT_NAME: str = "c" * 40
30
+ HOOK_INVOCATION_NAME: str = "pre-push"
31
+ PUSHED_REMOTE_NAME: str = "upstream"
32
+ PUSHED_REMOTE_URL: str = "https://example.invalid/owner/repository.git"
33
+
34
+
35
+ LOCALE_INVALID_REFERENCE_LISTING_BYTES: bytes = b"origin/caf\xe9\norigin/main\n"
36
+ EMPTY_COMMAND_STREAM_BYTES: bytes = b""
37
+ STRICT_DECODER_NAME: str = "utf-8"
38
+ UNDECODABLE_BYTE_START_INDEX: int = 10
39
+ UNDECODABLE_BYTE_END_INDEX: int = 11
40
+ UNDECODABLE_BYTE_REASON: str = "invalid continuation byte"
41
+ TEXT_DECODING_KEYWORD: str = "text"
42
+ GIT_MISSING_REFERENCE_EXIT_CODE: int = 1
43
+ GIT_LAUNCH_FAILURE_DETAIL: str = "git executable not found"
44
+ RESOLVED_REMOTE_MAIN_REFERENCE: str = "origin/main"
45
+ UNRESOLVABLE_BASE_REPORT_MARKER: str = "no usable gate base"
46
+ GIT_UNAVAILABLE_REPORT_MARKER: str = "could not run git"
47
+ NEW_BRANCH_PUSH_STDIN: str = (
48
+ f"refs/heads/feature {NON_ZERO_LOCAL_SHA} refs/heads/feature {ALL_ZEROS_OBJECT_NAME}\n"
49
+ )
50
+
51
+
52
+ def _refuse_locale_decoding() -> UnicodeDecodeError:
53
+ """Build the error a text-mode subprocess raises on undecodable output."""
54
+ return UnicodeDecodeError(
55
+ STRICT_DECODER_NAME,
56
+ LOCALE_INVALID_REFERENCE_LISTING_BYTES,
57
+ UNDECODABLE_BYTE_START_INDEX,
58
+ UNDECODABLE_BYTE_END_INDEX,
59
+ UNDECODABLE_BYTE_REASON,
60
+ )
61
+
62
+
63
+ def _completed_git_process(
64
+ all_command_arguments: list[str], exit_code: int, standard_output_bytes: bytes
65
+ ) -> subprocess.CompletedProcess[bytes]:
66
+ """Build a completed git process carrying raw bytes on standard output."""
67
+ return subprocess.CompletedProcess(
68
+ all_command_arguments,
69
+ exit_code,
70
+ standard_output_bytes,
71
+ EMPTY_COMMAND_STREAM_BYTES,
72
+ )
73
+
74
+
75
+ def _answer_git_with_locale_invalid_bytes(
76
+ all_command_arguments: list[str],
77
+ **all_keyword_arguments: object,
78
+ ) -> subprocess.CompletedProcess[bytes]:
79
+ """Stand in for a git whose output the process locale cannot decode.
80
+
81
+ ::
82
+
83
+ text=True -> UnicodeDecodeError bytes mode -> the raw listing
84
+
85
+ Args:
86
+ all_command_arguments: The full git command the seam assembled.
87
+ all_keyword_arguments: The keyword arguments the seam passed.
88
+
89
+ Returns:
90
+ A completed process carrying the raw listing bytes.
91
+ """
92
+ if all_keyword_arguments.get(TEXT_DECODING_KEYWORD):
93
+ raise _refuse_locale_decoding()
94
+ if git_hooks_constants.GIT_FOR_EACH_REF_SUBCOMMAND in all_command_arguments:
95
+ return _completed_git_process(
96
+ all_command_arguments,
97
+ GIT_RESOLVED_EXIT_CODE,
98
+ LOCALE_INVALID_REFERENCE_LISTING_BYTES,
99
+ )
100
+ return _completed_git_process(
101
+ all_command_arguments,
102
+ GIT_MISSING_REFERENCE_EXIT_CODE,
103
+ EMPTY_COMMAND_STREAM_BYTES,
104
+ )
105
+
106
+
107
+ def _write_gate_that_allows_every_push(
108
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
109
+ ) -> None:
110
+ """Point both gates at stubs that allow, with a new-branch push on stdin."""
111
+ passing_gate_path = tmp_path / "code_rules_gate.py"
112
+ passing_gate_path.write_text("import sys\nsys.exit(0)\n", encoding="utf-8")
113
+ monkeypatch.setenv("CODE_RULES_GATE_PATH", str(passing_gate_path))
114
+ _isolate_code_review_gate(tmp_path, monkeypatch)
115
+ monkeypatch.setattr(sys, "stdin", io.StringIO(NEW_BRANCH_PUSH_STDIN))
116
+
117
+
118
+ def test_reference_listing_with_locale_invalid_bytes_resolves_a_base(
119
+ monkeypatch: pytest.MonkeyPatch,
120
+ ) -> None:
121
+ monkeypatch.setattr(subprocess, "run", _answer_git_with_locale_invalid_bytes)
122
+
123
+ resolved_reference = pre_push.resolve_usable_base_reference(
124
+ git_hooks_constants.DEFAULT_REMOTE_BASE_REFERENCE,
125
+ git_hooks_constants.DEFAULT_REMOTE_NAME,
126
+ REAL_RUN_GIT_TEXT_COMMAND,
127
+ )
128
+
129
+ assert resolved_reference == RESOLVED_REMOTE_MAIN_REFERENCE
130
+
131
+
132
+ def test_run_git_text_command_raises_when_git_cannot_launch(
133
+ monkeypatch: pytest.MonkeyPatch,
134
+ ) -> None:
135
+ def raising_subprocess_run(
136
+ all_command_arguments: list[str], **all_keyword_arguments: object
137
+ ) -> subprocess.CompletedProcess[bytes]:
138
+ raise OSError(GIT_LAUNCH_FAILURE_DETAIL)
139
+
140
+ monkeypatch.setattr(subprocess, "run", raising_subprocess_run)
141
+
142
+ with pytest.raises(pre_push.GitCommandUnavailable):
143
+ REAL_RUN_GIT_TEXT_COMMAND([git_hooks_constants.GIT_FOR_EACH_REF_SUBCOMMAND])
144
+
145
+
146
+ def test_main_reports_infrastructure_failure_when_git_cannot_launch(
147
+ tmp_path: Path,
148
+ monkeypatch: pytest.MonkeyPatch,
149
+ capsys: pytest.CaptureFixture[str],
150
+ ) -> None:
151
+ _write_gate_that_allows_every_push(tmp_path, monkeypatch)
152
+
153
+ def unavailable_run_git_text_command(
154
+ all_command_arguments: list[str],
155
+ ) -> tuple[int, str]:
156
+ raise pre_push.GitCommandUnavailable(GIT_LAUNCH_FAILURE_DETAIL)
157
+
158
+ monkeypatch.setattr(
159
+ pre_push, "run_git_text_command", unavailable_run_git_text_command
160
+ )
161
+
162
+ exit_code = pre_push.main()
163
+
164
+ captured_streams = capsys.readouterr()
165
+ assert exit_code == git_hooks_constants.GATE_INFRASTRUCTURE_FAILURE_EXIT_CODE
166
+ assert GIT_UNAVAILABLE_REPORT_MARKER in captured_streams.err
167
+ assert GIT_LAUNCH_FAILURE_DETAIL in captured_streams.err
168
+ assert UNRESOLVABLE_BASE_REPORT_MARKER not in captured_streams.err
169
+
170
+
171
+ def test_main_reports_a_missing_default_branch_as_absence(
172
+ tmp_path: Path,
173
+ monkeypatch: pytest.MonkeyPatch,
174
+ capsys: pytest.CaptureFixture[str],
175
+ ) -> None:
176
+ _write_gate_that_allows_every_push(tmp_path, monkeypatch)
177
+
178
+ def empty_run_git_text_command(
179
+ all_command_arguments: list[str],
180
+ ) -> tuple[int, str]:
181
+ return GIT_MISSING_REFERENCE_EXIT_CODE, ""
182
+
183
+ monkeypatch.setattr(pre_push, "run_git_text_command", empty_run_git_text_command)
184
+
185
+ exit_code = pre_push.main()
186
+
187
+ captured_streams = capsys.readouterr()
188
+ assert exit_code == git_hooks_constants.GATE_INFRASTRUCTURE_FAILURE_EXIT_CODE
189
+ assert UNRESOLVABLE_BASE_REPORT_MARKER in captured_streams.err
190
+ assert GIT_UNAVAILABLE_REPORT_MARKER not in captured_streams.err
191
+
192
+
193
+ @pytest.fixture(autouse=True)
194
+ def resolve_remote_head_reference(monkeypatch: pytest.MonkeyPatch) -> None:
195
+ """Make every reference resolve, so these tests ignore the clone's remote state.
196
+
197
+ ``main`` resolves the gate base through git before it invokes the gate. A
198
+ clone with no ``origin/HEAD`` would send these tests down the unresolvable
199
+ path and change what ``main`` returns. Stubbing the seam keeps each test
200
+ about the flow it names. Base resolution itself is covered by
201
+ ``test_pre_push_base_reference.py``.
202
+ """
203
+
204
+ def fake_run_git_text_command(all_command_arguments: list[str]) -> tuple[int, str]:
205
+ return GIT_RESOLVED_EXIT_CODE, RESOLVED_COMMIT_OBJECT_NAME
206
+
207
+ monkeypatch.setattr(pre_push, "run_git_text_command", fake_run_git_text_command)
208
+
209
+
31
210
  def _isolate_code_review_gate(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
32
211
  monkeypatch.setenv(
33
212
  "CODE_REVIEW_PUSH_GATE_PATH", str(tmp_path / "no_code_review_gate.py")
34
213
  )
35
214
 
36
215
 
37
- def test_resolve_base_reference_uses_remote_object_when_non_zero() -> None:
216
+ def test_resolve_base_reference_from_stdin_uses_remote_object_when_non_zero() -> None:
38
217
  stdin_text = (
39
218
  f"refs/heads/feature {NON_ZERO_LOCAL_SHA} refs/heads/feature {NON_ZERO_REMOTE_SHA_ONE}\n"
40
219
  )
@@ -44,7 +223,7 @@ def test_resolve_base_reference_uses_remote_object_when_non_zero() -> None:
44
223
  assert base_reference == NON_ZERO_REMOTE_SHA_ONE
45
224
 
46
225
 
47
- def test_resolve_base_reference_falls_back_when_remote_is_all_zeros() -> None:
226
+ def test_resolve_base_reference_from_stdin_falls_back_when_remote_is_all_zeros() -> None:
48
227
  stdin_text = f"refs/heads/feature {NON_ZERO_LOCAL_SHA} refs/heads/feature {ALL_ZEROS_OBJECT_NAME}\n"
49
228
 
50
229
  base_reference = pre_push.resolve_base_reference_from_stdin(stdin_text)
@@ -52,13 +231,13 @@ def test_resolve_base_reference_falls_back_when_remote_is_all_zeros() -> None:
52
231
  assert base_reference == pre_push.DEFAULT_REMOTE_BASE_REFERENCE
53
232
 
54
233
 
55
- def test_resolve_base_reference_falls_back_when_stdin_empty() -> None:
234
+ def test_resolve_base_reference_from_stdin_falls_back_when_stdin_is_empty() -> None:
56
235
  base_reference = pre_push.resolve_base_reference_from_stdin("")
57
236
 
58
237
  assert base_reference == pre_push.DEFAULT_REMOTE_BASE_REFERENCE
59
238
 
60
239
 
61
- def test_resolve_base_reference_prefers_first_non_zero_remote_object_among_many() -> (
240
+ def test_resolve_base_reference_from_stdin_prefers_first_non_zero_remote_object() -> (
62
241
  None
63
242
  ):
64
243
  stdin_text = (
@@ -121,6 +300,37 @@ def test_main_invokes_gate_with_resolved_base_reference(
121
300
  assert recorded_arguments == ["--base", remote_sha]
122
301
 
123
302
 
303
+ def test_main_passes_the_pushed_remote_name_to_base_resolution(
304
+ tmp_path: Path,
305
+ monkeypatch: pytest.MonkeyPatch,
306
+ ) -> None:
307
+ _write_passing_code_rules_gate(tmp_path, monkeypatch)
308
+ _isolate_code_review_gate(tmp_path, monkeypatch)
309
+ all_recorded_remote_names: list[str] = []
310
+
311
+ def recording_resolve_usable_base_reference(
312
+ base_reference: str,
313
+ remote_name: str,
314
+ ask_git: Callable[[list[str]], tuple[int, str]],
315
+ ) -> str:
316
+ all_recorded_remote_names.append(remote_name)
317
+ return base_reference
318
+
319
+ monkeypatch.setattr(
320
+ pre_push,
321
+ "resolve_usable_base_reference",
322
+ recording_resolve_usable_base_reference,
323
+ )
324
+ monkeypatch.setattr(
325
+ sys, "argv", [HOOK_INVOCATION_NAME, PUSHED_REMOTE_NAME, PUSHED_REMOTE_URL]
326
+ )
327
+
328
+ exit_code = pre_push.main()
329
+
330
+ assert exit_code == 0
331
+ assert all_recorded_remote_names == [PUSHED_REMOTE_NAME]
332
+
333
+
124
334
  def test_main_propagates_blocking_exit_code_from_gate(
125
335
  tmp_path: Path,
126
336
  monkeypatch: pytest.MonkeyPatch,
@@ -193,7 +403,7 @@ def test_main_exits_two_when_invoke_gate_raises_oserror(
193
403
  assert exit_code == git_hooks_constants.GATE_INFRASTRUCTURE_FAILURE_EXIT_CODE
194
404
 
195
405
 
196
- def test_resolve_base_reference_emits_warning_for_malformed_line(
406
+ def test_resolve_base_reference_from_stdin_emits_warning_for_malformed_line(
197
407
  capsys: pytest.CaptureFixture[str],
198
408
  ) -> None:
199
409
  malformed_stdin_text = "only_one_field\n"
@@ -204,7 +414,7 @@ def test_resolve_base_reference_emits_warning_for_malformed_line(
204
414
  assert "malformed" in captured.err
205
415
 
206
416
 
207
- def test_resolve_base_reference_returns_none_when_local_sha_is_all_zeros() -> None:
417
+ def test_resolve_base_reference_from_stdin_returns_none_when_local_sha_is_all_zeros() -> None:
208
418
  stdin_text = f"refs/heads/feature {ALL_ZEROS_OBJECT_NAME} refs/heads/feature {ALL_ZEROS_OBJECT_NAME}\n"
209
419
 
210
420
  base_reference = pre_push.resolve_base_reference_from_stdin(stdin_text)
@@ -227,9 +437,7 @@ def test_main_exits_zero_immediately_when_push_is_deletion(
227
437
  assert exit_code == 0
228
438
 
229
439
 
230
- def test_resolve_base_reference_returns_sentinel_when_only_malformed_lines_present(
231
- capsys: pytest.CaptureFixture[str],
232
- ) -> None:
440
+ def test_resolve_base_reference_from_stdin_returns_sentinel_for_malformed_lines() -> None:
233
441
  malformed_only_stdin = "one_field_only\nalso_malformed\n"
234
442
 
235
443
  base_reference = pre_push.resolve_base_reference_from_stdin(malformed_only_stdin)
@@ -254,9 +462,7 @@ def test_main_prints_stderr_when_gate_script_missing(
254
462
  assert captured.err != ""
255
463
 
256
464
 
257
- def test_resolve_base_reference_exits_two_when_only_malformed_lines_and_no_valid_lines(
258
- capsys: pytest.CaptureFixture[str],
259
- ) -> None:
465
+ def test_resolve_base_reference_from_stdin_reports_sentinel_with_no_valid_lines() -> None:
260
466
  malformed_only_stdin = "one_field_only\nalso_malformed\n"
261
467
 
262
468
  result = pre_push.resolve_base_reference_from_stdin(malformed_only_stdin)
@@ -550,11 +756,14 @@ def test_main_allows_when_code_review_enforcement_flag_is_off(
550
756
  ) -> None:
551
757
  """Native pre-push honors the real gate with enforcement off.
552
758
 
553
- Points at the production push gate (not a reason stub). With the shipped
554
- default `CODE_REVIEW_ENFORCEMENT_ENABLED = False`, the gate returns no
555
- deny reason and the backstop allows the push one path covering flag,
556
- real gate load, and pre-push exit together.
759
+ Points at the production push gate (not a reason stub). The gate resolves
760
+ its flag when its config module first executes, so this test clears the
761
+ environment variable and evicts that one module if a sibling suite cached
762
+ it earlier in the same session. The gate then returns no deny reason and
763
+ the backstop allows the push.
557
764
  """
765
+ monkeypatch.delenv(CODE_REVIEW_ENFORCEMENT_ENV_VAR, raising=False)
766
+ monkeypatch.delitem(sys.modules, ENFORCEMENT_CONSTANTS_MODULE_NAME, raising=False)
558
767
  _write_passing_code_rules_gate(tmp_path, monkeypatch)
559
768
  real_gate_path = (
560
769
  Path(__file__).resolve().parent.parent
@@ -0,0 +1,339 @@
1
+ """Specifications for the pre-push hook's remote base reference resolution.
2
+
3
+ A clone leaves ``origin/HEAD`` unset, so the gate base the hook hands to
4
+ ``code_rules_gate.py`` names an object git cannot resolve and the push aborts.
5
+ These specifications pin the fallback to the default branch of the remote git
6
+ names in the hook's arguments, across every candidate default branch name.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Callable
12
+
13
+ import pytest
14
+
15
+ import git_hooks_constants
16
+ import pre_push_base_reference
17
+
18
+
19
+ RESOLVED_REMOTE_MAIN_REFERENCE: str = "origin/main"
20
+ RESOLVED_REMOTE_MASTER_REFERENCE: str = "origin/master"
21
+ RESOLVED_REMOTE_TRUNK_REFERENCE: str = "origin/trunk"
22
+ REMOTE_HEAD_SYMBOLIC_TARGET: str = "refs/remotes/origin/main"
23
+ UPSTREAM_REMOTE_NAME: str = "upstream"
24
+ RESOLVED_UPSTREAM_MAIN_REFERENCE: str = "upstream/main"
25
+ UPSTREAM_HEAD_SYMBOLIC_TARGET: str = "refs/remotes/upstream/main"
26
+ HOOK_INVOCATION_NAME: str = "pre-push"
27
+ PUSHED_REMOTE_URL: str = "https://example.invalid/owner/repository.git"
28
+ SSH_PUSHED_REMOTE_URL: str = "git@example.invalid:owner/repository.git"
29
+ CONCRETE_REMOTE_OBJECT_NAME: str = "1" * 40
30
+ ALL_GLOB_BEARING_REMOTE_NAMES: tuple[str, ...] = (
31
+ "orig*n",
32
+ "orig?n",
33
+ "orig[i]n",
34
+ "orig\\n",
35
+ )
36
+ GIT_COMMAND_SUCCESS_CODE: int = 0
37
+ GIT_COMMAND_FAILURE_CODE: int = 1
38
+ EMPTY_COMMAND_OUTPUT: str = ""
39
+ LISTING_LINE_SEPARATOR: str = "\n"
40
+
41
+
42
+ def _list_existing_short_names(
43
+ command_arguments: list[str],
44
+ all_resolvable_references: set[str],
45
+ ) -> str:
46
+ """Answer a ``for-each-ref`` listing in reverse-sorted order.
47
+
48
+ Git sorts a listing by reference name rather than by the pattern order the
49
+ caller passed, so this seam reverses the sort to keep that clash visible.
50
+
51
+ Args:
52
+ command_arguments: The git arguments the hook passed.
53
+ all_resolvable_references: The short names this repository holds.
54
+
55
+ Returns:
56
+ The newline-joined short names, one per existing reference.
57
+ """
58
+ prefix = git_hooks_constants.REMOTE_REFERENCE_NAME_PREFIX
59
+ all_listed_short_names = [
60
+ each_argument.removeprefix(prefix)
61
+ for each_argument in command_arguments
62
+ if each_argument.removeprefix(prefix) in all_resolvable_references
63
+ ]
64
+ return LISTING_LINE_SEPARATOR.join(sorted(all_listed_short_names, reverse=True))
65
+
66
+
67
+ def _build_fake_git(
68
+ all_resolvable_references: set[str],
69
+ symbolic_reference_target: str | None,
70
+ all_recorded_command_arguments: list[list[str]],
71
+ ) -> Callable[[list[str]], tuple[int, str]]:
72
+ """Return a git seam that answers from a table rather than a repository."""
73
+
74
+ def fake_run_git_text_command(command_arguments: list[str]) -> tuple[int, str]:
75
+ all_recorded_command_arguments.append(list(command_arguments))
76
+ if git_hooks_constants.GIT_SYMBOLIC_REFERENCE_SUBCOMMAND in command_arguments:
77
+ if symbolic_reference_target is None:
78
+ return GIT_COMMAND_FAILURE_CODE, EMPTY_COMMAND_OUTPUT
79
+ return GIT_COMMAND_SUCCESS_CODE, symbolic_reference_target
80
+ if git_hooks_constants.GIT_FOR_EACH_REF_SUBCOMMAND in command_arguments:
81
+ return GIT_COMMAND_SUCCESS_CODE, _list_existing_short_names(
82
+ command_arguments, all_resolvable_references
83
+ )
84
+ requested_reference = command_arguments[-1].removesuffix(
85
+ git_hooks_constants.COMMIT_OBJECT_NAME_SUFFIX
86
+ )
87
+ if requested_reference in all_resolvable_references:
88
+ return GIT_COMMAND_SUCCESS_CODE, CONCRETE_REMOTE_OBJECT_NAME
89
+ return GIT_COMMAND_FAILURE_CODE, EMPTY_COMMAND_OUTPUT
90
+
91
+ return fake_run_git_text_command
92
+
93
+
94
+ def _build_recording_git(
95
+ all_resolvable_references: set[str],
96
+ symbolic_reference_target: str | None,
97
+ ) -> tuple[Callable[[list[str]], tuple[int, str]], list[list[str]]]:
98
+ """Build the table-driven git seam together with its call record.
99
+
100
+ Args:
101
+ all_resolvable_references: Reference names ``rev-parse`` resolves and
102
+ ``for-each-ref`` lists.
103
+ symbolic_reference_target: The target ``symbolic-ref`` reports, or None
104
+ when the symbolic reference is unset.
105
+
106
+ Returns:
107
+ The git seam paired with the list recording each argument list it saw.
108
+ """
109
+ all_recorded_command_arguments: list[list[str]] = []
110
+ ask_git = _build_fake_git(
111
+ all_resolvable_references,
112
+ symbolic_reference_target,
113
+ all_recorded_command_arguments,
114
+ )
115
+ return ask_git, all_recorded_command_arguments
116
+
117
+
118
+ def should_keep_the_base_reference_when_git_resolves_it() -> None:
119
+ ask_git, _ = _build_recording_git(
120
+ {git_hooks_constants.DEFAULT_REMOTE_BASE_REFERENCE},
121
+ symbolic_reference_target=None,
122
+ )
123
+
124
+ resolved_reference = pre_push_base_reference.resolve_usable_base_reference(
125
+ git_hooks_constants.DEFAULT_REMOTE_BASE_REFERENCE,
126
+ git_hooks_constants.DEFAULT_REMOTE_NAME,
127
+ ask_git,
128
+ )
129
+
130
+ assert resolved_reference == git_hooks_constants.DEFAULT_REMOTE_BASE_REFERENCE
131
+
132
+
133
+ def should_use_the_symbolic_target_when_remote_head_is_unresolvable() -> None:
134
+ ask_git, _ = _build_recording_git(
135
+ {RESOLVED_REMOTE_MAIN_REFERENCE},
136
+ symbolic_reference_target=REMOTE_HEAD_SYMBOLIC_TARGET,
137
+ )
138
+
139
+ resolved_reference = pre_push_base_reference.resolve_usable_base_reference(
140
+ git_hooks_constants.DEFAULT_REMOTE_BASE_REFERENCE,
141
+ git_hooks_constants.DEFAULT_REMOTE_NAME,
142
+ ask_git,
143
+ )
144
+
145
+ assert resolved_reference == RESOLVED_REMOTE_MAIN_REFERENCE
146
+
147
+
148
+ def should_fall_back_to_a_candidate_when_the_symbolic_reference_is_unset() -> None:
149
+ ask_git, _ = _build_recording_git(
150
+ {RESOLVED_REMOTE_MASTER_REFERENCE},
151
+ symbolic_reference_target=None,
152
+ )
153
+
154
+ resolved_reference = pre_push_base_reference.resolve_usable_base_reference(
155
+ git_hooks_constants.DEFAULT_REMOTE_BASE_REFERENCE,
156
+ git_hooks_constants.DEFAULT_REMOTE_NAME,
157
+ ask_git,
158
+ )
159
+
160
+ assert resolved_reference == RESOLVED_REMOTE_MASTER_REFERENCE
161
+
162
+
163
+ def should_prefer_main_when_the_listing_reports_master_first() -> None:
164
+ ask_git, _ = _build_recording_git(
165
+ {RESOLVED_REMOTE_MAIN_REFERENCE, RESOLVED_REMOTE_MASTER_REFERENCE},
166
+ symbolic_reference_target=None,
167
+ )
168
+
169
+ resolved_reference = pre_push_base_reference.resolve_usable_base_reference(
170
+ git_hooks_constants.DEFAULT_REMOTE_BASE_REFERENCE,
171
+ git_hooks_constants.DEFAULT_REMOTE_NAME,
172
+ ask_git,
173
+ )
174
+
175
+ assert resolved_reference == RESOLVED_REMOTE_MAIN_REFERENCE
176
+
177
+
178
+ def should_resolve_a_trunk_default_branch() -> None:
179
+ ask_git, _ = _build_recording_git(
180
+ {RESOLVED_REMOTE_TRUNK_REFERENCE},
181
+ symbolic_reference_target=None,
182
+ )
183
+
184
+ resolved_reference = pre_push_base_reference.resolve_usable_base_reference(
185
+ git_hooks_constants.DEFAULT_REMOTE_BASE_REFERENCE,
186
+ git_hooks_constants.DEFAULT_REMOTE_NAME,
187
+ ask_git,
188
+ )
189
+
190
+ assert resolved_reference == RESOLVED_REMOTE_TRUNK_REFERENCE
191
+
192
+
193
+ def should_resolve_through_a_remote_named_other_than_origin() -> None:
194
+ ask_git, _ = _build_recording_git(
195
+ {RESOLVED_UPSTREAM_MAIN_REFERENCE},
196
+ symbolic_reference_target=UPSTREAM_HEAD_SYMBOLIC_TARGET,
197
+ )
198
+
199
+ resolved_reference = pre_push_base_reference.resolve_usable_base_reference(
200
+ git_hooks_constants.DEFAULT_REMOTE_BASE_REFERENCE,
201
+ UPSTREAM_REMOTE_NAME,
202
+ ask_git,
203
+ )
204
+
205
+ assert resolved_reference == RESOLVED_UPSTREAM_MAIN_REFERENCE
206
+
207
+
208
+ def should_list_candidate_branches_under_the_named_remote() -> None:
209
+ ask_git, all_recorded_command_arguments = _build_recording_git(
210
+ {RESOLVED_UPSTREAM_MAIN_REFERENCE},
211
+ symbolic_reference_target=None,
212
+ )
213
+
214
+ resolved_reference = pre_push_base_reference.resolve_usable_base_reference(
215
+ git_hooks_constants.DEFAULT_REMOTE_BASE_REFERENCE,
216
+ UPSTREAM_REMOTE_NAME,
217
+ ask_git,
218
+ )
219
+
220
+ listing_command_arguments = all_recorded_command_arguments[-1]
221
+ assert resolved_reference == RESOLVED_UPSTREAM_MAIN_REFERENCE
222
+ assert UPSTREAM_HEAD_SYMBOLIC_TARGET in listing_command_arguments
223
+
224
+
225
+ def should_report_no_usable_reference_when_nothing_resolves() -> None:
226
+ ask_git, _ = _build_recording_git(set(), symbolic_reference_target=None)
227
+
228
+ resolved_reference = pre_push_base_reference.resolve_usable_base_reference(
229
+ git_hooks_constants.DEFAULT_REMOTE_BASE_REFERENCE,
230
+ git_hooks_constants.DEFAULT_REMOTE_NAME,
231
+ ask_git,
232
+ )
233
+
234
+ assert resolved_reference is None
235
+
236
+
237
+ def should_name_the_pushed_remote_in_the_unresolvable_report(
238
+ capsys: pytest.CaptureFixture[str],
239
+ ) -> None:
240
+ ask_git, _ = _build_recording_git(set(), symbolic_reference_target=None)
241
+
242
+ pre_push_base_reference.resolve_usable_base_reference(
243
+ git_hooks_constants.DEFAULT_REMOTE_BASE_REFERENCE,
244
+ UPSTREAM_REMOTE_NAME,
245
+ ask_git,
246
+ )
247
+
248
+ captured_streams = capsys.readouterr()
249
+ assert UPSTREAM_REMOTE_NAME in captured_streams.err
250
+
251
+
252
+ def should_pass_a_concrete_object_name_through_untouched() -> None:
253
+ ask_git, _ = _build_recording_git(
254
+ set(), symbolic_reference_target=REMOTE_HEAD_SYMBOLIC_TARGET
255
+ )
256
+
257
+ resolved_reference = pre_push_base_reference.resolve_usable_base_reference(
258
+ CONCRETE_REMOTE_OBJECT_NAME,
259
+ git_hooks_constants.DEFAULT_REMOTE_NAME,
260
+ ask_git,
261
+ )
262
+
263
+ assert resolved_reference == CONCRETE_REMOTE_OBJECT_NAME
264
+
265
+
266
+ def should_ask_git_nothing_about_a_concrete_object_name() -> None:
267
+ ask_git, all_recorded_command_arguments = _build_recording_git(
268
+ set(), symbolic_reference_target=REMOTE_HEAD_SYMBOLIC_TARGET
269
+ )
270
+
271
+ pre_push_base_reference.resolve_usable_base_reference(
272
+ CONCRETE_REMOTE_OBJECT_NAME,
273
+ git_hooks_constants.DEFAULT_REMOTE_NAME,
274
+ ask_git,
275
+ )
276
+
277
+ assert all_recorded_command_arguments == []
278
+
279
+
280
+ def should_ask_git_to_verify_the_reference_as_a_commit() -> None:
281
+ ask_git, all_recorded_command_arguments = _build_recording_git(
282
+ {git_hooks_constants.DEFAULT_REMOTE_BASE_REFERENCE},
283
+ symbolic_reference_target=None,
284
+ )
285
+
286
+ pre_push_base_reference.resolve_usable_base_reference(
287
+ git_hooks_constants.DEFAULT_REMOTE_BASE_REFERENCE,
288
+ git_hooks_constants.DEFAULT_REMOTE_NAME,
289
+ ask_git,
290
+ )
291
+
292
+ first_command_arguments = all_recorded_command_arguments[0]
293
+ assert first_command_arguments[-1] == (
294
+ git_hooks_constants.DEFAULT_REMOTE_BASE_REFERENCE
295
+ + git_hooks_constants.COMMIT_OBJECT_NAME_SUFFIX
296
+ )
297
+
298
+
299
+ def test_reads_the_remote_name_from_the_push_arguments() -> None:
300
+ resolved_remote_name = pre_push_base_reference.resolve_remote_name_from_arguments(
301
+ [HOOK_INVOCATION_NAME, UPSTREAM_REMOTE_NAME, PUSHED_REMOTE_URL]
302
+ )
303
+
304
+ assert resolved_remote_name == UPSTREAM_REMOTE_NAME
305
+
306
+
307
+ def test_uses_the_default_remote_when_a_url_takes_the_remote_position() -> None:
308
+ resolved_remote_name = pre_push_base_reference.resolve_remote_name_from_arguments(
309
+ [HOOK_INVOCATION_NAME, PUSHED_REMOTE_URL, PUSHED_REMOTE_URL]
310
+ )
311
+
312
+ assert resolved_remote_name == git_hooks_constants.DEFAULT_REMOTE_NAME
313
+
314
+
315
+ def test_uses_the_default_remote_when_an_ssh_url_takes_the_remote_position() -> None:
316
+ resolved_remote_name = pre_push_base_reference.resolve_remote_name_from_arguments(
317
+ [HOOK_INVOCATION_NAME, SSH_PUSHED_REMOTE_URL, SSH_PUSHED_REMOTE_URL]
318
+ )
319
+
320
+ assert resolved_remote_name == git_hooks_constants.DEFAULT_REMOTE_NAME
321
+
322
+
323
+ @pytest.mark.parametrize("glob_bearing_remote_name", ALL_GLOB_BEARING_REMOTE_NAMES)
324
+ def test_uses_the_default_remote_when_the_remote_name_carries_a_glob_character(
325
+ glob_bearing_remote_name: str,
326
+ ) -> None:
327
+ resolved_remote_name = pre_push_base_reference.resolve_remote_name_from_arguments(
328
+ [HOOK_INVOCATION_NAME, glob_bearing_remote_name, PUSHED_REMOTE_URL]
329
+ )
330
+
331
+ assert resolved_remote_name == git_hooks_constants.DEFAULT_REMOTE_NAME
332
+
333
+
334
+ def test_uses_the_default_remote_when_the_push_arguments_are_empty() -> None:
335
+ resolved_remote_name = pre_push_base_reference.resolve_remote_name_from_arguments(
336
+ [HOOK_INVOCATION_NAME]
337
+ )
338
+
339
+ assert resolved_remote_name == git_hooks_constants.DEFAULT_REMOTE_NAME