py-wlcli 0.8.0__py3-none-any.whl

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 (320) hide show
  1. py_wlcli-0.8.0.dist-info/METADATA +139 -0
  2. py_wlcli-0.8.0.dist-info/RECORD +320 -0
  3. py_wlcli-0.8.0.dist-info/WHEEL +4 -0
  4. py_wlcli-0.8.0.dist-info/entry_points.txt +2 -0
  5. py_wlcli-0.8.0.dist-info/licenses/LICENSE +21 -0
  6. wl_cli/__init__.py +27 -0
  7. wl_cli/__main__.py +130 -0
  8. wl_cli/capabilities.py +79 -0
  9. wl_cli/cc/__init__.py +5 -0
  10. wl_cli/cc/checks.py +1019 -0
  11. wl_cli/cc/cli.py +370 -0
  12. wl_cli/cc/mutate.py +1350 -0
  13. wl_cli/cli_click.py +156 -0
  14. wl_cli/commands/__init__.py +84 -0
  15. wl_cli/commands/base.py +80 -0
  16. wl_cli/commands/buildcommands/__init__.py +1 -0
  17. wl_cli/commands/buildcommands/build.py +71 -0
  18. wl_cli/commands/buildcommands/build_utils.py +47 -0
  19. wl_cli/commands/buildcommands/builders/__init__.py +18 -0
  20. wl_cli/commands/buildcommands/builders/cleanup.py +35 -0
  21. wl_cli/commands/buildcommands/builders/coordinator.py +122 -0
  22. wl_cli/commands/buildcommands/builders/environment.py +81 -0
  23. wl_cli/commands/buildcommands/builders/rust_builder.py +39 -0
  24. wl_cli/commands/buildcommands/builders/stubs.py +58 -0
  25. wl_cli/commands/buildcommands/dist.py +326 -0
  26. wl_cli/commands/buildcommands/test.py +89 -0
  27. wl_cli/commands/clean/__init__.py +1 -0
  28. wl_cli/commands/clean/build.py +151 -0
  29. wl_cli/commands/clean/clean.py +294 -0
  30. wl_cli/commands/clean/clean_utils.py +77 -0
  31. wl_cli/commands/clean/core.py +236 -0
  32. wl_cli/commands/clean/dead_code/__init__.py +0 -0
  33. wl_cli/commands/clean/dead_code/dead_code_command.py +70 -0
  34. wl_cli/commands/clean/dead_code/detector.py +161 -0
  35. wl_cli/commands/clean/dead_code/fixer.py +176 -0
  36. wl_cli/commands/clean/dead_code/reporter.py +295 -0
  37. wl_cli/commands/clean/dead_code/utils.py +0 -0
  38. wl_cli/commands/clean/lfs.py +215 -0
  39. wl_cli/commands/clean/python.py +81 -0
  40. wl_cli/commands/clean/rust.py +91 -0
  41. wl_cli/commands/clean/utils/__init__.py +34 -0
  42. wl_cli/commands/clean/venv.py +91 -0
  43. wl_cli/commands/cleanup.py +73 -0
  44. wl_cli/commands/click_base.py +79 -0
  45. wl_cli/commands/config/__init__.py +1 -0
  46. wl_cli/commands/config/config.py +291 -0
  47. wl_cli/commands/debug/__init__.py +6 -0
  48. wl_cli/commands/debug/debug.py +309 -0
  49. wl_cli/commands/debug/debug_cache.py +86 -0
  50. wl_cli/commands/debug/debug_subagent.py +156 -0
  51. wl_cli/commands/debug/output_formatter.py +165 -0
  52. wl_cli/commands/debug/source_hasher.py +92 -0
  53. wl_cli/commands/format/__init__.py +13 -0
  54. wl_cli/commands/format/format_cache_adapter.py +125 -0
  55. wl_cli/commands/format/format_command.py +197 -0
  56. wl_cli/commands/format/format_coordinator.py +191 -0
  57. wl_cli/commands/format/format_executor.py +470 -0
  58. wl_cli/commands/format/format_outcome.py +44 -0
  59. wl_cli/commands/format/format_path_handler.py +1016 -0
  60. wl_cli/commands/format/format_progress.py +62 -0
  61. wl_cli/commands/format/format_reporter.py +31 -0
  62. wl_cli/commands/format/python_formatter.py +506 -0
  63. wl_cli/commands/format/rust_formatter.py +106 -0
  64. wl_cli/commands/gate/__init__.py +1 -0
  65. wl_cli/commands/gate/gate_command.py +862 -0
  66. wl_cli/commands/gate/preflight.py +445 -0
  67. wl_cli/commands/git/__init__.py +1 -0
  68. wl_cli/commands/git/config.py +169 -0
  69. wl_cli/commands/git/git_facts.py +60 -0
  70. wl_cli/commands/git/git_flow.py +86 -0
  71. wl_cli/commands/git/hook.py +112 -0
  72. wl_cli/commands/git/policy.py +216 -0
  73. wl_cli/commands/git/push_update.py +82 -0
  74. wl_cli/commands/initenv/__init__.py +1 -0
  75. wl_cli/commands/initenv/common/__init__.py +23 -0
  76. wl_cli/commands/initenv/common/check_git_bash.py +78 -0
  77. wl_cli/commands/initenv/common/error_handler.py +68 -0
  78. wl_cli/commands/initenv/common/exceptions.py +52 -0
  79. wl_cli/commands/initenv/components/__init__.py +4 -0
  80. wl_cli/commands/initenv/components/cargo_sync.py +240 -0
  81. wl_cli/commands/initenv/components/config_manager.py +106 -0
  82. wl_cli/commands/initenv/components/directory_utils.py +71 -0
  83. wl_cli/commands/initenv/components/git/__init__.py +9 -0
  84. wl_cli/commands/initenv/components/git/branch_creator.py +143 -0
  85. wl_cli/commands/initenv/components/git/git_flow_interface.py +24 -0
  86. wl_cli/commands/initenv/components/git/git_flow_manager.py +159 -0
  87. wl_cli/commands/initenv/components/git/git_lfs_manager.py +272 -0
  88. wl_cli/commands/initenv/components/git/git_utility.py +254 -0
  89. wl_cli/commands/initenv/components/git/gitignore_manager.py +53 -0
  90. wl_cli/commands/initenv/components/git_initializer.py +212 -0
  91. wl_cli/commands/initenv/components/i18n_manager.py +104 -0
  92. wl_cli/commands/initenv/components/log_manager.py +241 -0
  93. wl_cli/commands/initenv/components/project_structure/__init__.py +23 -0
  94. wl_cli/commands/initenv/components/project_structure/config_generator.py +360 -0
  95. wl_cli/commands/initenv/components/project_structure/directory_creator.py +184 -0
  96. wl_cli/commands/initenv/components/project_structure/hook_source.py +110 -0
  97. wl_cli/commands/initenv/components/project_structure/hooks_manager.py +995 -0
  98. wl_cli/commands/initenv/components/project_structure/readme_handler.py +56 -0
  99. wl_cli/commands/initenv/components/project_structure/rust_files_handler.py +30 -0
  100. wl_cli/commands/initenv/components/project_structure/setup_handler.py +103 -0
  101. wl_cli/commands/initenv/components/project_structure/sop_generator.py +73 -0
  102. wl_cli/commands/initenv/components/project_structure/template_copier.py +104 -0
  103. wl_cli/commands/initenv/components/project_structure/template_manager.py +195 -0
  104. wl_cli/commands/initenv/components/pyproject_generator.py +194 -0
  105. wl_cli/commands/initenv/components/rust_initializer.py +145 -0
  106. wl_cli/commands/initenv/components/symlink_cleaner.py +99 -0
  107. wl_cli/commands/initenv/components/symlink_manager.py +52 -0
  108. wl_cli/commands/initenv/components/venv_manager.py +118 -0
  109. wl_cli/commands/initenv/initenv.py +169 -0
  110. wl_cli/commands/initenv/services/__init__.py +11 -0
  111. wl_cli/commands/initenv/services/environment_initializer.py +288 -0
  112. wl_cli/commands/initenv/services/git_lfs_initializer.py +26 -0
  113. wl_cli/commands/initenv/services/project_structure_initializer.py +52 -0
  114. wl_cli/commands/lfs/README.md +221 -0
  115. wl_cli/commands/lfs/__init__.py +5 -0
  116. wl_cli/commands/lfs/cli.py +106 -0
  117. wl_cli/commands/lfs/config.py +164 -0
  118. wl_cli/commands/lfs/hook.py +61 -0
  119. wl_cli/commands/lfs/initializer.py +531 -0
  120. wl_cli/commands/lfs/manager.py +95 -0
  121. wl_cli/commands/lfs/policy.py +165 -0
  122. wl_cli/commands/lfs/watcher.py +294 -0
  123. wl_cli/commands/lint/__init__.py +1 -0
  124. wl_cli/commands/lint/lint_command.py +297 -0
  125. wl_cli/commands/lint/lint_executor.py +409 -0
  126. wl_cli/commands/lint/lint_reporter.py +579 -0
  127. wl_cli/commands/lint/lint_run.py +258 -0
  128. wl_cli/commands/lint/mypy_config.py +90 -0
  129. wl_cli/commands/registry.py +103 -0
  130. wl_cli/commands/self/__init__.py +7 -0
  131. wl_cli/commands/self/common/__init__.py +8 -0
  132. wl_cli/commands/self/common/capabilities.py +30 -0
  133. wl_cli/commands/self/common/config.py +27 -0
  134. wl_cli/commands/self/common/version_cache.py +85 -0
  135. wl_cli/commands/self/handlers/__init__.py +19 -0
  136. wl_cli/commands/self/handlers/base_handler.py +28 -0
  137. wl_cli/commands/self/handlers/install_handler.py +85 -0
  138. wl_cli/commands/self/handlers/status_handler.py +66 -0
  139. wl_cli/commands/self/handlers/uninstall_handler.py +58 -0
  140. wl_cli/commands/self/handlers/update_handler.py +102 -0
  141. wl_cli/commands/self/handlers/version_handler.py +16 -0
  142. wl_cli/commands/self/self_command.py +237 -0
  143. wl_cli/commands/self/services/__init__.py +15 -0
  144. wl_cli/commands/self/services/installation_manager.py +187 -0
  145. wl_cli/commands/self/services/local_installer.py +165 -0
  146. wl_cli/commands/self/services/pypi_updater.py +278 -0
  147. wl_cli/commands/self/services/windows_update.py +174 -0
  148. wl_cli/commands/testcommands/__init__.py +8 -0
  149. wl_cli/commands/testcommands/base_dependency_analyzer.py +198 -0
  150. wl_cli/commands/testcommands/batch_processor.py +278 -0
  151. wl_cli/commands/testcommands/cache_manager.py +1156 -0
  152. wl_cli/commands/testcommands/coverage_analyzer.py +955 -0
  153. wl_cli/commands/testcommands/coverage_cache_manager.py +183 -0
  154. wl_cli/commands/testcommands/progress_manager.py +79 -0
  155. wl_cli/commands/testcommands/python_env_detector.py +207 -0
  156. wl_cli/commands/testcommands/report_generator.py +101 -0
  157. wl_cli/commands/testcommands/single_test_executor.py +250 -0
  158. wl_cli/commands/testcommands/speed_checker.py +118 -0
  159. wl_cli/commands/testcommands/test_command_builder.py +165 -0
  160. wl_cli/commands/testcommands/test_commands.py +786 -0
  161. wl_cli/commands/testcommands/test_reporter.py +181 -0
  162. wl_cli/commands/testcommands/test_result_handler.py +96 -0
  163. wl_cli/commands/testcommands/test_runner.py +1343 -0
  164. wl_cli/commands/testcommands/test_utils.py +197 -0
  165. wl_cli/commands/testcommands/utils/__init__.py +17 -0
  166. wl_cli/commands/testcommands/utils/coverage_parser.py +98 -0
  167. wl_cli/commands/testcommands/utils/env_info.py +36 -0
  168. wl_cli/commands/testcommands/utils/junit_parser.py +75 -0
  169. wl_cli/commands/testcommands/utils/markdown_utils.py +20 -0
  170. wl_cli/commands/testcommands/utils/project_root.py +61 -0
  171. wl_cli/commands/update/__init__.py +9 -0
  172. wl_cli/commands/update/services/__init__.py +21 -0
  173. wl_cli/commands/update/services/backup_manager.py +207 -0
  174. wl_cli/commands/update/services/component_manager.py +83 -0
  175. wl_cli/commands/update/services/conflict_resolver.py +189 -0
  176. wl_cli/commands/update/services/vendors_updater.py +85 -0
  177. wl_cli/commands/update/services/version_tracker.py +127 -0
  178. wl_cli/commands/update/update_command.py +270 -0
  179. wl_cli/commands/update/utils/__init__.py +3 -0
  180. wl_cli/commands/version/__init__.py +9 -0
  181. wl_cli/commands/version/version_command.py +155 -0
  182. wl_cli/commands/workflow.py +477 -0
  183. wl_cli/env/__init__.py +5 -0
  184. wl_cli/env/cli.py +167 -0
  185. wl_cli/env_status.py +157 -0
  186. wl_cli/envelope.py +91 -0
  187. wl_cli/exceptions.py +85 -0
  188. wl_cli/lib/__init__.py +3 -0
  189. wl_cli/lib/rust_utils.py +35 -0
  190. wl_cli/mcp/__init__.py +6 -0
  191. wl_cli/mcp/capabilities.py +19 -0
  192. wl_cli/mcp/cli.py +22 -0
  193. wl_cli/mcp/gitlab.py +553 -0
  194. wl_cli/mcp/k8s.py +344 -0
  195. wl_cli/mcp/protocol.py +159 -0
  196. wl_cli/platform.py +139 -0
  197. wl_cli/quality/__init__.py +18 -0
  198. wl_cli/utils/__init__.py +7 -0
  199. wl_cli/utils/cache.py +491 -0
  200. wl_cli/utils/cache_ledger.py +355 -0
  201. wl_cli/utils/cache_store.py +546 -0
  202. wl_cli/utils/config/__init__.py +27 -0
  203. wl_cli/utils/config/config_loader.py +163 -0
  204. wl_cli/utils/config/core.py +862 -0
  205. wl_cli/utils/config/defaults.py +142 -0
  206. wl_cli/utils/config/env_exporter.py +86 -0
  207. wl_cli/utils/config/env_parser.py +108 -0
  208. wl_cli/utils/config/validation.py +280 -0
  209. wl_cli/utils/error_handler.py +165 -0
  210. wl_cli/utils/file_copier.py +56 -0
  211. wl_cli/utils/file_operations.py +506 -0
  212. wl_cli/utils/language_utils.py +122 -0
  213. wl_cli/utils/lazy_import.py +240 -0
  214. wl_cli/utils/log_levels.py +35 -0
  215. wl_cli/utils/log_rotators.py +74 -0
  216. wl_cli/utils/logging.py +458 -0
  217. wl_cli/utils/output_context.py +142 -0
  218. wl_cli/utils/performance/__init__.py +1 -0
  219. wl_cli/utils/performance/decorators.py +111 -0
  220. wl_cli/utils/performance/exporter.py +38 -0
  221. wl_cli/utils/performance/models.py +16 -0
  222. wl_cli/utils/performance/monitor.py +161 -0
  223. wl_cli/utils/performance_monitor.py +32 -0
  224. wl_cli/utils/platform_adapter.py +131 -0
  225. wl_cli/utils/pre_commit.py +266 -0
  226. wl_cli/utils/pre_commit_config.py +72 -0
  227. wl_cli/utils/root.py +521 -0
  228. wl_cli/utils/root_fallback.py +256 -0
  229. wl_cli/utils/run_evidence.py +236 -0
  230. wl_cli/utils/structured_logger.py +18 -0
  231. wl_cli/utils/structured_logging/__init__.py +19 -0
  232. wl_cli/utils/structured_logging/config.py +337 -0
  233. wl_cli/utils/structured_logging/core.py +177 -0
  234. wl_cli/utils/structured_logging/env.py +56 -0
  235. wl_cli/utils/structured_logging/filtering.py +75 -0
  236. wl_cli/utils/structured_logging/filters.py +55 -0
  237. wl_cli/utils/structured_logging/formatters.py +251 -0
  238. wl_cli/utils/structured_logging/handlers.py +308 -0
  239. wl_cli/utils/structured_logging/logger.py +354 -0
  240. wl_cli/utils/subprocess.py +71 -0
  241. wl_cli/utils/subprocess_utils.py +260 -0
  242. wl_cli/utils/test_utils/__init__.py +5 -0
  243. wl_cli/utils/test_utils/test_filter.py +36 -0
  244. wl_cli/utils/uv_run.py +79 -0
  245. wl_cli/utils/uv_tool.py +117 -0
  246. wl_cli/utils/uv_tree.py +71 -0
  247. wl_cli/utils/uv_workspace.py +174 -0
  248. wl_cli/utils/version/__init__.py +15 -0
  249. wl_cli/utils/version/pypi_version_checker.py +95 -0
  250. wl_cli/utils/version/version_comparator.py +133 -0
  251. wl_cli/utils/version/version_detectors.py +312 -0
  252. wl_cli/utils/version/version_service.py +131 -0
  253. wl_cli/utils/version/version_updater.py +198 -0
  254. wl_cli/utils/wl_dir_updater.py +1440 -0
  255. wl_cli/utils/workspace_detector/__init__.py +28 -0
  256. wl_cli/utils/workspace_detector/core.py +161 -0
  257. wl_cli/utils/workspace_detector/detection_strategies.py +107 -0
  258. wl_cli/utils/workspace_detector/exceptions.py +5 -0
  259. wl_cli/utils/workspace_detector/types.py +17 -0
  260. wl_cli/utils/workspace_detector/venv_resolver.py +84 -0
  261. wl_cli/vendors/config/.codespellrc +2 -0
  262. wl_cli/vendors/config/dependencies.toml +10 -0
  263. wl_cli/vendors/config/mypy.ini +62 -0
  264. wl_cli/vendors/config/pyproject_defaults.toml +28 -0
  265. wl_cli/vendors/config/template_config.toml +24 -0
  266. wl_cli/vendors/config/tool_configs.toml +76 -0
  267. wl_cli/vendors/git/.gitignore +122 -0
  268. wl_cli/vendors/git/.pre-commit-config.yaml +37 -0
  269. wl_cli/vendors/hooks/applypatch-msg.sample +15 -0
  270. wl_cli/vendors/hooks/commit-msg.sample +24 -0
  271. wl_cli/vendors/hooks/fsmonitor-watchman.sample +174 -0
  272. wl_cli/vendors/hooks/post-update.sample +8 -0
  273. wl_cli/vendors/hooks/pre-applypatch.sample +14 -0
  274. wl_cli/vendors/hooks/pre-commit +45 -0
  275. wl_cli/vendors/hooks/pre-commit.legacy +31 -0
  276. wl_cli/vendors/hooks/pre-commit.sample +49 -0
  277. wl_cli/vendors/hooks/pre-merge-commit.sample +13 -0
  278. wl_cli/vendors/hooks/pre-push.sample +60 -0
  279. wl_cli/vendors/hooks/pre-rebase.sample +169 -0
  280. wl_cli/vendors/hooks/pre-receive.sample +24 -0
  281. wl_cli/vendors/hooks/prepare-commit-msg.sample +42 -0
  282. wl_cli/vendors/hooks/push-to-checkout.sample +78 -0
  283. wl_cli/vendors/hooks/sendemail-validate.sample +77 -0
  284. wl_cli/vendors/hooks/unix/applypatch-msg.sample +15 -0
  285. wl_cli/vendors/hooks/unix/check_lockfile.py +58 -0
  286. wl_cli/vendors/hooks/unix/check_lockfile.sh +36 -0
  287. wl_cli/vendors/hooks/unix/commit-msg.sample +24 -0
  288. wl_cli/vendors/hooks/unix/fsmonitor-watchman.sample +174 -0
  289. wl_cli/vendors/hooks/unix/post-update.sample +8 -0
  290. wl_cli/vendors/hooks/unix/pre-applypatch.sample +14 -0
  291. wl_cli/vendors/hooks/unix/pre-commit +190 -0
  292. wl_cli/vendors/hooks/unix/pre-commit.legacy +31 -0
  293. wl_cli/vendors/hooks/unix/pre-commit.py +81 -0
  294. wl_cli/vendors/hooks/unix/pre-commit.sample +49 -0
  295. wl_cli/vendors/hooks/unix/pre-merge-commit.sample +13 -0
  296. wl_cli/vendors/hooks/unix/pre-push +417 -0
  297. wl_cli/vendors/hooks/unix/pre-push.sample +53 -0
  298. wl_cli/vendors/hooks/unix/pre-rebase.sample +169 -0
  299. wl_cli/vendors/hooks/unix/pre-receive.sample +24 -0
  300. wl_cli/vendors/hooks/unix/prepare-commit-msg.sample +42 -0
  301. wl_cli/vendors/hooks/update.sample +128 -0
  302. wl_cli/vendors/hooks/win/work.bat +63 -0
  303. wl_cli/vendors/project/.gitignore +90 -0
  304. wl_cli/vendors/project/.python-version +1 -0
  305. wl_cli/vendors/project/LICENSE +21 -0
  306. wl_cli/vendors/project/README.md +0 -0
  307. wl_cli/vendors/project/pyproject.toml +27 -0
  308. wl_cli/vendors/project/pyproject_no_workspace.toml +16 -0
  309. wl_cli/vendors/project/pyproject_workspace.toml +14 -0
  310. wl_cli/vendors/readme/README.md +120 -0
  311. wl_cli/vendors/rust/Cargo.toml +14 -0
  312. wl_cli/vendors/rust/lib/__init__.py +3 -0
  313. wl_cli/vendors/rust/lib/rust_utils.py +31 -0
  314. wl_cli/vendors/rust/src/lib.rs +13 -0
  315. wl_cli/vendors/rust/src/main.rs +3 -0
  316. wl_cli/vendors/rust/tests/test_rust_fallback.py +40 -0
  317. wl_cli/vendors/sop_doc/git-dev-sop.md +220 -0
  318. wl_cli/vendors/sop_doc/industrial-standard-sop.md +145 -0
  319. wl_cli/vendors/sop_doc/python-sop.md +342 -0
  320. wl_cli/vendors/sop_doc/rust-readme.md +219 -0
