claude-dev-env 1.95.0 → 2.0.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 (241) hide show
  1. package/_shared/advisor/CLAUDE.md +2 -2
  2. package/_shared/advisor/advisor-protocol.md +20 -20
  3. package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +15 -12
  4. package/_shared/advisor/scripts/model_tier_run_validator.py +11 -10
  5. package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +25 -19
  6. package/_shared/advisor/scripts/tests/test_tier_model_ids.py +17 -17
  7. package/_shared/advisor/scripts/tier_model_ids.py +18 -18
  8. package/_shared/pr-loop/CLAUDE.md +1 -0
  9. package/_shared/pr-loop/scripts/CLAUDE.md +2 -1
  10. package/_shared/pr-loop/scripts/README.md +1 -0
  11. package/_shared/pr-loop/scripts/code_rules_gate.py +253 -1980
  12. package/_shared/pr-loop/scripts/code_rules_gate_parts/CLAUDE.md +32 -0
  13. package/_shared/pr-loop/scripts/code_rules_gate_parts/__init__.py +7 -0
  14. package/_shared/pr-loop/scripts/code_rules_gate_parts/added_line_maps.py +268 -0
  15. package/_shared/pr-loop/scripts/code_rules_gate_parts/enforcer_loading.py +172 -0
  16. package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_arguments.py +70 -0
  17. package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_running.py +326 -0
  18. package/_shared/pr-loop/scripts/code_rules_gate_parts/git_blob_readers.py +85 -0
  19. package/_shared/pr-loop/scripts/code_rules_gate_parts/git_file_sets.py +331 -0
  20. package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_running.py +369 -0
  21. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/conftest.py +14 -0
  22. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_added_line_maps.py +118 -0
  23. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_enforcer_loading.py +17 -0
  24. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_arguments.py +29 -0
  25. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_running.py +99 -0
  26. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_git_blob_readers.py +69 -0
  27. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_git_file_sets.py +137 -0
  28. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_running.py +116 -0
  29. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_violation_scoping.py +75 -0
  30. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_wrapper_plumb_check.py +49 -0
  31. package/_shared/pr-loop/scripts/code_rules_gate_parts/violation_scoping.py +328 -0
  32. package/_shared/pr-loop/scripts/code_rules_gate_parts/wrapper_plumb_check.py +206 -0
  33. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +24 -17
  34. package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +1 -0
  35. package/_shared/pr-loop/scripts/reviews_disabled.py +19 -2
  36. package/_shared/pr-loop/scripts/test_code_rules_gate.py +278 -0
  37. package/_shared/pr-loop/scripts/tests/test_code_rules_gate_constants.py +6 -39
  38. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +43 -0
  39. package/_shared/pr-loop/worker-spawn.md +186 -0
  40. package/agents/code-verifier.md +1 -1
  41. package/bin/ever-shipped-skills.mjs +3 -0
  42. package/bin/expand_home_directory_tokens.mjs +1 -1
  43. package/bin/install.mjs +5 -2
  44. package/hooks/advisory/refactor_guard.py +3 -4
  45. package/hooks/blocking/CLAUDE.md +7 -1
  46. package/hooks/blocking/block_main_commit.py +2 -2
  47. package/hooks/blocking/claude_md_orphan_file_blocker.py +75 -699
  48. package/hooks/blocking/claude_md_orphan_file_blocker_parts/CLAUDE.md +28 -0
  49. package/hooks/blocking/claude_md_orphan_file_blocker_parts/__init__.py +1 -0
  50. package/hooks/blocking/claude_md_orphan_file_blocker_parts/config/__init__.py +1 -0
  51. package/hooks/blocking/claude_md_orphan_file_blocker_parts/config/orphan_blocker_constants.py +18 -0
  52. package/hooks/blocking/claude_md_orphan_file_blocker_parts/decision.py +81 -0
  53. package/hooks/blocking/claude_md_orphan_file_blocker_parts/references.py +307 -0
  54. package/hooks/blocking/claude_md_orphan_file_blocker_parts/scan_plan.py +124 -0
  55. package/hooks/blocking/claude_md_orphan_file_blocker_parts/subtree_scan.py +179 -0
  56. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/conftest.py +10 -0
  57. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_decision.py +34 -0
  58. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_references.py +42 -0
  59. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_scan_plan.py +27 -0
  60. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_subtree_scan.py +30 -0
  61. package/hooks/blocking/code_rules_boolean_mustcheck.py +1 -1
  62. package/hooks/blocking/code_rules_mock_completeness.py +1 -1
  63. package/hooks/blocking/code_rules_optional_params.py +2 -2
  64. package/hooks/blocking/code_rules_shared.py +1 -1
  65. package/hooks/blocking/code_rules_test_assertions.py +1 -1
  66. package/hooks/blocking/code_rules_typeddict_stub.py +1 -1
  67. package/hooks/blocking/gh_pr_author_enforcer.py +1 -1
  68. package/hooks/blocking/inventory_intent_records/CLAUDE.md +26 -0
  69. package/hooks/blocking/inventory_intent_records/__init__.py +1 -0
  70. package/hooks/blocking/inventory_intent_records/config/__init__.py +1 -0
  71. package/hooks/blocking/inventory_intent_records/config/intent_records_constants.py +20 -0
  72. package/hooks/blocking/inventory_intent_records/records.py +271 -0
  73. package/hooks/blocking/inventory_intent_records/tests/conftest.py +10 -0
  74. package/hooks/blocking/inventory_intent_records/tests/test_records.py +80 -0
  75. package/hooks/blocking/package_inventory_stale_blocker.py +54 -384
  76. package/hooks/blocking/package_inventory_stale_blocker_parts/CLAUDE.md +26 -0
  77. package/hooks/blocking/package_inventory_stale_blocker_parts/__init__.py +1 -0
  78. package/hooks/blocking/package_inventory_stale_blocker_parts/config/__init__.py +1 -0
  79. package/hooks/blocking/package_inventory_stale_blocker_parts/config/inventory_blocker_constants.py +16 -0
  80. package/hooks/blocking/package_inventory_stale_blocker_parts/decision.py +84 -0
  81. package/hooks/blocking/package_inventory_stale_blocker_parts/inventory_detection.py +307 -0
  82. package/hooks/blocking/package_inventory_stale_blocker_parts/tests/conftest.py +10 -0
  83. package/hooks/blocking/package_inventory_stale_blocker_parts/tests/test_decision.py +38 -0
  84. package/hooks/blocking/package_inventory_stale_blocker_parts/tests/test_inventory_detection.py +61 -0
  85. package/hooks/blocking/pii_payload_scan.py +138 -42
  86. package/hooks/blocking/pii_prevention_blocker.py +185 -291
  87. package/hooks/blocking/pii_prevention_blocker_parts/CLAUDE.md +24 -0
  88. package/hooks/blocking/pii_prevention_blocker_parts/__init__.py +1 -0
  89. package/hooks/blocking/pii_prevention_blocker_parts/config/__init__.py +1 -0
  90. package/hooks/blocking/pii_prevention_blocker_parts/config/repository_resolution_constants.py +28 -0
  91. package/hooks/blocking/pii_prevention_blocker_parts/repository_exemption.py +214 -0
  92. package/hooks/blocking/pii_prevention_blocker_parts/repository_resolution.py +208 -0
  93. package/hooks/blocking/pr_description_command_parser.py +8 -4
  94. package/hooks/blocking/precommit_code_rules_gate.py +3 -3
  95. package/hooks/blocking/tdd_enforcer.py +97 -608
  96. package/hooks/blocking/tdd_enforcer_parts/CLAUDE.md +30 -0
  97. package/hooks/blocking/tdd_enforcer_parts/__init__.py +1 -0
  98. package/hooks/blocking/tdd_enforcer_parts/candidate_paths.py +142 -0
  99. package/hooks/blocking/tdd_enforcer_parts/config/__init__.py +1 -0
  100. package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +32 -0
  101. package/hooks/blocking/tdd_enforcer_parts/content_analysis.py +268 -0
  102. package/hooks/blocking/tdd_enforcer_parts/decisions.py +92 -0
  103. package/hooks/blocking/tdd_enforcer_parts/freshness.py +80 -0
  104. package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +63 -0
  105. package/hooks/blocking/tdd_enforcer_parts/path_classification.py +119 -0
  106. package/hooks/blocking/tdd_enforcer_parts/tests/conftest.py +10 -0
  107. package/hooks/blocking/tdd_enforcer_parts/tests/test_candidate_paths.py +31 -0
  108. package/hooks/blocking/tdd_enforcer_parts/tests/test_content_analysis.py +30 -0
  109. package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +34 -0
  110. package/hooks/blocking/tdd_enforcer_parts/tests/test_freshness.py +28 -0
  111. package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +48 -0
  112. package/hooks/blocking/tdd_enforcer_parts/tests/test_path_classification.py +36 -0
  113. package/hooks/blocking/test_inventory_deadlock_resolution.py +154 -0
  114. package/hooks/blocking/test_pii_payload_scan.py +168 -0
  115. package/hooks/blocking/test_tdd_enforcer_restore.py +108 -0
  116. package/hooks/blocking/test_verifier_verdict_minter.py +55 -158
  117. package/hooks/blocking/tests/conftest.py +10 -0
  118. package/hooks/blocking/tests/test_pii_prevention_blocker.py +260 -0
  119. package/hooks/blocking/tests/test_repository_exemption.py +105 -0
  120. package/hooks/blocking/tests/test_repository_resolution.py +108 -0
  121. package/hooks/diagnostic/hook_log_extractor.py +12 -10
  122. package/hooks/git-hooks/post_commit.py +3 -4
  123. package/hooks/hooks_constants/CLAUDE.md +2 -2
  124. package/hooks/hooks_constants/banned_identifiers_constants.py +0 -1
  125. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -1
  126. package/hooks/hooks_constants/local_identity.py +59 -8
  127. package/hooks/hooks_constants/pii_prevention_constants.py +0 -6
  128. package/hooks/hooks_constants/test_local_identity.py +105 -3
  129. package/hooks/pyproject.toml +13 -36
  130. package/hooks/session/plugin_data_dir_cleanup.py +0 -1
  131. package/hooks/validation/mypy_validator.py +2 -2
  132. package/hooks/validators/health_check.py +1 -0
  133. package/hooks/validators/mypy_integration.py +2 -0
  134. package/hooks/validators/ruff_integration.py +3 -0
  135. package/hooks/workflow/auto_formatter.py +5 -4
  136. package/package.json +1 -1
  137. package/scripts/CLAUDE.md +4 -0
  138. package/scripts/dev_env_scripts_constants/CLAUDE.md +6 -4
  139. package/scripts/dev_env_scripts_constants/code_review_constants.py +71 -0
  140. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +435 -0
  141. package/scripts/dev_env_scripts_constants/timing.py +7 -1
  142. package/scripts/grok_headless_runner.py +294 -0
  143. package/scripts/grok_worker_preflight.py +410 -0
  144. package/scripts/invoke_code_review.py +463 -0
  145. package/scripts/resolve_worker_spawn.py +619 -0
  146. package/scripts/spawn_grok_batch.py +672 -0
  147. package/scripts/test_grok_headless_runner.py +626 -0
  148. package/scripts/test_grok_worker_preflight.py +1054 -0
  149. package/scripts/test_invoke_code_review.py +672 -0
  150. package/scripts/test_resolve_worker_spawn.py +1014 -0
  151. package/scripts/test_spawn_grok_batch.py +1017 -0
  152. package/skills/CLAUDE.md +5 -3
  153. package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +72 -13
  154. package/skills/_shared/pr-loop/scripts/build_fix_prompt.py +121 -14
  155. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +78 -0
  156. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +121 -0
  157. package/skills/_shared/pr-loop/scripts/test_build_fix_prompt.py +196 -6
  158. package/skills/autoconverge/CLAUDE.md +3 -3
  159. package/skills/autoconverge/SKILL.md +9 -3
  160. package/skills/autoconverge/reference/CLAUDE.md +2 -2
  161. package/skills/autoconverge/reference/convergence.md +33 -11
  162. package/skills/autoconverge/reference/stop-conditions.md +16 -5
  163. package/skills/autoconverge/workflow/CLAUDE.md +2 -1
  164. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +7 -2
  165. package/skills/autoconverge/workflow/converge.codex-gate.test.mjs +300 -0
  166. package/skills/autoconverge/workflow/converge.contract.test.mjs +5 -5
  167. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +29 -29
  168. package/skills/autoconverge/workflow/converge.fix-progress.test.mjs +1 -1
  169. package/skills/autoconverge/workflow/converge.mjs +200 -16
  170. package/skills/bugteam/CLAUDE.md +2 -2
  171. package/skills/bugteam/CONSTRAINTS.md +3 -2
  172. package/skills/bugteam/PROMPTS.md +7 -6
  173. package/skills/bugteam/SKILL.md +18 -13
  174. package/skills/bugteam/reference/audit-and-teammates.md +215 -35
  175. package/skills/bugteam/reference/design-rationale.md +1 -1
  176. package/skills/bugteam/reference/obstacles/CLAUDE.md +1 -1
  177. package/skills/bugteam/reference/team-setup.md +8 -2
  178. package/skills/codex-review/CLAUDE.md +46 -0
  179. package/skills/codex-review/SKILL.md +181 -0
  180. package/skills/codex-review/reference/CLAUDE.md +15 -0
  181. package/skills/codex-review/reference/cli-contract.md +253 -0
  182. package/skills/codex-review/reference/loop-integration.md +118 -0
  183. package/skills/codex-review/scripts/codex_down_classifier.py +98 -0
  184. package/skills/codex-review/scripts/codex_review_scripts_constants/CLAUDE.md +18 -0
  185. package/skills/codex-review/scripts/codex_review_scripts_constants/__init__.py +1 -0
  186. package/skills/codex-review/scripts/codex_review_scripts_constants/classifier_constants.py +35 -0
  187. package/skills/codex-review/scripts/codex_review_scripts_constants/codex_usage_probe_constants.py +86 -0
  188. package/skills/codex-review/scripts/codex_review_scripts_constants/findings_constants.py +18 -0
  189. package/skills/codex-review/scripts/codex_review_scripts_constants/run_constants.py +45 -0
  190. package/skills/codex-review/scripts/codex_usage_probe.py +573 -0
  191. package/skills/codex-review/scripts/fixtures/auth_failure_synthetic.txt +1 -0
  192. package/skills/codex-review/scripts/fixtures/config_load_failure_v0.125.0.txt +1 -0
  193. package/skills/codex-review/scripts/fixtures/freeform_findings_v0.144.3.txt +6 -0
  194. package/skills/codex-review/scripts/fixtures/model_rejection_v0.125.0.jsonl +5 -0
  195. package/skills/codex-review/scripts/fixtures/structured_findings.txt +13 -0
  196. package/skills/codex-review/scripts/fixtures/success_stream_v0.144.3.jsonl +6 -0
  197. package/skills/codex-review/scripts/fixtures/unknown_failure_synthetic.txt +1 -0
  198. package/skills/codex-review/scripts/fixtures/usage_limit_synthetic.txt +1 -0
  199. package/skills/codex-review/scripts/parse_codex_findings.py +207 -0
  200. package/skills/codex-review/scripts/run_codex_review.py +415 -0
  201. package/skills/codex-review/scripts/test_codex_down_classifier.py +143 -0
  202. package/skills/codex-review/scripts/test_codex_usage_probe.py +678 -0
  203. package/skills/codex-review/scripts/test_parse_codex_findings.py +130 -0
  204. package/skills/codex-review/scripts/test_run_codex_review.py +812 -0
  205. package/skills/codex-review/test_skill_scaffold.py +192 -0
  206. package/skills/grok-spawn/CLAUDE.md +28 -0
  207. package/skills/grok-spawn/SKILL.md +226 -0
  208. package/skills/grok-spawn/reference/flag-profiles.md +132 -0
  209. package/skills/grok-spawn/reference/worker-briefs.md +152 -0
  210. package/skills/grokify/SKILL.md +9 -1
  211. package/skills/grokify/capability-claims.test.mjs +28 -0
  212. package/skills/grokify/evals/README.md +72 -0
  213. package/skills/grokify/evals/parse-payload.test.mjs +171 -0
  214. package/skills/grokify/evals/run-capability-evals.mjs +545 -0
  215. package/skills/orchestrator/SKILL.md +42 -29
  216. package/skills/orchestrator-refresh/SKILL.md +17 -9
  217. package/skills/pr-converge/SKILL.md +34 -13
  218. package/skills/pr-converge/reference/convergence-gates.md +42 -15
  219. package/skills/pr-converge/reference/fix-protocol.md +1 -1
  220. package/skills/pr-converge/reference/ground-rules.md +1 -1
  221. package/skills/pr-converge/reference/per-tick.md +130 -42
  222. package/skills/pr-converge/reference/state-schema.md +10 -0
  223. package/skills/pr-converge/scripts/CLAUDE.md +2 -0
  224. package/skills/pr-converge/scripts/_pr_converge_path_setup.py +5 -1
  225. package/skills/pr-converge/scripts/check_convergence.py +605 -29
  226. package/skills/pr-converge/scripts/check_convergence_availability.py +232 -0
  227. package/skills/pr-converge/scripts/check_convergence_gates.py +279 -235
  228. package/skills/pr-converge/scripts/check_convergence_thread_gates.py +1 -1
  229. package/skills/pr-converge/scripts/pr_converge_scripts_constants/convergence_gate_constants.py +36 -2
  230. package/skills/pr-converge/scripts/test__pr_converge_path_setup.py +4 -0
  231. package/skills/pr-converge/scripts/test_check_convergence.py +71 -3
  232. package/skills/pr-converge/scripts/test_check_convergence_availability.py +326 -0
  233. package/skills/pr-converge/scripts/test_check_convergence_codex.py +507 -0
  234. package/skills/pr-converge/scripts/test_check_convergence_contract.py +89 -17
  235. package/skills/pr-converge/scripts/test_check_convergence_fixture.py +179 -0
  236. package/skills/pr-converge/scripts/test_check_convergence_gates.py +84 -68
  237. package/skills/pr-converge/scripts/test_check_convergence_thread_gates.py +24 -0
  238. package/skills/pr-converge/test_step5_host_branch.py +106 -0
  239. package/skills/pr-loop-cloud-transport/SKILL.md +2 -0
  240. package/skills/reviewer-gates/SKILL.md +7 -5
  241. package/skills/team-advisor/SKILL.md +7 -7
