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.
- py_wlcli-0.8.0.dist-info/METADATA +139 -0
- py_wlcli-0.8.0.dist-info/RECORD +320 -0
- py_wlcli-0.8.0.dist-info/WHEEL +4 -0
- py_wlcli-0.8.0.dist-info/entry_points.txt +2 -0
- py_wlcli-0.8.0.dist-info/licenses/LICENSE +21 -0
- wl_cli/__init__.py +27 -0
- wl_cli/__main__.py +130 -0
- wl_cli/capabilities.py +79 -0
- wl_cli/cc/__init__.py +5 -0
- wl_cli/cc/checks.py +1019 -0
- wl_cli/cc/cli.py +370 -0
- wl_cli/cc/mutate.py +1350 -0
- wl_cli/cli_click.py +156 -0
- wl_cli/commands/__init__.py +84 -0
- wl_cli/commands/base.py +80 -0
- wl_cli/commands/buildcommands/__init__.py +1 -0
- wl_cli/commands/buildcommands/build.py +71 -0
- wl_cli/commands/buildcommands/build_utils.py +47 -0
- wl_cli/commands/buildcommands/builders/__init__.py +18 -0
- wl_cli/commands/buildcommands/builders/cleanup.py +35 -0
- wl_cli/commands/buildcommands/builders/coordinator.py +122 -0
- wl_cli/commands/buildcommands/builders/environment.py +81 -0
- wl_cli/commands/buildcommands/builders/rust_builder.py +39 -0
- wl_cli/commands/buildcommands/builders/stubs.py +58 -0
- wl_cli/commands/buildcommands/dist.py +326 -0
- wl_cli/commands/buildcommands/test.py +89 -0
- wl_cli/commands/clean/__init__.py +1 -0
- wl_cli/commands/clean/build.py +151 -0
- wl_cli/commands/clean/clean.py +294 -0
- wl_cli/commands/clean/clean_utils.py +77 -0
- wl_cli/commands/clean/core.py +236 -0
- wl_cli/commands/clean/dead_code/__init__.py +0 -0
- wl_cli/commands/clean/dead_code/dead_code_command.py +70 -0
- wl_cli/commands/clean/dead_code/detector.py +161 -0
- wl_cli/commands/clean/dead_code/fixer.py +176 -0
- wl_cli/commands/clean/dead_code/reporter.py +295 -0
- wl_cli/commands/clean/dead_code/utils.py +0 -0
- wl_cli/commands/clean/lfs.py +215 -0
- wl_cli/commands/clean/python.py +81 -0
- wl_cli/commands/clean/rust.py +91 -0
- wl_cli/commands/clean/utils/__init__.py +34 -0
- wl_cli/commands/clean/venv.py +91 -0
- wl_cli/commands/cleanup.py +73 -0
- wl_cli/commands/click_base.py +79 -0
- wl_cli/commands/config/__init__.py +1 -0
- wl_cli/commands/config/config.py +291 -0
- wl_cli/commands/debug/__init__.py +6 -0
- wl_cli/commands/debug/debug.py +309 -0
- wl_cli/commands/debug/debug_cache.py +86 -0
- wl_cli/commands/debug/debug_subagent.py +156 -0
- wl_cli/commands/debug/output_formatter.py +165 -0
- wl_cli/commands/debug/source_hasher.py +92 -0
- wl_cli/commands/format/__init__.py +13 -0
- wl_cli/commands/format/format_cache_adapter.py +125 -0
- wl_cli/commands/format/format_command.py +197 -0
- wl_cli/commands/format/format_coordinator.py +191 -0
- wl_cli/commands/format/format_executor.py +470 -0
- wl_cli/commands/format/format_outcome.py +44 -0
- wl_cli/commands/format/format_path_handler.py +1016 -0
- wl_cli/commands/format/format_progress.py +62 -0
- wl_cli/commands/format/format_reporter.py +31 -0
- wl_cli/commands/format/python_formatter.py +506 -0
- wl_cli/commands/format/rust_formatter.py +106 -0
- wl_cli/commands/gate/__init__.py +1 -0
- wl_cli/commands/gate/gate_command.py +862 -0
- wl_cli/commands/gate/preflight.py +445 -0
- wl_cli/commands/git/__init__.py +1 -0
- wl_cli/commands/git/config.py +169 -0
- wl_cli/commands/git/git_facts.py +60 -0
- wl_cli/commands/git/git_flow.py +86 -0
- wl_cli/commands/git/hook.py +112 -0
- wl_cli/commands/git/policy.py +216 -0
- wl_cli/commands/git/push_update.py +82 -0
- wl_cli/commands/initenv/__init__.py +1 -0
- wl_cli/commands/initenv/common/__init__.py +23 -0
- wl_cli/commands/initenv/common/check_git_bash.py +78 -0
- wl_cli/commands/initenv/common/error_handler.py +68 -0
- wl_cli/commands/initenv/common/exceptions.py +52 -0
- wl_cli/commands/initenv/components/__init__.py +4 -0
- wl_cli/commands/initenv/components/cargo_sync.py +240 -0
- wl_cli/commands/initenv/components/config_manager.py +106 -0
- wl_cli/commands/initenv/components/directory_utils.py +71 -0
- wl_cli/commands/initenv/components/git/__init__.py +9 -0
- wl_cli/commands/initenv/components/git/branch_creator.py +143 -0
- wl_cli/commands/initenv/components/git/git_flow_interface.py +24 -0
- wl_cli/commands/initenv/components/git/git_flow_manager.py +159 -0
- wl_cli/commands/initenv/components/git/git_lfs_manager.py +272 -0
- wl_cli/commands/initenv/components/git/git_utility.py +254 -0
- wl_cli/commands/initenv/components/git/gitignore_manager.py +53 -0
- wl_cli/commands/initenv/components/git_initializer.py +212 -0
- wl_cli/commands/initenv/components/i18n_manager.py +104 -0
- wl_cli/commands/initenv/components/log_manager.py +241 -0
- wl_cli/commands/initenv/components/project_structure/__init__.py +23 -0
- wl_cli/commands/initenv/components/project_structure/config_generator.py +360 -0
- wl_cli/commands/initenv/components/project_structure/directory_creator.py +184 -0
- wl_cli/commands/initenv/components/project_structure/hook_source.py +110 -0
- wl_cli/commands/initenv/components/project_structure/hooks_manager.py +995 -0
- wl_cli/commands/initenv/components/project_structure/readme_handler.py +56 -0
- wl_cli/commands/initenv/components/project_structure/rust_files_handler.py +30 -0
- wl_cli/commands/initenv/components/project_structure/setup_handler.py +103 -0
- wl_cli/commands/initenv/components/project_structure/sop_generator.py +73 -0
- wl_cli/commands/initenv/components/project_structure/template_copier.py +104 -0
- wl_cli/commands/initenv/components/project_structure/template_manager.py +195 -0
- wl_cli/commands/initenv/components/pyproject_generator.py +194 -0
- wl_cli/commands/initenv/components/rust_initializer.py +145 -0
- wl_cli/commands/initenv/components/symlink_cleaner.py +99 -0
- wl_cli/commands/initenv/components/symlink_manager.py +52 -0
- wl_cli/commands/initenv/components/venv_manager.py +118 -0
- wl_cli/commands/initenv/initenv.py +169 -0
- wl_cli/commands/initenv/services/__init__.py +11 -0
- wl_cli/commands/initenv/services/environment_initializer.py +288 -0
- wl_cli/commands/initenv/services/git_lfs_initializer.py +26 -0
- wl_cli/commands/initenv/services/project_structure_initializer.py +52 -0
- wl_cli/commands/lfs/README.md +221 -0
- wl_cli/commands/lfs/__init__.py +5 -0
- wl_cli/commands/lfs/cli.py +106 -0
- wl_cli/commands/lfs/config.py +164 -0
- wl_cli/commands/lfs/hook.py +61 -0
- wl_cli/commands/lfs/initializer.py +531 -0
- wl_cli/commands/lfs/manager.py +95 -0
- wl_cli/commands/lfs/policy.py +165 -0
- wl_cli/commands/lfs/watcher.py +294 -0
- wl_cli/commands/lint/__init__.py +1 -0
- wl_cli/commands/lint/lint_command.py +297 -0
- wl_cli/commands/lint/lint_executor.py +409 -0
- wl_cli/commands/lint/lint_reporter.py +579 -0
- wl_cli/commands/lint/lint_run.py +258 -0
- wl_cli/commands/lint/mypy_config.py +90 -0
- wl_cli/commands/registry.py +103 -0
- wl_cli/commands/self/__init__.py +7 -0
- wl_cli/commands/self/common/__init__.py +8 -0
- wl_cli/commands/self/common/capabilities.py +30 -0
- wl_cli/commands/self/common/config.py +27 -0
- wl_cli/commands/self/common/version_cache.py +85 -0
- wl_cli/commands/self/handlers/__init__.py +19 -0
- wl_cli/commands/self/handlers/base_handler.py +28 -0
- wl_cli/commands/self/handlers/install_handler.py +85 -0
- wl_cli/commands/self/handlers/status_handler.py +66 -0
- wl_cli/commands/self/handlers/uninstall_handler.py +58 -0
- wl_cli/commands/self/handlers/update_handler.py +102 -0
- wl_cli/commands/self/handlers/version_handler.py +16 -0
- wl_cli/commands/self/self_command.py +237 -0
- wl_cli/commands/self/services/__init__.py +15 -0
- wl_cli/commands/self/services/installation_manager.py +187 -0
- wl_cli/commands/self/services/local_installer.py +165 -0
- wl_cli/commands/self/services/pypi_updater.py +278 -0
- wl_cli/commands/self/services/windows_update.py +174 -0
- wl_cli/commands/testcommands/__init__.py +8 -0
- wl_cli/commands/testcommands/base_dependency_analyzer.py +198 -0
- wl_cli/commands/testcommands/batch_processor.py +278 -0
- wl_cli/commands/testcommands/cache_manager.py +1156 -0
- wl_cli/commands/testcommands/coverage_analyzer.py +955 -0
- wl_cli/commands/testcommands/coverage_cache_manager.py +183 -0
- wl_cli/commands/testcommands/progress_manager.py +79 -0
- wl_cli/commands/testcommands/python_env_detector.py +207 -0
- wl_cli/commands/testcommands/report_generator.py +101 -0
- wl_cli/commands/testcommands/single_test_executor.py +250 -0
- wl_cli/commands/testcommands/speed_checker.py +118 -0
- wl_cli/commands/testcommands/test_command_builder.py +165 -0
- wl_cli/commands/testcommands/test_commands.py +786 -0
- wl_cli/commands/testcommands/test_reporter.py +181 -0
- wl_cli/commands/testcommands/test_result_handler.py +96 -0
- wl_cli/commands/testcommands/test_runner.py +1343 -0
- wl_cli/commands/testcommands/test_utils.py +197 -0
- wl_cli/commands/testcommands/utils/__init__.py +17 -0
- wl_cli/commands/testcommands/utils/coverage_parser.py +98 -0
- wl_cli/commands/testcommands/utils/env_info.py +36 -0
- wl_cli/commands/testcommands/utils/junit_parser.py +75 -0
- wl_cli/commands/testcommands/utils/markdown_utils.py +20 -0
- wl_cli/commands/testcommands/utils/project_root.py +61 -0
- wl_cli/commands/update/__init__.py +9 -0
- wl_cli/commands/update/services/__init__.py +21 -0
- wl_cli/commands/update/services/backup_manager.py +207 -0
- wl_cli/commands/update/services/component_manager.py +83 -0
- wl_cli/commands/update/services/conflict_resolver.py +189 -0
- wl_cli/commands/update/services/vendors_updater.py +85 -0
- wl_cli/commands/update/services/version_tracker.py +127 -0
- wl_cli/commands/update/update_command.py +270 -0
- wl_cli/commands/update/utils/__init__.py +3 -0
- wl_cli/commands/version/__init__.py +9 -0
- wl_cli/commands/version/version_command.py +155 -0
- wl_cli/commands/workflow.py +477 -0
- wl_cli/env/__init__.py +5 -0
- wl_cli/env/cli.py +167 -0
- wl_cli/env_status.py +157 -0
- wl_cli/envelope.py +91 -0
- wl_cli/exceptions.py +85 -0
- wl_cli/lib/__init__.py +3 -0
- wl_cli/lib/rust_utils.py +35 -0
- wl_cli/mcp/__init__.py +6 -0
- wl_cli/mcp/capabilities.py +19 -0
- wl_cli/mcp/cli.py +22 -0
- wl_cli/mcp/gitlab.py +553 -0
- wl_cli/mcp/k8s.py +344 -0
- wl_cli/mcp/protocol.py +159 -0
- wl_cli/platform.py +139 -0
- wl_cli/quality/__init__.py +18 -0
- wl_cli/utils/__init__.py +7 -0
- wl_cli/utils/cache.py +491 -0
- wl_cli/utils/cache_ledger.py +355 -0
- wl_cli/utils/cache_store.py +546 -0
- wl_cli/utils/config/__init__.py +27 -0
- wl_cli/utils/config/config_loader.py +163 -0
- wl_cli/utils/config/core.py +862 -0
- wl_cli/utils/config/defaults.py +142 -0
- wl_cli/utils/config/env_exporter.py +86 -0
- wl_cli/utils/config/env_parser.py +108 -0
- wl_cli/utils/config/validation.py +280 -0
- wl_cli/utils/error_handler.py +165 -0
- wl_cli/utils/file_copier.py +56 -0
- wl_cli/utils/file_operations.py +506 -0
- wl_cli/utils/language_utils.py +122 -0
- wl_cli/utils/lazy_import.py +240 -0
- wl_cli/utils/log_levels.py +35 -0
- wl_cli/utils/log_rotators.py +74 -0
- wl_cli/utils/logging.py +458 -0
- wl_cli/utils/output_context.py +142 -0
- wl_cli/utils/performance/__init__.py +1 -0
- wl_cli/utils/performance/decorators.py +111 -0
- wl_cli/utils/performance/exporter.py +38 -0
- wl_cli/utils/performance/models.py +16 -0
- wl_cli/utils/performance/monitor.py +161 -0
- wl_cli/utils/performance_monitor.py +32 -0
- wl_cli/utils/platform_adapter.py +131 -0
- wl_cli/utils/pre_commit.py +266 -0
- wl_cli/utils/pre_commit_config.py +72 -0
- wl_cli/utils/root.py +521 -0
- wl_cli/utils/root_fallback.py +256 -0
- wl_cli/utils/run_evidence.py +236 -0
- wl_cli/utils/structured_logger.py +18 -0
- wl_cli/utils/structured_logging/__init__.py +19 -0
- wl_cli/utils/structured_logging/config.py +337 -0
- wl_cli/utils/structured_logging/core.py +177 -0
- wl_cli/utils/structured_logging/env.py +56 -0
- wl_cli/utils/structured_logging/filtering.py +75 -0
- wl_cli/utils/structured_logging/filters.py +55 -0
- wl_cli/utils/structured_logging/formatters.py +251 -0
- wl_cli/utils/structured_logging/handlers.py +308 -0
- wl_cli/utils/structured_logging/logger.py +354 -0
- wl_cli/utils/subprocess.py +71 -0
- wl_cli/utils/subprocess_utils.py +260 -0
- wl_cli/utils/test_utils/__init__.py +5 -0
- wl_cli/utils/test_utils/test_filter.py +36 -0
- wl_cli/utils/uv_run.py +79 -0
- wl_cli/utils/uv_tool.py +117 -0
- wl_cli/utils/uv_tree.py +71 -0
- wl_cli/utils/uv_workspace.py +174 -0
- wl_cli/utils/version/__init__.py +15 -0
- wl_cli/utils/version/pypi_version_checker.py +95 -0
- wl_cli/utils/version/version_comparator.py +133 -0
- wl_cli/utils/version/version_detectors.py +312 -0
- wl_cli/utils/version/version_service.py +131 -0
- wl_cli/utils/version/version_updater.py +198 -0
- wl_cli/utils/wl_dir_updater.py +1440 -0
- wl_cli/utils/workspace_detector/__init__.py +28 -0
- wl_cli/utils/workspace_detector/core.py +161 -0
- wl_cli/utils/workspace_detector/detection_strategies.py +107 -0
- wl_cli/utils/workspace_detector/exceptions.py +5 -0
- wl_cli/utils/workspace_detector/types.py +17 -0
- wl_cli/utils/workspace_detector/venv_resolver.py +84 -0
- wl_cli/vendors/config/.codespellrc +2 -0
- wl_cli/vendors/config/dependencies.toml +10 -0
- wl_cli/vendors/config/mypy.ini +62 -0
- wl_cli/vendors/config/pyproject_defaults.toml +28 -0
- wl_cli/vendors/config/template_config.toml +24 -0
- wl_cli/vendors/config/tool_configs.toml +76 -0
- wl_cli/vendors/git/.gitignore +122 -0
- wl_cli/vendors/git/.pre-commit-config.yaml +37 -0
- wl_cli/vendors/hooks/applypatch-msg.sample +15 -0
- wl_cli/vendors/hooks/commit-msg.sample +24 -0
- wl_cli/vendors/hooks/fsmonitor-watchman.sample +174 -0
- wl_cli/vendors/hooks/post-update.sample +8 -0
- wl_cli/vendors/hooks/pre-applypatch.sample +14 -0
- wl_cli/vendors/hooks/pre-commit +45 -0
- wl_cli/vendors/hooks/pre-commit.legacy +31 -0
- wl_cli/vendors/hooks/pre-commit.sample +49 -0
- wl_cli/vendors/hooks/pre-merge-commit.sample +13 -0
- wl_cli/vendors/hooks/pre-push.sample +60 -0
- wl_cli/vendors/hooks/pre-rebase.sample +169 -0
- wl_cli/vendors/hooks/pre-receive.sample +24 -0
- wl_cli/vendors/hooks/prepare-commit-msg.sample +42 -0
- wl_cli/vendors/hooks/push-to-checkout.sample +78 -0
- wl_cli/vendors/hooks/sendemail-validate.sample +77 -0
- wl_cli/vendors/hooks/unix/applypatch-msg.sample +15 -0
- wl_cli/vendors/hooks/unix/check_lockfile.py +58 -0
- wl_cli/vendors/hooks/unix/check_lockfile.sh +36 -0
- wl_cli/vendors/hooks/unix/commit-msg.sample +24 -0
- wl_cli/vendors/hooks/unix/fsmonitor-watchman.sample +174 -0
- wl_cli/vendors/hooks/unix/post-update.sample +8 -0
- wl_cli/vendors/hooks/unix/pre-applypatch.sample +14 -0
- wl_cli/vendors/hooks/unix/pre-commit +190 -0
- wl_cli/vendors/hooks/unix/pre-commit.legacy +31 -0
- wl_cli/vendors/hooks/unix/pre-commit.py +81 -0
- wl_cli/vendors/hooks/unix/pre-commit.sample +49 -0
- wl_cli/vendors/hooks/unix/pre-merge-commit.sample +13 -0
- wl_cli/vendors/hooks/unix/pre-push +417 -0
- wl_cli/vendors/hooks/unix/pre-push.sample +53 -0
- wl_cli/vendors/hooks/unix/pre-rebase.sample +169 -0
- wl_cli/vendors/hooks/unix/pre-receive.sample +24 -0
- wl_cli/vendors/hooks/unix/prepare-commit-msg.sample +42 -0
- wl_cli/vendors/hooks/update.sample +128 -0
- wl_cli/vendors/hooks/win/work.bat +63 -0
- wl_cli/vendors/project/.gitignore +90 -0
- wl_cli/vendors/project/.python-version +1 -0
- wl_cli/vendors/project/LICENSE +21 -0
- wl_cli/vendors/project/README.md +0 -0
- wl_cli/vendors/project/pyproject.toml +27 -0
- wl_cli/vendors/project/pyproject_no_workspace.toml +16 -0
- wl_cli/vendors/project/pyproject_workspace.toml +14 -0
- wl_cli/vendors/readme/README.md +120 -0
- wl_cli/vendors/rust/Cargo.toml +14 -0
- wl_cli/vendors/rust/lib/__init__.py +3 -0
- wl_cli/vendors/rust/lib/rust_utils.py +31 -0
- wl_cli/vendors/rust/src/lib.rs +13 -0
- wl_cli/vendors/rust/src/main.rs +3 -0
- wl_cli/vendors/rust/tests/test_rust_fallback.py +40 -0
- wl_cli/vendors/sop_doc/git-dev-sop.md +220 -0
- wl_cli/vendors/sop_doc/industrial-standard-sop.md +145 -0
- wl_cli/vendors/sop_doc/python-sop.md +342 -0
- 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)
|