@@ -0,0 +1,445 @@
1
+ """``wl gate`` step 0 -- preflight: can the chain actually run on this machine?
2
+
3
+ The gate only earns its keep as a *trustworthy* local signal if its red always
4
+ means "your change is bad" and never "this machine is incomplete" (issue #98).
5
+ So before the four-step chain runs, ``wl gate`` checks -- read-only -- that the
6
+ **subjects it is about to execute** are actually available. If anything is
7
+ missing it fails fast with a named, actionable error and a distinct exit code,
8
+ and the chain is never entered, so no phantom failure lines are produced.
9
+
10
+ The subject is the whole point (review F1). The chain never runs
11
+ ``sys.executable``: it runs the bare ``ruff`` / ``mypy`` / ``wl`` that PATH
12
+ resolves for it, and the pytest plugins inside whatever interpreter the test
13
+ runner picks. Probing ``sys.executable`` answered a different question, which
14
+ produced both a false red (a globally installed ``wl`` carries no project test
15
+ extras, so the gate refused to run a chain that would have worked) and a false
16
+ green (a bare PATH failure surfaced as ``ruff check - FAILED (exit -1)`` and an
17
+ undocumented exit 255).
18
+
19
+ So nothing here is hand-written twice: the executable list is derived from the
20
+ chain's own ``build_steps()``, the plugin interpreter from the test step's own
21
+ declared ``cwd`` fed to :class:`PythonEnvDetector`, and the PATH the executables
22
+ are resolved against from the executor that will run them
23
+ (``SubprocessExecutor.subprocess_path`` -- the chain does *not* necessarily
24
+ search this process' own ``PATH``, because uv-tool runs have it rewritten).
25
+ :func:`evaluate` stays a pure function over observations so it can be
26
+ unit-tested without installing anything; :func:`observe` is the only part that
27
+ touches the machine, and it only looks things up.
28
+
29
+ A step that is *killed by a signal* is a different animal from a missing
30
+ subject, and is reported as such (review N1). Such a step ran -- PATH resolved
31
+ its executable and it was launched -- and then died, so it gets its own message
32
+ and the shell's ``128 + signum`` exit code, never the environment-gap code.
33
+ Collapsing the two, as the old fallback did, told the reader to go fix the
34
+ machine when the cause could just as well be the change under test (a suite
35
+ that grows until the runner is OOM-killed is one signal away from this path).
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import contextlib
41
+ import io
42
+ import shutil
43
+ import signal
44
+ import subprocess
45
+ from dataclasses import dataclass
46
+ from typing import TYPE_CHECKING
47
+
48
+ from ...utils.subprocess_utils import SubprocessExecutor
49
+ from ..testcommands.python_env_detector import PythonEnvDetector
50
+
51
+ if TYPE_CHECKING:
52
+ from collections.abc import Mapping, Sequence
53
+ from pathlib import Path
54
+
55
+ from .gate_command import GateStep
56
+
57
+ EXIT_ENV_MISSING = 78
58
+ """Exit code for "the environment cannot run the chain".
59
+
60
+ Chosen so it cannot collide with any code the four-step chain produces on its
61
+ own (``ruff check``/``ruff format``/``mypy``: 0-2; pytest: 0-5, including the
62
+ ``INTERNALERROR`` code 3 that a broken coverage environment yields; Click usage
63
+ errors: 2). Callers can therefore tell "go fix the environment" apart from "go
64
+ fix your change" mechanically. 78 is ``EX_CONFIG`` from ``sysexits.h`` -- a
65
+ configuration (here: environment) error.
66
+
67
+ There is no third exit path: a step whose executable cannot even be launched is
68
+ reported through this same code (:func:`render_launch_error`) rather than
69
+ leaking the executor's sentinel ``-1``, which ``sys.exit`` would turn into an
70
+ undocumented 255.
71
+
72
+ A step that was *launched and then killed by a signal* is deliberately **not**
73
+ reported through this code: see :data:`EXIT_SIGNALLED_BASE`.
74
+ """
75
+
76
+ EXIT_SIGNALLED_BASE = 128
77
+ """Base for "a gate step was terminated by a signal" exit codes.
78
+
79
+ A signalled process is not an environment gap. The step ran -- PATH resolved
80
+ its executable and it was launched -- and then died, so its code follows the
81
+ POSIX shell convention ``128 + signum`` (SIGKILL(9) -> 137, SIGTERM(15) -> 143).
82
+ Because ``signum >= 1``, every such code is ``>= 129`` and can therefore never
83
+ collide with :data:`EXIT_ENV_MISSING` (78) or with any step's own code
84
+ (``ruff``/``mypy``/``format``: 0-2; pytest: 0-5). Callers can thus tell "go fix
85
+ the environment" (78) apart from "this step, and possibly your change, died"
86
+ (``128 + N``) mechanically.
87
+ """
88
+
89
+
90
+ def signal_exit_code(signum: int) -> int:
91
+ """The exit code a POSIX shell reports for a process killed by *signum*."""
92
+ return EXIT_SIGNALLED_BASE + signum
93
+
94
+
95
+ FIX_COMMAND = 'uv pip install -e ".[dev,test]"'
96
+ """Provisions the ``test`` extra's plugins (the module requirements below)."""
97
+
98
+
99
+ @dataclass(frozen=True)
100
+ class Requirement:
101
+ """One thing the chain needs, and how to check for it.
102
+
103
+ ``kind`` decides how :func:`observe` answers:
104
+
105
+ * ``"binary"`` -- the chain will exec this token, so PATH must resolve it.
106
+ * ``"module"`` -- the chain's test runner will import this name, so it must
107
+ import inside the interpreter that runner picks.
108
+ """
109
+
110
+ label: str
111
+ """User-facing name, as reported when it is missing."""
112
+
113
+ key: str
114
+ """Executable token (binary) or import name (module)."""
115
+
116
+ kind: str
117
+ """Either ``"binary"`` or ``"module"``."""
118
+
119
+ source: str
120
+ """Where the requirement comes from, for the error message."""
121
+
122
+ fix: str
123
+ """The command or action that supplies it."""
124
+
125
+
126
+ #: The pytest plugins the chain's test step imports. These ride in the project's
127
+ #: ``test`` extra, so they are probed as *modules* -- in the interpreter the test
128
+ #: runner will use, never in the interpreter driving the gate.
129
+ MODULE_REQUIREMENTS: tuple[Requirement, ...] = (
130
+ Requirement(
131
+ "pytest",
132
+ "pytest",
133
+ "module",
134
+ "imported by the pytest step",
135
+ FIX_COMMAND,
136
+ ),
137
+ Requirement(
138
+ "pytest-cov",
139
+ "pytest_cov",
140
+ "module",
141
+ "imported by the pytest step",
142
+ FIX_COMMAND,
143
+ ),
144
+ Requirement(
145
+ "pytest-asyncio",
146
+ "pytest_asyncio",
147
+ "module",
148
+ "imported by the pytest step",
149
+ FIX_COMMAND,
150
+ ),
151
+ )
152
+
153
+
154
+ @dataclass(frozen=True)
155
+ class PreflightReport:
156
+ """The verdict: which requirements (if any) this machine cannot supply."""
157
+
158
+ subjects: tuple[str, ...]
159
+ """What was probed, for the error message."""
160
+
161
+ required: tuple[Requirement, ...]
162
+ """The rule set that was applied."""
163
+
164
+ missing: tuple[Requirement, ...]
165
+
166
+ @property
167
+ def ok(self) -> bool:
168
+ """True when every requirement is available."""
169
+ return not self.missing
170
+
171
+
172
+ def requirements_for(steps: Sequence[GateStep]) -> tuple[Requirement, ...]:
173
+ """Derive what the chain will execute, from the chain itself.
174
+
175
+ Single source of truth: the executable tokens come out of the very step
176
+ commands that will be run, so the two can never drift apart.
177
+
178
+ A step that will be **skipped** contributes nothing (rv193/M2). The
179
+ pre-commit leg declares the managed config it needs; when that file is
180
+ absent the step is jumped over at execution time, so demanding its
181
+ executable here would refuse to start a chain whose hook leg was never
182
+ going to launch anything -- a 78 over a leg the gate itself skips. The
183
+ judgement is *not* re-made here: it is :attr:`GateStep.is_skipped`, the same
184
+ property the executing loop reads, so the two cannot disagree about which
185
+ steps the chain will run.
186
+ """
187
+ provenance: dict[str, list[str]] = {}
188
+ for index, step in enumerate(steps, start=1):
189
+ if step.is_skipped:
190
+ continue
191
+ provenance.setdefault(step.command[0], []).append(f"step {index} ({step.name})")
192
+
193
+ binaries = tuple(
194
+ Requirement(
195
+ label=token,
196
+ key=token,
197
+ kind="binary",
198
+ source="needed by " + ", ".join(where),
199
+ fix='uv pip install -e ".[dev]" (or activate the project environment)',
200
+ )
201
+ for token, where in provenance.items()
202
+ )
203
+ return binaries + MODULE_REQUIREMENTS
204
+
205
+
206
+ def interpreter_anchor(steps: Sequence[GateStep]) -> Path | None:
207
+ """The directory whose interpreter the chain's test step will run in.
208
+
209
+ Read straight off the step that flags itself ``probe_interpreter`` -- the
210
+ ``wl test`` leg -- using that step's own ``cwd``, the very field
211
+ :meth:`GateCommand.execute
212
+ <wl_cli.commands.gate.gate_command.GateCommand.execute>` hands the executor.
213
+ Nothing here re-derives a root: the judged anchor *is* the executed step's
214
+ declared working directory, so the two cannot drift apart (issue #110).
215
+
216
+ ``None`` when no step claims the interpreter -- the historical, CWD-relative
217
+ probe, kept for callers that build a bare chain.
218
+ """
219
+ for step in steps:
220
+ if step.probe_interpreter:
221
+ return step.cwd
222
+ return None
223
+
224
+
225
+ def test_interpreter(anchor_root: Path | None = None) -> str:
226
+ """The interpreter the chain's test step runs pytest with.
227
+
228
+ Reused from the chain rather than reimplemented: ``PythonEnvDetector`` is
229
+ exactly what ``wl test`` consults to pick that interpreter, so asking
230
+ anything else would judge a different subject than the one that executes.
231
+ Its chatty ``print`` is captured so the gate's own output stays parseable.
232
+
233
+ *anchor_root* is the directory that test step runs in -- for the real chain
234
+ it comes from :func:`interpreter_anchor`, i.e. the pytest step's own
235
+ ``cwd``, and the ``wl test`` subprocess it launches resolves *its*
236
+ interpreter from that same directory. Anchoring the detector here is what
237
+ keeps the claim below true: without it a gate invoked from anywhere else
238
+ asked about the caller's CWD while the chain ran elsewhere, so preflight
239
+ judged one interpreter and the chain ran another (issue #110). The
240
+ un-anchored default -- ``None`` -- is byte-for-byte the pre-#110 behaviour.
241
+ """
242
+ sink = io.StringIO()
243
+ with contextlib.redirect_stdout(sink):
244
+ return PythonEnvDetector(anchor_root).get_python_executable()
245
+
246
+
247
+ def evaluate(
248
+ required: Sequence[Requirement],
249
+ subjects: Sequence[str],
250
+ observations: Mapping[str, bool],
251
+ ) -> PreflightReport:
252
+ """Decide which of *required* are missing. Pure: performs no I/O.
253
+
254
+ Args:
255
+ required: The rule set to apply.
256
+ subjects: What *observations* were taken from; carried into the report
257
+ so the error can name them.
258
+ observations: Availability keyed by ``Requirement.key``, as measured by
259
+ :func:`observe` (or fabricated by a test). A requirement with no
260
+ observation counts as missing.
261
+ """
262
+ missing = tuple(
263
+ requirement
264
+ for requirement in required
265
+ if not observations.get(requirement.key, False)
266
+ )
267
+ return PreflightReport(
268
+ subjects=tuple(subjects), required=tuple(required), missing=missing
269
+ )
270
+
271
+
272
+ def observe(
273
+ required: Sequence[Requirement],
274
+ interpreter: str,
275
+ search_path: str | None = None,
276
+ ) -> dict[str, bool]:
277
+ """Look up every requirement. Read-only: resolves PATH, imports modules.
278
+
279
+ Nothing is installed, nothing is written, and no network is touched.
280
+
281
+ *search_path* is the ``PATH`` the chain's own subprocesses resolve a bare
282
+ ``ruff`` / ``mypy`` / ``wl`` against. It defaults to
283
+ :meth:`SubprocessExecutor.subprocess_path` -- the executor's own answer --
284
+ rather than to this process' ``PATH``, because the executor rewrites that
285
+ variable for uv-tool runs (``get_uv_tool_env`` prepends ``UV_TOOL_DIR``).
286
+ Probing the process ``PATH`` there judges a different path than the one that
287
+ executes, and can refuse a chain the child could have run (F1 residual).
288
+ """
289
+ if search_path is None:
290
+ search_path = SubprocessExecutor().subprocess_path()
291
+ observed = {
292
+ requirement.key: shutil.which(requirement.key, path=search_path) is not None
293
+ for requirement in required
294
+ if requirement.kind == "binary"
295
+ }
296
+ names = [
297
+ requirement.key for requirement in required if requirement.kind == "module"
298
+ ]
299
+ observed.update(_probe_modules(names, interpreter))
300
+ return observed
301
+
302
+
303
+ def run_preflight(steps: Sequence[GateStep]) -> PreflightReport:
304
+ """Probe and judge the subjects *steps* will run, in one call.
305
+
306
+ The interpreter to probe is taken from the steps themselves -- the test
307
+ step's own ``cwd`` (see :func:`interpreter_anchor`) -- so there is no
308
+ separate root argument to hand over and no way for the judged root to
309
+ differ from the executed one (issue #110). A chain whose steps name no test
310
+ interpreter falls back to the historical, CWD-relative probe.
311
+ """
312
+ required = requirements_for(steps)
313
+ interpreter = test_interpreter(interpreter_anchor(steps))
314
+ subjects = ("PATH (the executables the chain runs)", interpreter)
315
+ return evaluate(required, subjects, observe(required, interpreter))
316
+
317
+
318
+ def render_env_error(report: PreflightReport) -> str:
319
+ """Render the environment gap: named, actionable, not a step failure."""
320
+ return _frame(
321
+ [
322
+ f" probed: {', '.join(report.subjects)}",
323
+ f" missing {len(report.missing)} of {len(report.required)} required items:",
324
+ ],
325
+ [
326
+ f"{item.label} ({item.source})\n fix: {item.fix}"
327
+ for item in report.missing
328
+ ],
329
+ )
330
+
331
+
332
+ def render_launch_error(step_name: str, executable: str) -> str:
333
+ """Render a step whose executable could not be launched at all.
334
+
335
+ A binary that survived preflight but cannot be exec'd is still an
336
+ environment problem, so it gets the environment shape and code -- not a
337
+ ``FAILED`` line, and never the executor's sentinel turned into 255.
338
+
339
+ The bullet no longer claims "PATH no longer resolves it": preflight already
340
+ proved PATH *did* resolve it, so an exec failure here is the kernel
341
+ refusing to run a file it found (removed in the race, or not executable),
342
+ and the old wording asserted a cause it had not established (review N1).
343
+ """
344
+ return _frame(
345
+ [f" step: {step_name}"],
346
+ [
347
+ f"could not launch '{executable}' -- it was on PATH, but executing "
348
+ "it failed (removed since the check, or not executable)"
349
+ ],
350
+ )
351
+
352
+
353
+ def render_signal_error(step_name: str, executable: str, signum: int) -> str:
354
+ """Render a step whose process was killed by a signal.
355
+
356
+ The step *ran*: PATH resolved *executable*, it was launched, and it then
357
+ died from signal *signum*. So this must not borrow the environment-gap
358
+ shape -- "the quality chain was NOT run" and "PATH no longer resolves it"
359
+ would both be false (review N1), and they sent the reader to fix the
360
+ machine when the cause could just as well be the change under test. It is
361
+ reported as a signalled step, with the shell's ``128 + signum`` code.
362
+ """
363
+ return "\n".join(
364
+ [
365
+ f"wl gate: step '{step_name}' was killed by signal {signum} "
366
+ f"({_signal_name(signum)}).",
367
+ f" command: {executable}",
368
+ " the step ran and was then signalled, so this is NOT an environment gap:",
369
+ " the chain was entered (its earlier lines stand) and "
370
+ f"'{executable}' was resolved by PATH.",
371
+ (
372
+ f" note: exit code {signal_exit_code(signum)} (128 + {signum}) means "
373
+ "'a step was signalled', not 'the environment is missing' "
374
+ f"({EXIT_ENV_MISSING}) -- check the step and the change, not the "
375
+ "machine."
376
+ ),
377
+ ]
378
+ )
379
+
380
+
381
+ def _signal_name(signum: int) -> str:
382
+ """The conventional name for *signum*, or a readable fallback if unknown."""
383
+ try:
384
+ return signal.Signals(signum).name
385
+ except ValueError:
386
+ return f"unknown signal {signum}"
387
+
388
+
389
+ def _frame(detail: Sequence[str], bullets: Sequence[str]) -> str:
390
+ """The shared envelope for every ``EXIT_ENV_MISSING`` message.
391
+
392
+ Deliberately shaped *unlike* a step result: no ``[i/n]`` numbering and no
393
+ ``FAILED`` token, so an environment gap can never be mistaken for a step
394
+ that ran and failed.
395
+ """
396
+ return "\n".join(
397
+ [
398
+ "wl gate: environment is incomplete - the quality chain was NOT run.",
399
+ *detail,
400
+ " missing required items:",
401
+ *(f" - {bullet}" for bullet in bullets),
402
+ (
403
+ f" note: exit code {EXIT_ENV_MISSING} means 'environment problem', "
404
+ "not 'your change is broken'."
405
+ ),
406
+ ]
407
+ )
408
+
409
+
410
+ def _probe_modules(names: Sequence[str], interpreter: str) -> dict[str, bool]:
411
+ """Ask *interpreter* which of *names* it can import.
412
+
413
+ Each module is imported for real rather than merely located: a broken
414
+ install could not run a gate step either.
415
+
416
+ One module per probe process, and the verdict is that process' **exit
417
+ status** -- never a parse of its stdout. A module that imports fine is free
418
+ to print whatever it likes; the old ``set(result.stdout.split())`` reader
419
+ took any token it printed for the name of a *failed* requirement, so a
420
+ chatty ``pytest_cov`` could have turned a working environment into a
421
+ phantom gap (review F5).
422
+ """
423
+ return {name: _imports(interpreter, name) for name in names}
424
+
425
+
426
+ def _imports(interpreter: str, name: str) -> bool:
427
+ """True when *interpreter* can import *name*, judged by its exit status."""
428
+ try:
429
+ result = subprocess.run(
430
+ [interpreter, "-c", _PROBE_SOURCE, name],
431
+ capture_output=True,
432
+ text=True,
433
+ timeout=60,
434
+ check=False,
435
+ )
436
+ except (OSError, subprocess.SubprocessError):
437
+ # The interpreter itself could not be driven: nothing is verified.
438
+ return False
439
+ return result.returncode == 0
440
+
441
+
442
+ # Imports the name given on argv; the interpreter's own exit status is the
443
+ # answer, so nothing the module prints can be mistaken for a verdict. Run
444
+ # inside the target interpreter so the answer describes *that* environment.
445
+ _PROBE_SOURCE = "import importlib, sys; importlib.import_module(sys.argv[1])"
@@ -0,0 +1 @@
1
+ """Git Flow commands and policy helpers."""
@@ -0,0 +1,169 @@
1
+ """Git Flow configuration loading."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from copy import deepcopy
6
+ from typing import TYPE_CHECKING, Any, cast
7
+
8
+ try:
9
+ from yaml import YAMLError
10
+ except ImportError:
11
+ YAMLError = ValueError
12
+
13
+ from ...utils.root import RootKind
14
+ from ...utils.root_fallback import current_root
15
+
16
+ if TYPE_CHECKING:
17
+ from pathlib import Path
18
+
19
+
20
+ class GitFlowConfigError(ValueError):
21
+ """Raised when Git Flow configuration is malformed."""
22
+
23
+
24
+ class GitFlowConfig:
25
+ DEFAULT_CONFIG: dict[str, Any] = {
26
+ "branches": {
27
+ "main": {"name": "main", "protected": True, "require_pr": True},
28
+ "develop": {"name": "develop", "protected": True, "require_pr": True},
29
+ "feature": {
30
+ "prefix": "feature/",
31
+ "source": "develop",
32
+ "merge_to": "develop",
33
+ },
34
+ "release": {
35
+ "prefix": "release/",
36
+ "source": "develop",
37
+ "merge_to": ["main", "develop"],
38
+ "require_version": True,
39
+ "require_changelog": True,
40
+ },
41
+ "hotfix": {
42
+ "prefix": "hotfix/",
43
+ "source": "main",
44
+ "merge_to": ["main", "develop"],
45
+ "require_version": True,
46
+ "require_changelog": True,
47
+ },
48
+ },
49
+ "version": {"file": "VERSION"},
50
+ "changelog": {"file": "CHANGELOG.md"},
51
+ }
52
+
53
+ def __init__(self, config_path: Path | None = None) -> None:
54
+ # Root-anchored (#125): the deploy root's ``.wl`` -- the directory the
55
+ # vendored pre-push hook reads from (git runs hooks with CWD = the
56
+ # work-tree top, which is exactly this root) -- not the process CWD's,
57
+ # so ``wl git-flow`` from a subdirectory reads the root's policy rather
58
+ # than silently falling back to the built-in defaults.
59
+ self.config_path = config_path or (
60
+ current_root(RootKind.DEPLOY).wl_dir() / "git-flow.yaml"
61
+ )
62
+ self._config = self._load_config()
63
+
64
+ @property
65
+ def config_source(self) -> str:
66
+ if self.config_path.exists():
67
+ return str(self.config_path)
68
+ return "built-in defaults"
69
+
70
+ @property
71
+ def main_branch(self) -> str:
72
+ return cast("str", self._config["branches"]["main"]["name"])
73
+
74
+ @property
75
+ def develop_branch(self) -> str:
76
+ return cast("str", self._config["branches"]["develop"]["name"])
77
+
78
+ def branch_prefix(self, branch_type: str) -> str:
79
+ return cast("str", self._config["branches"][branch_type]["prefix"])
80
+
81
+ def source_branch(self, branch_type: str) -> str:
82
+ return cast("str", self._config["branches"][branch_type]["source"])
83
+
84
+ def merge_targets(self, branch_type: str) -> list[str]:
85
+ targets = self._config["branches"][branch_type]["merge_to"]
86
+ if isinstance(targets, str):
87
+ return [targets]
88
+ return list(targets)
89
+
90
+ def _load_config(self) -> dict[str, Any]:
91
+ config = deepcopy(self.DEFAULT_CONFIG)
92
+ if not self.config_path.exists():
93
+ return config
94
+
95
+ try:
96
+ import yaml
97
+
98
+ loaded = yaml.safe_load(self.config_path.read_text(encoding="utf-8"))
99
+ except ImportError as exc:
100
+ raise GitFlowConfigError(
101
+ f"{self.config_path}: PyYAML is required to read Git Flow config "
102
+ "(bundled in the lfs extra). "
103
+ "Install with: pip install py_wlcli[lfs]"
104
+ ) from exc
105
+ except YAMLError as exc:
106
+ raise GitFlowConfigError(
107
+ f"{self.config_path}: invalid YAML: {exc}"
108
+ ) from exc
109
+
110
+ if loaded is None:
111
+ return config
112
+ if not isinstance(loaded, dict):
113
+ raise GitFlowConfigError(f"{self.config_path}: config must be a mapping")
114
+
115
+ self._validate_known_keys(loaded)
116
+ return self._deep_merge(config, loaded)
117
+
118
+ def _validate_known_keys(self, config: dict[str, Any]) -> None:
119
+ branches = config.get("branches")
120
+ if branches is None:
121
+ return
122
+ if not isinstance(branches, dict):
123
+ raise GitFlowConfigError(f"{self.config_path}: branches must be a mapping")
124
+
125
+ for branch_type, branch_config in branches.items():
126
+ if branch_type not in self.DEFAULT_CONFIG["branches"]:
127
+ continue
128
+ path = f"branches.{branch_type}"
129
+ if not isinstance(branch_config, dict):
130
+ raise GitFlowConfigError(
131
+ f"{self.config_path}: {path} must be a mapping"
132
+ )
133
+ for key, value in branch_config.items():
134
+ if key not in self.DEFAULT_CONFIG["branches"][branch_type]:
135
+ continue
136
+ self._validate_branch_value(path, key, value)
137
+
138
+ def _validate_branch_value(self, path: str, key: str, value: Any) -> None:
139
+ full_path = f"{path}.{key}"
140
+ if key in {"name", "prefix", "source"} and not isinstance(value, str):
141
+ raise GitFlowConfigError(
142
+ f"{self.config_path}: {full_path} must be a string"
143
+ )
144
+ if key == "merge_to":
145
+ if isinstance(value, str):
146
+ return
147
+ if isinstance(value, list) and all(isinstance(item, str) for item in value):
148
+ return
149
+ raise GitFlowConfigError(
150
+ f"{self.config_path}: {full_path} must be a string or list of strings"
151
+ )
152
+ if key in {"protected", "require_pr", "require_version", "require_changelog"}:
153
+ if not isinstance(value, bool):
154
+ raise GitFlowConfigError(
155
+ f"{self.config_path}: {full_path} must be a boolean"
156
+ )
157
+
158
+ def _deep_merge(
159
+ self, base: dict[str, Any], override: dict[str, Any]
160
+ ) -> dict[str, Any]:
161
+ result = deepcopy(base)
162
+ for key, value in override.items():
163
+ if key not in result:
164
+ continue
165
+ if isinstance(result[key], dict) and isinstance(value, dict):
166
+ result[key] = self._deep_merge(result[key], value)
167
+ else:
168
+ result[key] = deepcopy(value)
169
+ return result
@@ -0,0 +1,60 @@
1
+ """Git facts used by Git Flow policy adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+
7
+
8
+ def get_current_branch() -> str:
9
+ result = subprocess.run(
10
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
11
+ capture_output=True,
12
+ text=True,
13
+ check=False,
14
+ )
15
+ if result.returncode != 0:
16
+ return ""
17
+ return result.stdout.strip()
18
+
19
+
20
+ def get_changed_files(
21
+ source_branch: str,
22
+ file_filters: list[str] | None = None,
23
+ cwd: str | None = None,
24
+ ) -> frozenset[str]:
25
+ """Return files changed between ``source_branch`` and ``HEAD``.
26
+
27
+ When the local ``source_branch`` ref is absent (common in shallow/CI-style
28
+ clones that only carry ``origin/<source_branch>``), retry the diff against
29
+ the remote-tracking branch before falling back to an empty result.
30
+ """
31
+ cmd = [
32
+ "git",
33
+ "diff",
34
+ "--name-only",
35
+ f"{source_branch}...HEAD",
36
+ ]
37
+ if file_filters:
38
+ cmd.extend(["--", *file_filters])
39
+
40
+ result = subprocess.run(
41
+ cmd,
42
+ capture_output=True,
43
+ text=True,
44
+ check=False,
45
+ cwd=cwd,
46
+ )
47
+ if result.returncode != 0:
48
+ # The local <source_branch> may be missing; retry against
49
+ # origin/<source_branch> before treating the diff as "no changes".
50
+ cmd[3] = f"origin/{source_branch}...HEAD"
51
+ result = subprocess.run(
52
+ cmd,
53
+ capture_output=True,
54
+ text=True,
55
+ check=False,
56
+ cwd=cwd,
57
+ )
58
+ if result.returncode != 0:
59
+ return frozenset()
60
+ return frozenset(line for line in result.stdout.splitlines() if line)