@@ -0,0 +1,331 @@
1
+ """Resolve the file sets the gate reads from git.
2
+
3
+ Every helper shells out to git with a scrubbed environment and null-terminated
4
+ output, so a filename holding a tab or newline round-trips unmangled. The path
5
+ readers turn ``-z`` streams into absolute paths; the staged helpers report the
6
+ added state and line span of one file.
7
+ """
8
+
9
+ import subprocess
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ from pr_loop_shared_constants.code_rules_gate_constants import (
14
+ ALL_GIT_DIFF_CACHED_NAME_ONLY_NULL_TERMINATED_COMMAND,
15
+ ALL_GIT_DIFF_NAME_ONLY_NULL_TERMINATED_COMMAND_PREFIX,
16
+ ALL_GIT_LS_FILES_UNTRACKED_NULL_TERMINATED_COMMAND,
17
+ GATE_ERROR_EXIT_CODE,
18
+ GIT_NAME_STATUS_ADDED_PREFIX,
19
+ )
20
+ from terminology_sweep import repository_environment
21
+
22
+ __all__ = [
23
+ "repository_environment",
24
+ "resolve_merge_base",
25
+ "paths_from_git_staged",
26
+ "paths_from_git_diff",
27
+ "paths_from_git_untracked",
28
+ "filter_paths_under_prefixes",
29
+ "is_staged_file_newly_added",
30
+ "staged_file_line_count",
31
+ "staged_unified_diff_text",
32
+ ]
33
+
34
+
35
+ def _git_text_or_exit(
36
+ repository_root: Path, all_git_arguments: list[str], failure_prefix: str
37
+ ) -> str:
38
+ """Run a git command in text mode, returning stdout or exiting on failure.
39
+
40
+ Args:
41
+ repository_root: Repository root used as the working directory.
42
+ all_git_arguments: The full git argv, including the ``git`` word.
43
+ failure_prefix: The stderr message prefix written on a non-zero exit.
44
+
45
+ Returns:
46
+ The command's stdout.
47
+
48
+ Raises:
49
+ SystemExit: When the git command returns non-zero.
50
+ """
51
+ completed = subprocess.run(
52
+ all_git_arguments,
53
+ cwd=str(repository_root),
54
+ capture_output=True,
55
+ text=True,
56
+ encoding="utf-8",
57
+ errors="replace",
58
+ check=False,
59
+ env=repository_environment(),
60
+ )
61
+ if completed.returncode != 0:
62
+ sys.stderr.write(f"{failure_prefix}:\n{completed.stderr}\n")
63
+ raise SystemExit(GATE_ERROR_EXIT_CODE)
64
+ return completed.stdout
65
+
66
+
67
+ def _git_bytes_or_exit(
68
+ repository_root: Path, all_git_arguments: list[str], failure_prefix: str
69
+ ) -> bytes:
70
+ """Run a git command in binary mode, returning stdout or exiting on failure.
71
+
72
+ Args:
73
+ repository_root: Repository root used as the working directory.
74
+ all_git_arguments: The full git argv, including the ``git`` word.
75
+ failure_prefix: The stderr message prefix written on a non-zero exit.
76
+
77
+ Returns:
78
+ The command's stdout bytes.
79
+
80
+ Raises:
81
+ SystemExit: When the git command returns non-zero.
82
+ """
83
+ completed = subprocess.run(
84
+ all_git_arguments,
85
+ cwd=str(repository_root),
86
+ capture_output=True,
87
+ check=False,
88
+ env=repository_environment(),
89
+ )
90
+ if completed.returncode != 0:
91
+ stderr_text = completed.stderr.decode("utf-8", errors="replace")
92
+ sys.stderr.write(f"{failure_prefix}:\n{stderr_text}\n")
93
+ raise SystemExit(GATE_ERROR_EXIT_CODE)
94
+ return completed.stdout
95
+
96
+
97
+ def _null_separated_paths(raw_stdout: bytes, repository_root: Path, skip_label: str) -> list[Path]:
98
+ """Turn a null-terminated git path stream into absolute paths.
99
+
100
+ Args:
101
+ raw_stdout: The ``-z`` git stdout bytes.
102
+ repository_root: Repository root each relative path is joined to.
103
+ skip_label: Word naming the source, shown in the skip message.
104
+
105
+ Returns:
106
+ Absolute paths; names whose bytes are not Unicode are logged and skipped.
107
+ """
108
+ resolved_paths: list[Path] = []
109
+ for each_raw_path in raw_stdout.split(b"\x00"):
110
+ if not each_raw_path:
111
+ continue
112
+ try:
113
+ relative_path = each_raw_path.decode("utf-8")
114
+ except UnicodeDecodeError:
115
+ sys.stderr.write(
116
+ f"code_rules_gate: skipping {skip_label} path with non-UTF-8 "
117
+ f"filename: {each_raw_path!r}\n"
118
+ )
119
+ continue
120
+ resolved_paths.append(repository_root / relative_path)
121
+ return resolved_paths
122
+
123
+
124
+ def resolve_merge_base(repository_root: Path, base_reference: str) -> str:
125
+ """Return the merge-base SHA between HEAD and *base_reference*.
126
+
127
+ Args:
128
+ repository_root: Repository root used as the ``git -C`` target.
129
+ base_reference: The git reference to merge-base against.
130
+
131
+ Returns:
132
+ The stripped merge-base SHA.
133
+
134
+ Raises:
135
+ SystemExit: When ``git merge-base`` returns non-zero.
136
+ """
137
+ stdout = _git_text_or_exit(
138
+ repository_root,
139
+ ["git", "merge-base", "HEAD", base_reference],
140
+ f"code_rules_gate: git merge-base HEAD {base_reference} failed",
141
+ )
142
+ return stdout.strip()
143
+
144
+
145
+ def paths_from_git_staged(repository_root: Path) -> list[Path]:
146
+ """Return absolute paths for every file in the staged index.
147
+
148
+ Args:
149
+ repository_root: Repository root used as the ``git -C`` target.
150
+
151
+ Returns:
152
+ Absolute paths for staged files, non-Unicode names skipped.
153
+
154
+ Raises:
155
+ SystemExit: When the staged name-only command returns non-zero.
156
+ """
157
+ raw_stdout = _git_bytes_or_exit(
158
+ repository_root,
159
+ list(ALL_GIT_DIFF_CACHED_NAME_ONLY_NULL_TERMINATED_COMMAND),
160
+ "code_rules_gate: git diff --cached --name-only -z failed",
161
+ )
162
+ return _null_separated_paths(raw_stdout, repository_root, "staged")
163
+
164
+
165
+ def paths_from_git_diff(repository_root: Path, base_reference: str) -> list[Path]:
166
+ """Return absolute paths for every file changed since *base_reference*.
167
+
168
+ Args:
169
+ repository_root: Repository root used as the ``git -C`` target.
170
+ base_reference: The git reference to merge-base against.
171
+
172
+ Returns:
173
+ Absolute paths changed since the merge-base of HEAD and *base_reference*.
174
+
175
+ Raises:
176
+ SystemExit: When the diff name-only command returns non-zero.
177
+ """
178
+ merge_base = resolve_merge_base(repository_root, base_reference)
179
+ diff_command = [
180
+ *ALL_GIT_DIFF_NAME_ONLY_NULL_TERMINATED_COMMAND_PREFIX,
181
+ f"{merge_base}..HEAD",
182
+ ]
183
+ raw_stdout = _git_bytes_or_exit(
184
+ repository_root,
185
+ diff_command,
186
+ "code_rules_gate: git diff --name-only -z failed",
187
+ )
188
+ return _null_separated_paths(raw_stdout, repository_root, "diff")
189
+
190
+
191
+ def paths_from_git_untracked(repository_root: Path) -> list[Path]:
192
+ """Return absolute paths for every untracked, non-ignored file.
193
+
194
+ Args:
195
+ repository_root: Repository root used as the ``git -C`` target.
196
+
197
+ Returns:
198
+ Absolute paths git reports as untracked and not git-ignored.
199
+
200
+ Raises:
201
+ SystemExit: When ``git ls-files --others`` returns non-zero.
202
+ """
203
+ raw_stdout = _git_bytes_or_exit(
204
+ repository_root,
205
+ list(ALL_GIT_LS_FILES_UNTRACKED_NULL_TERMINATED_COMMAND),
206
+ "code_rules_gate: git ls-files --others --exclude-standard -z failed",
207
+ )
208
+ return _null_separated_paths(raw_stdout, repository_root, "untracked")
209
+
210
+
211
+ def _normalized_prefixes(all_prefixes: list[str]) -> list[str]:
212
+ """Return the trimmed, slash-normalized, non-empty prefixes."""
213
+ return [
214
+ each_prefix.strip().replace("\\", "/").rstrip("/")
215
+ for each_prefix in all_prefixes
216
+ if each_prefix.strip()
217
+ ]
218
+
219
+
220
+ def _relative_posix_under_root(file_path: Path, resolved_root: Path) -> str | None:
221
+ """Return *file_path* relative to *resolved_root* as POSIX text, or None."""
222
+ try:
223
+ return file_path.resolve().relative_to(resolved_root).as_posix()
224
+ except ValueError:
225
+ return None
226
+
227
+
228
+ def _matches_any_prefix(relative_posix: str, all_normalized_prefixes: list[str]) -> bool:
229
+ """Return True when *relative_posix* equals or nests under a prefix."""
230
+ return any(
231
+ relative_posix == each_prefix or relative_posix.startswith(each_prefix + "/")
232
+ for each_prefix in all_normalized_prefixes
233
+ )
234
+
235
+
236
+ def filter_paths_under_prefixes(
237
+ all_file_paths: list[Path],
238
+ repository_root: Path,
239
+ all_prefixes: list[str],
240
+ ) -> list[Path]:
241
+ """Filter *all_file_paths* to entries under the supplied prefixes.
242
+
243
+ Args:
244
+ all_file_paths: Resolved file paths to filter.
245
+ repository_root: Repository root the relative paths are measured against.
246
+ all_prefixes: Repo-relative POSIX prefixes; a path passes when it
247
+ equals a prefix or is nested beneath it.
248
+
249
+ Returns:
250
+ The matching subset, or the input unchanged when no prefix is given.
251
+ """
252
+ all_normalized_prefixes = _normalized_prefixes(all_prefixes)
253
+ if not all_normalized_prefixes:
254
+ return all_file_paths
255
+ resolved_root = repository_root.resolve()
256
+ filtered: list[Path] = []
257
+ for each_path in all_file_paths:
258
+ relative_posix = _relative_posix_under_root(each_path, resolved_root)
259
+ if relative_posix is None:
260
+ continue
261
+ if _matches_any_prefix(relative_posix, all_normalized_prefixes):
262
+ filtered.append(each_path)
263
+ return filtered
264
+
265
+
266
+ def is_staged_file_newly_added(repository_root: Path, relative_path_posix: str) -> bool:
267
+ """Check whether *relative_path_posix* is newly added in the staged diff.
268
+
269
+ Args:
270
+ repository_root: Repository root used as the ``git -C`` target.
271
+ relative_path_posix: Repository-relative POSIX path to inspect.
272
+
273
+ Returns:
274
+ True when the first non-empty name-status line begins with ``A``.
275
+
276
+ Raises:
277
+ SystemExit: When the staged name-status command returns non-zero.
278
+ """
279
+ stdout = _git_text_or_exit(
280
+ repository_root,
281
+ ["git", "diff", "--cached", "--name-status", "--", relative_path_posix],
282
+ (f"code_rules_gate: git diff --cached --name-status failed for {relative_path_posix}"),
283
+ )
284
+ for each_line in stdout.splitlines():
285
+ stripped_line = each_line.strip()
286
+ if stripped_line:
287
+ return stripped_line.startswith(GIT_NAME_STATUS_ADDED_PREFIX)
288
+ return False
289
+
290
+
291
+ def staged_file_line_count(repository_root: Path, relative_path_posix: str) -> int:
292
+ """Return the staged-blob line count for *relative_path_posix*.
293
+
294
+ Args:
295
+ repository_root: Repository root used as the ``git -C`` target.
296
+ relative_path_posix: Repository-relative POSIX path of the staged file.
297
+
298
+ Returns:
299
+ The staged content line count, or zero when the blob is empty.
300
+
301
+ Raises:
302
+ SystemExit: When ``git show :<path>`` returns non-zero.
303
+ """
304
+ staged_content = _git_text_or_exit(
305
+ repository_root,
306
+ ["git", "show", f":{relative_path_posix}"],
307
+ f"code_rules_gate: git show :{relative_path_posix} failed",
308
+ )
309
+ if not staged_content:
310
+ return 0
311
+ return len(staged_content.splitlines())
312
+
313
+
314
+ def staged_unified_diff_text(repository_root: Path, relative_path_posix: str) -> str:
315
+ """Return the staged unified-zero diff text for one file.
316
+
317
+ Args:
318
+ repository_root: Repository root used as the ``git -C`` target.
319
+ relative_path_posix: Repository-relative POSIX path to diff.
320
+
321
+ Returns:
322
+ The ``git diff --cached --unified=0`` stdout for the file.
323
+
324
+ Raises:
325
+ SystemExit: When the staged diff command returns non-zero.
326
+ """
327
+ return _git_text_or_exit(
328
+ repository_root,
329
+ ["git", "diff", "--cached", "--unified=0", "--", relative_path_posix],
330
+ (f"code_rules_gate: git diff --cached --unified=0 failed for {relative_path_posix}"),
331
+ )
@@ -0,0 +1,369 @@
1
+ """Run the staged Python test files, grouped by their owning pytest config.
2
+
3
+ The commit gate runs each staged test file so a broken test blocks the commit.
4
+ Files are grouped by the nearest ancestor holding a pytest config, and each
5
+ group runs in its own pytest session with the working directory at that root, so
6
+ two packages exposing a same-named top-level package never shadow each other.
7
+
8
+ ``conftest.py`` paths are never passed as pytest collection targets: pytest loads
9
+ them automatically when nearby tests run, and collecting multiple bare confests
10
+ in one session collides on the shared module basename.
11
+ """
12
+
13
+ import os
14
+ import subprocess
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ from pr_loop_shared_constants.code_rules_gate_constants import (
19
+ ALL_POSIX_VENV_PYTHON_RELATIVE_PATH_SEGMENTS,
20
+ ALL_PYTEST_CONFIG_FILE_SECTIONS,
21
+ ALL_PYTEST_MODULE_INVOCATION,
22
+ ALL_VENV_DIRECTORY_NAMES,
23
+ ALL_WINDOWS_VENV_PYTHON_RELATIVE_PATH_SEGMENTS,
24
+ CODE_RULES_GATE_PYTHON_ENV_VAR,
25
+ CODE_RULES_GATE_PYTHONPATH_ENV_VAR,
26
+ COMMAND_LINE_ARGUMENT_SEPARATOR_LENGTH,
27
+ MAXIMUM_STAGED_PYTEST_COMMAND_LINE_CHARACTERS,
28
+ PYTHON_FILE_EXTENSION,
29
+ PYTHONPATH_ENV_VAR,
30
+ STAGED_PYTEST_TIMEOUT_SECONDS,
31
+ STAGED_TEST_FAILURE_HEADER,
32
+ STAGED_TEST_GROUP_FAILURE_MESSAGE,
33
+ TEST_CONFTEST_FILENAME,
34
+ )
35
+ from pr_loop_shared_constants.preflight_constants import (
36
+ PYTEST_NO_TESTS_COLLECTED_EXIT_CODE,
37
+ )
38
+ from terminology_sweep import repository_environment
39
+
40
+ from code_rules_gate_parts.git_file_sets import paths_from_git_staged
41
+ from code_rules_gate_parts.wrapper_plumb_check import is_test_path
42
+
43
+
44
+ def _is_conftest_path(file_path: Path) -> bool:
45
+ """Return True when *file_path* is a pytest ``conftest.py`` fixture module."""
46
+ return file_path.name == TEST_CONFTEST_FILENAME
47
+
48
+
49
+ def _pytest_target_paths(all_test_paths: list[Path]) -> list[Path]:
50
+ """Return staged test paths that pytest should collect as targets.
51
+
52
+ ::
53
+
54
+ ok: [pkg/test_a.py, pkg/conftest.py] -> [pkg/test_a.py]
55
+ ok: [a/conftest.py, b/conftest.py] -> []
56
+ flag: bare confests collected as targets share basename ``conftest``
57
+
58
+ Args:
59
+ all_test_paths: Staged paths that match the test-file classifier.
60
+
61
+ Returns:
62
+ The same paths with every ``conftest.py`` removed.
63
+ """
64
+ return [
65
+ each_path for each_path in all_test_paths if not _is_conftest_path(each_path)
66
+ ]
67
+
68
+
69
+ def _staged_test_file_paths(repository_root: Path) -> list[Path]:
70
+ """Return the staged Python test files that exist under a repository.
71
+
72
+ Args:
73
+ repository_root: The repository root whose staged index is read.
74
+
75
+ Returns:
76
+ Staged paths whose extension is Python, whose name matches a test-file
77
+ pattern, and which exist on disk.
78
+ """
79
+ all_test_paths: list[Path] = []
80
+ for each_path in paths_from_git_staged(repository_root):
81
+ if each_path.suffix != PYTHON_FILE_EXTENSION:
82
+ continue
83
+ if is_test_path(str(each_path)) and each_path.is_file():
84
+ all_test_paths.append(each_path)
85
+ return all_test_paths
86
+
87
+
88
+ def _directory_holds_pytest_config(directory: Path) -> bool:
89
+ """Return True when *directory* holds a recognized pytest configuration file."""
90
+ for each_filename, each_required_section in ALL_PYTEST_CONFIG_FILE_SECTIONS:
91
+ config_path = directory / each_filename
92
+ if not config_path.is_file():
93
+ continue
94
+ if each_required_section is None:
95
+ return True
96
+ try:
97
+ config_text = config_path.read_text(encoding="utf-8", errors="replace")
98
+ except OSError:
99
+ continue
100
+ if each_required_section in config_text:
101
+ return True
102
+ return False
103
+
104
+
105
+ def _resolve_owning_test_root(test_file_path: Path, repository_root: Path) -> Path:
106
+ """Return the nearest ancestor of *test_file_path* owning a pytest config.
107
+
108
+ Args:
109
+ test_file_path: The staged test file to resolve a root for.
110
+ repository_root: The repository root that bounds the upward walk.
111
+
112
+ Returns:
113
+ The resolved owning test root directory.
114
+ """
115
+ resolved_repository_root = repository_root.resolve()
116
+ for each_ancestor in test_file_path.resolve().parents:
117
+ if _directory_holds_pytest_config(each_ancestor):
118
+ return each_ancestor
119
+ if each_ancestor == resolved_repository_root:
120
+ return resolved_repository_root
121
+ return resolved_repository_root
122
+
123
+
124
+ def _group_staged_tests_by_root(
125
+ all_test_paths: list[Path], repository_root: Path
126
+ ) -> dict[Path, list[Path]]:
127
+ """Group *all_test_paths* by their owning pytest-config root.
128
+
129
+ Args:
130
+ all_test_paths: The staged test files to partition.
131
+ repository_root: The repository root that bounds each upward walk.
132
+
133
+ Returns:
134
+ A mapping from owning test root to the staged test files under it.
135
+ """
136
+ all_tests_by_root: dict[Path, list[Path]] = {}
137
+ for each_test_path in all_test_paths:
138
+ owning_root = _resolve_owning_test_root(each_test_path, repository_root)
139
+ all_tests_by_root.setdefault(owning_root, []).append(each_test_path)
140
+ return all_tests_by_root
141
+
142
+
143
+ def _venv_python_relative_path() -> tuple[str, ...]:
144
+ """Return the venv-relative python executable path segments for this platform."""
145
+ if os.name == "nt":
146
+ return ALL_WINDOWS_VENV_PYTHON_RELATIVE_PATH_SEGMENTS
147
+ return ALL_POSIX_VENV_PYTHON_RELATIVE_PATH_SEGMENTS
148
+
149
+
150
+ def _venv_python_executable(repository_root: Path) -> Path | None:
151
+ """Return the first existing venv interpreter under *repository_root*.
152
+
153
+ Args:
154
+ repository_root: The repository root to search for a venv directory.
155
+
156
+ Returns:
157
+ The resolved interpreter path, or None when no candidate exists.
158
+ """
159
+ relative_python_path = _venv_python_relative_path()
160
+ for each_venv_directory_name in ALL_VENV_DIRECTORY_NAMES:
161
+ candidate_path = repository_root.joinpath(each_venv_directory_name, *relative_python_path)
162
+ if candidate_path.is_file():
163
+ return candidate_path
164
+ return None
165
+
166
+
167
+ def _resolve_gate_python_executable(repository_root: Path) -> str:
168
+ """Return the interpreter the staged-test subprocess runs under.
169
+
170
+ Resolves the ``CODE_RULES_GATE_PYTHON`` variable, then a project venv
171
+ interpreter, then the interpreter running the gate.
172
+
173
+ Args:
174
+ repository_root: The repository root used to locate a project venv.
175
+
176
+ Returns:
177
+ The absolute path or command name of the interpreter to invoke.
178
+ """
179
+ configured_python = os.environ.get(CODE_RULES_GATE_PYTHON_ENV_VAR)
180
+ if configured_python:
181
+ return configured_python
182
+ venv_python = _venv_python_executable(repository_root)
183
+ if venv_python is not None:
184
+ return str(venv_python)
185
+ return sys.executable
186
+
187
+
188
+ def _staged_pytest_environment() -> dict[str, str]:
189
+ """Return the subprocess environment for the staged-test pytest run.
190
+
191
+ Returns:
192
+ The ``repository_environment`` mapping, with ``CODE_RULES_GATE_PYTHONPATH``
193
+ prepended to ``PYTHONPATH`` when it is set.
194
+ """
195
+ environment = repository_environment()
196
+ configured_pythonpath = os.environ.get(CODE_RULES_GATE_PYTHONPATH_ENV_VAR)
197
+ if not configured_pythonpath:
198
+ return environment
199
+ existing_pythonpath = environment.get(PYTHONPATH_ENV_VAR, "")
200
+ environment[PYTHONPATH_ENV_VAR] = (
201
+ configured_pythonpath
202
+ if not existing_pythonpath
203
+ else os.pathsep.join([configured_pythonpath, existing_pythonpath])
204
+ )
205
+ return environment
206
+
207
+
208
+ def _relative_pytest_argument(test_path: Path, group_root: Path) -> str:
209
+ """Express *test_path* as a pytest argument relative to *group_root*.
210
+
211
+ ::
212
+
213
+ group_root = /repo/package
214
+ ok: /repo/package/tests/test_a.py -> "tests/test_a.py"
215
+ flag: /elsewhere/test_b.py -> passed absolute
216
+
217
+ Args:
218
+ test_path: The staged test file to express as a pytest argument.
219
+ group_root: The pytest working directory for the group.
220
+
221
+ Returns:
222
+ The path relative to *group_root* when nested under it, else absolute.
223
+ """
224
+ try:
225
+ return str(test_path.relative_to(group_root))
226
+ except ValueError:
227
+ return str(test_path)
228
+
229
+
230
+ def _batched_pytest_arguments(
231
+ all_pytest_arguments: list[str], character_budget: int
232
+ ) -> list[list[str]]:
233
+ """Split pytest path arguments into command-line-length-safe batches.
234
+
235
+ Args:
236
+ all_pytest_arguments: The path arguments to distribute, in order.
237
+ character_budget: The joined length each batch stays within; an argument
238
+ wider than the budget lands in a batch by itself.
239
+
240
+ Returns:
241
+ One argument list per pytest invocation, preserving input order.
242
+ """
243
+ all_batches: list[list[str]] = []
244
+ current_batch: list[str] = []
245
+ current_length = 0
246
+ for each_argument in all_pytest_arguments:
247
+ argument_length = len(each_argument) + COMMAND_LINE_ARGUMENT_SEPARATOR_LENGTH
248
+ if current_batch and current_length + argument_length > character_budget:
249
+ all_batches.append(current_batch)
250
+ current_batch = []
251
+ current_length = 0
252
+ current_batch.append(each_argument)
253
+ current_length += argument_length
254
+ if current_batch:
255
+ all_batches.append(current_batch)
256
+ return all_batches
257
+
258
+
259
+ def _pytest_batch_exit_code(all_batch_command: list[str], group_root: Path) -> int:
260
+ """Run one pytest invocation and normalize its exit code.
261
+
262
+ Args:
263
+ all_batch_command: The full argv for this pytest invocation.
264
+ group_root: The owning test root used as the working directory.
265
+
266
+ Returns:
267
+ 0 when the batch passes or collects no tests; pytest's exit code
268
+ otherwise.
269
+ """
270
+ pytest_process = subprocess.run(
271
+ all_batch_command,
272
+ cwd=str(group_root),
273
+ timeout=STAGED_PYTEST_TIMEOUT_SECONDS,
274
+ check=False,
275
+ env=_staged_pytest_environment(),
276
+ )
277
+ if pytest_process.returncode == PYTEST_NO_TESTS_COLLECTED_EXIT_CODE:
278
+ return 0
279
+ return pytest_process.returncode
280
+
281
+
282
+ def _pytest_fixed_command(repository_root: Path) -> list[str]:
283
+ """Return the interpreter-and-pytest prefix shared by every batch."""
284
+ return [
285
+ _resolve_gate_python_executable(repository_root),
286
+ *ALL_PYTEST_MODULE_INVOCATION,
287
+ ]
288
+
289
+
290
+ def _first_failing_batch_exit_code(
291
+ all_fixed_command: list[str], all_batches: list[list[str]], group_root: Path
292
+ ) -> int:
293
+ """Run each batch and return the first non-zero pytest exit code, or zero."""
294
+ first_failing_exit_code = 0
295
+ for each_batch in all_batches:
296
+ batch_exit_code = _pytest_batch_exit_code([*all_fixed_command, *each_batch], group_root)
297
+ if batch_exit_code != 0 and first_failing_exit_code == 0:
298
+ first_failing_exit_code = batch_exit_code
299
+ return first_failing_exit_code
300
+
301
+
302
+ def _run_pytest_for_group(
303
+ group_root: Path, all_group_test_paths: list[Path], repository_root: Path
304
+ ) -> int:
305
+ """Run pytest over one group's test files, split into budget-safe batches.
306
+
307
+ Args:
308
+ group_root: The owning test root used as the pytest working directory.
309
+ all_group_test_paths: The staged test files that share *group_root*.
310
+ repository_root: The repository root used to resolve a project venv.
311
+
312
+ Returns:
313
+ 0 when every batch passes or collects nothing; the first failing batch's
314
+ exit code otherwise.
315
+ """
316
+ all_fixed_command = _pytest_fixed_command(repository_root)
317
+ fixed_command_length = sum(
318
+ len(each_part) + COMMAND_LINE_ARGUMENT_SEPARATOR_LENGTH for each_part in all_fixed_command
319
+ )
320
+ all_batches = _batched_pytest_arguments(
321
+ [_relative_pytest_argument(each_path, group_root) for each_path in all_group_test_paths],
322
+ MAXIMUM_STAGED_PYTEST_COMMAND_LINE_CHARACTERS - fixed_command_length,
323
+ )
324
+ return _first_failing_batch_exit_code(all_fixed_command, all_batches, group_root)
325
+
326
+
327
+ def _run_grouped_staged_tests(
328
+ all_tests_by_root: dict[Path, list[Path]], repository_root: Path
329
+ ) -> int:
330
+ """Run each staged test group and return the first failing exit code, or zero."""
331
+ first_failing_exit_code = 0
332
+ for each_group_root in sorted(all_tests_by_root):
333
+ group_exit_code = _run_pytest_for_group(
334
+ each_group_root, all_tests_by_root[each_group_root], repository_root
335
+ )
336
+ if group_exit_code == 0:
337
+ continue
338
+ sys.stderr.write(
339
+ STAGED_TEST_GROUP_FAILURE_MESSAGE.format(group_root=each_group_root) + "\n"
340
+ )
341
+ if first_failing_exit_code == 0:
342
+ first_failing_exit_code = group_exit_code
343
+ return first_failing_exit_code
344
+
345
+
346
+ def run_staged_test_files(repository_root: Path) -> int:
347
+ """Run pytest over the staged test files and return the gate exit code.
348
+
349
+ ``conftest.py`` files are excluded from collection targets. Pytest still
350
+ loads them automatically when a nearby staged test runs under the same
351
+ owning root.
352
+
353
+ Args:
354
+ repository_root: The repository root the staged test files belong to.
355
+
356
+ Returns:
357
+ 0 when no collectable test file is staged, when every group collects no
358
+ tests, or when every group passes. The first failing group's exit code
359
+ otherwise.
360
+ """
361
+ all_test_paths = _staged_test_file_paths(repository_root)
362
+ all_pytest_targets = _pytest_target_paths(all_test_paths)
363
+ if not all_pytest_targets:
364
+ return 0
365
+ all_tests_by_root = _group_staged_tests_by_root(all_pytest_targets, repository_root)
366
+ first_failing_exit_code = _run_grouped_staged_tests(all_tests_by_root, repository_root)
367
+ if first_failing_exit_code != 0:
368
+ sys.stderr.write(STAGED_TEST_FAILURE_HEADER + "\n")
369
+ return first_failing_exit_code