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,1440 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Utility for checking and updating the .wl directory based on version compatibility.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import shutil
|
|
11
|
+
import stat
|
|
12
|
+
import time
|
|
13
|
+
from functools import partial
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
16
|
+
|
|
17
|
+
from .logging import log_info_pair, log_warning_pair
|
|
18
|
+
from .root import Root, RootKind
|
|
19
|
+
from .root_fallback import current_context, current_root
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from collections.abc import Callable
|
|
23
|
+
|
|
24
|
+
from ..commands.initenv.components.project_structure.hooks_manager import (
|
|
25
|
+
HookSyncState,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
#: Cooldown (seconds) before the heavy .wl sync is retried after a failure.
|
|
29
|
+
_UPDATE_FAILED_COOLDOWN_SECONDS = 3600
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _project_wl_dir(project_root: Root) -> Path:
|
|
33
|
+
"""The workspace/project ``.wl`` at an already-resolved project root (#160).
|
|
34
|
+
|
|
35
|
+
One of the two anchors :func:`check_and_update_wl_dir` consults to decide
|
|
36
|
+
whether a checkout is wl-managed at all. In the ``项目根 != git 根`` layouts
|
|
37
|
+
(#95 / #96) the workspace ``.wl`` lives here while the hooks leg belongs to
|
|
38
|
+
the deploy root below.
|
|
39
|
+
|
|
40
|
+
The root is a resolved value handed in -- :func:`current_root` for the
|
|
41
|
+
hot-path check, or the context's ``project`` inside the sync. This helper no
|
|
42
|
+
longer derives a root of its own, so a forgotten value cannot silently fall
|
|
43
|
+
back to the process CWD. The ``.wl`` spelling lives on
|
|
44
|
+
:meth:`~wl_cli.utils.root.Root.wl_dir` (#118).
|
|
45
|
+
"""
|
|
46
|
+
return project_root.wl_dir()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _deploy_wl_dir(deploy_root: Root) -> Path:
|
|
50
|
+
"""The ``.wl`` at an already-resolved hooks deploy root (#160).
|
|
51
|
+
|
|
52
|
+
This is where the self-sync reads/writes ``config.json`` and deploys the
|
|
53
|
+
hooks leg and config templates, so those artifacts stay inside the git work
|
|
54
|
+
tree (#95 / #96). In the regular layout -- project root == git work tree root
|
|
55
|
+
-- it is the same directory :func:`_project_wl_dir` returns.
|
|
56
|
+
"""
|
|
57
|
+
return deploy_root.wl_dir()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _same_path(a: Path, b: Path) -> bool:
|
|
61
|
+
"""Whether two paths point at the same directory (tolerating symlinks)."""
|
|
62
|
+
try:
|
|
63
|
+
return a.resolve() == b.resolve()
|
|
64
|
+
except Exception:
|
|
65
|
+
return a == b
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _migrate_legacy_config_if_needed(
|
|
69
|
+
deploy_wl_dir: Path, legacy_wl_dir: Path | None
|
|
70
|
+
) -> None:
|
|
71
|
+
"""Carry a pre-#95 / #96 ``config.json`` over to the deploy root.
|
|
72
|
+
|
|
73
|
+
Before the hooks leg was anchored at the git work-tree root, ``.wl`` lived at
|
|
74
|
+
the project root. In a divergent layout (an outer workspace ``.wl``
|
|
75
|
+
above a member repo, or a project below the git root) that is *not* the
|
|
76
|
+
deploy root, so the deploy root would otherwise mint a fresh default and
|
|
77
|
+
silently abandon the user's settings. When the deploy root has no
|
|
78
|
+
``config.json`` yet and a legacy one was located, copy it across -- once --
|
|
79
|
+
and say so.
|
|
80
|
+
|
|
81
|
+
``legacy_wl_dir`` is resolved from the *invocation* directory by the caller
|
|
82
|
+
(through the root module's ``LEGACY`` kind) and never re-derived here: re-deriving it now
|
|
83
|
+
would resolve to the deploy root itself -- its ``.wl`` exists by this point
|
|
84
|
+
-- and silently skip the migration, which is exactly the F2 residual this
|
|
85
|
+
parameter exists to close.
|
|
86
|
+
|
|
87
|
+
The legacy ``.wl`` directory itself is deliberately **retained**: deleting a
|
|
88
|
+
user directory is never this tool's call, and nothing reads it any more --
|
|
89
|
+
its ``config.json`` was migrated above and its hook files are inert
|
|
90
|
+
(``core.hooksPath`` now points at the deploy root). Retention is not silent,
|
|
91
|
+
though: the report below names the exact legacy path and tells the user how
|
|
92
|
+
to remove it once they are satisfied nothing else there is still wanted.
|
|
93
|
+
|
|
94
|
+
That report *is* the adjudication of the retained-legacy-``.wl`` policy
|
|
95
|
+
(#111, transferred from #103 item 5): keep the directory, but make the keep
|
|
96
|
+
actionable. It is verifiable behaviour, not a comment that says "known" --
|
|
97
|
+
reverting the report to the bare inert-hooks note makes
|
|
98
|
+
``test_retained_legacy_wl_is_reported_with_actionable_cleanup_guidance``
|
|
99
|
+
fail. Auto-cleanup is deliberately *not* the ruling: it would silently
|
|
100
|
+
delete a user directory, which the backward-compatibility contract forbids.
|
|
101
|
+
"""
|
|
102
|
+
target = deploy_wl_dir / "config.json"
|
|
103
|
+
if target.exists():
|
|
104
|
+
return # the deploy root already owns the config
|
|
105
|
+
|
|
106
|
+
if legacy_wl_dir is None or _same_path(legacy_wl_dir, deploy_wl_dir):
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
legacy_config = legacy_wl_dir / "config.json"
|
|
110
|
+
if not legacy_config.is_file():
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
# copyfile, not copy2: the mode is applied explicitly just below, so the
|
|
115
|
+
# target can keep the source's bits *minus* the read-only trap. copy2's
|
|
116
|
+
# verbatim mode is what broke a 0444 legacy config.json: the migrated
|
|
117
|
+
# target stayed unwritable, so the version stamp could never advance --
|
|
118
|
+
# rewriting it makes _update_config_version() catch OSError and hand off
|
|
119
|
+
# to get_config_manager().set("version", ...), whose own write
|
|
120
|
+
# (ConfigSaver.save: open(..., "w") then "except OSError: pass",
|
|
121
|
+
# config/core.py) swallows the failure -- leaving the stamp stale and
|
|
122
|
+
# every `wl` re-running the full sync (#104 N2).
|
|
123
|
+
shutil.copyfile(legacy_config, target)
|
|
124
|
+
except OSError as e:
|
|
125
|
+
log_info_pair(
|
|
126
|
+
f"Warning: could not migrate {legacy_config} to {target}: {e}",
|
|
127
|
+
f"警告:无法将 {legacy_config} 迁移到 {target}:{e}",
|
|
128
|
+
)
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
# Carry the source's permission bits (a private 0600 legacy config stays
|
|
132
|
+
# 0600 rather than becoming 0644 under the umask) but always keep the
|
|
133
|
+
# owner-write bit: the target is ours, and _update_config_version() has to
|
|
134
|
+
# rewrite it (#104 N2).
|
|
135
|
+
try:
|
|
136
|
+
target.chmod(stat.S_IMODE(legacy_config.stat().st_mode) | stat.S_IWUSR)
|
|
137
|
+
except OSError as e:
|
|
138
|
+
# The copy above already succeeded, so the config *did* migrate -- do not
|
|
139
|
+
# claim otherwise. But the target may be unwritable, in which case the
|
|
140
|
+
# version stamp cannot advance here and will not be retried (the early
|
|
141
|
+
# return above skips a target that already exists), so name that
|
|
142
|
+
# consequence instead of leaving it to be discovered later.
|
|
143
|
+
log_info_pair(
|
|
144
|
+
f"Warning: migrated {legacy_config} to {target}, but could not make it "
|
|
145
|
+
f"writable ({e}); until {target} is writable the .wl version stamp "
|
|
146
|
+
"will not advance and the full sync will re-run on every command.",
|
|
147
|
+
f"警告:已将 {legacy_config} 迁移到 {target},但无法使其可写({e});"
|
|
148
|
+
f"在 {target} 可写之前,.wl 版本戳不会前进,每次命令都会重跑全量同步。",
|
|
149
|
+
)
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
log_info_pair(
|
|
153
|
+
f"✓ Migrated wl config to the repository root: {legacy_config} -> {target}",
|
|
154
|
+
f"✓ 已将 wl 配置迁移到仓库根:{legacy_config} -> {target}",
|
|
155
|
+
)
|
|
156
|
+
log_info_pair(
|
|
157
|
+
f"Note: the legacy .wl at {legacy_wl_dir} is deliberately retained: its "
|
|
158
|
+
f"config was migrated to {target} above and its hook files are inert, so "
|
|
159
|
+
"nothing reads it any more -- and wl never deletes a user directory. If "
|
|
160
|
+
f"you no longer need it, remove it yourself, e.g. `rm -rf {legacy_wl_dir}`.",
|
|
161
|
+
f"注意:旧 .wl 目录 {legacy_wl_dir} 为有意保留:其配置已迁移到上方的 "
|
|
162
|
+
f"{target},钩子文件亦已失效,已不再被读取;wl 不会自行删除用户目录。"
|
|
163
|
+
f"若确认不再需要,可自行清理,例如:`rm -rf {legacy_wl_dir}`。",
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def check_and_update_wl_dir() -> None:
|
|
168
|
+
"""
|
|
169
|
+
Check if .wl directory version is compatible with current wl version.
|
|
170
|
+
If not, update the .wl directory.
|
|
171
|
+
|
|
172
|
+
Runs on every `wl` command (hot path): short-circuits cheaply when the
|
|
173
|
+
.wl directory is current, and skips the heavy sync entirely if a previous
|
|
174
|
+
attempt failed recently (see _update_failed_marker).
|
|
175
|
+
|
|
176
|
+
The failure marker is cleared only when the sync fully succeeded; any
|
|
177
|
+
partial sub-step failure keeps it (or writes it) so the cooldown engages.
|
|
178
|
+
"""
|
|
179
|
+
from wl_cli import __version__
|
|
180
|
+
|
|
181
|
+
current_version = __version__
|
|
182
|
+
|
|
183
|
+
# Hot path first (#160/F3): answer "is this a wl project at all?" with the
|
|
184
|
+
# two single-root lookups, before anything walks the legacy chain. A full
|
|
185
|
+
# ``RootContext`` also resolves LEGACY, whose walk shells out to git; paying
|
|
186
|
+
# that in every *non-wl* directory, on every ``wl`` command, would take the
|
|
187
|
+
# hot path from one ``git rev-parse`` to two. Both lookups are memoised by
|
|
188
|
+
# the root module for this start, so the context built below reuses them.
|
|
189
|
+
#
|
|
190
|
+
# A checkout is wl-managed when `.wl` exists at either anchor: the deploy
|
|
191
|
+
# root (git work-tree root, else project root) or the project root. In the
|
|
192
|
+
# regular layout they coincide; the two differ in the #95 / #96 layouts --
|
|
193
|
+
# an outer workspace `.wl` above a member repo (the project root has `.wl`,
|
|
194
|
+
# the freshly-visited deploy root does not yet) or a sub-package below the
|
|
195
|
+
# git root (the deploy root has `.wl`, the project root does not).
|
|
196
|
+
deploy_root = current_root(RootKind.DEPLOY)
|
|
197
|
+
project_root = current_root(RootKind.PROJECT)
|
|
198
|
+
if (
|
|
199
|
+
not _deploy_wl_dir(deploy_root).exists()
|
|
200
|
+
and not _project_wl_dir(project_root).exists()
|
|
201
|
+
):
|
|
202
|
+
# No .wl at either anchor: not a wl project, nothing to update
|
|
203
|
+
return
|
|
204
|
+
|
|
205
|
+
# Managed checkout: resolve the three roots **once** (#160). The sync's
|
|
206
|
+
# deploy root and legacy point come from this one value, instead of each
|
|
207
|
+
# layer re-deriving a root from the process CWD -- the per-layer
|
|
208
|
+
# ``Root | None = None`` defaults that made a forgotten argument silently
|
|
209
|
+
# fall back to the CWD.
|
|
210
|
+
#
|
|
211
|
+
# The legacy point is resolved from the *invocation* directory, before the
|
|
212
|
+
# deploy `.wl` is created: once it exists, the PROJECT walk resolves to the
|
|
213
|
+
# deploy root itself and the old location becomes invisible. The previous
|
|
214
|
+
# code resolved it after a mid-sync chdir and skipped the migration silently
|
|
215
|
+
# (F2 residual). LEGACY's bounds (volume-root ceiling, system-scope
|
|
216
|
+
# blacklist, the explicit ``<home>/.wl`` refusal) come from the root module
|
|
217
|
+
# (#118); the context calls no legacy walk of its own.
|
|
218
|
+
context = current_context()
|
|
219
|
+
|
|
220
|
+
# The sync behaves *as if run from the deploy root* -- not the CWD and not
|
|
221
|
+
# the project root -- because that is where git resolves the relative
|
|
222
|
+
# ``core.hooksPath`` and runs hooks from, and where the whole ``.wl`` leg
|
|
223
|
+
# lives (#95 / #96). That anchoring is now delivered *as a value*:
|
|
224
|
+
# ``deploy_root`` is handed to every downstream step, which builds its ``.wl``
|
|
225
|
+
# paths from it instead of re-deriving a root from the process CWD. The
|
|
226
|
+
# process CWD is never touched (the old mid-sync ``os.chdir`` and its
|
|
227
|
+
# ``finally`` restore are gone), so every relative path in this process keeps
|
|
228
|
+
# resolving against the user's own directory.
|
|
229
|
+
_run_self_sync(current_version, context.legacy_wl_dir, context.deploy)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _run_self_sync(
|
|
233
|
+
current_version: str, legacy_wl_dir: Path | None, deploy_root: Root
|
|
234
|
+
) -> None:
|
|
235
|
+
"""Body of :func:`check_and_update_wl_dir`, anchored at *deploy_root*.
|
|
236
|
+
|
|
237
|
+
``deploy_root`` is the value every downstream step anchors its ``.wl``
|
|
238
|
+
lookups on; the sync no longer chdirs to it, so the root has to be handed
|
|
239
|
+
down explicitly rather than re-derived from the process CWD.
|
|
240
|
+
|
|
241
|
+
``legacy_wl_dir`` is the pre-#95 / #96 deploy point resolved from the
|
|
242
|
+
invocation directory through the root module's ``LEGACY`` kind; it is
|
|
243
|
+
threaded through untouched so the config migration never has to re-derive it.
|
|
244
|
+
|
|
245
|
+
The failure marker is cleared only when the sync fully succeeded; any
|
|
246
|
+
partial sub-step failure keeps it (or writes it) so the cooldown engages.
|
|
247
|
+
"""
|
|
248
|
+
# Hot path: don't retry a failed heavy sync on every command
|
|
249
|
+
if _update_failed_recently(deploy_root):
|
|
250
|
+
log_info_pair(
|
|
251
|
+
"Skipping .wl sync (previous attempt failed recently)...",
|
|
252
|
+
"正在跳过 .wl 同步(上次尝试失败不久)...",
|
|
253
|
+
)
|
|
254
|
+
return
|
|
255
|
+
|
|
256
|
+
try:
|
|
257
|
+
ok = sync_wl_dir(current_version, legacy_wl_dir, deploy_root)
|
|
258
|
+
except Exception as e:
|
|
259
|
+
_mark_update_failed(deploy_root)
|
|
260
|
+
log_info_pair(
|
|
261
|
+
f"Warning: .wl sync failed: {e}",
|
|
262
|
+
f"警告:.wl 同步失败:{e}",
|
|
263
|
+
)
|
|
264
|
+
return
|
|
265
|
+
|
|
266
|
+
# Only a fully successful sync clears the marker. A partial failure leaves
|
|
267
|
+
# (or writes) .update_failed so the cooldown engages and the config.json
|
|
268
|
+
# version stamp stays stale, forcing a retry after the cooldown.
|
|
269
|
+
if ok:
|
|
270
|
+
_clear_update_failed(deploy_root)
|
|
271
|
+
else:
|
|
272
|
+
_mark_update_failed(deploy_root)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def sync_wl_dir(
|
|
276
|
+
current_version: str,
|
|
277
|
+
legacy_wl_dir: Path | None = None,
|
|
278
|
+
deploy_root: Root | None = None,
|
|
279
|
+
) -> bool:
|
|
280
|
+
"""Single orchestration entry: decide what needs syncing and do it.
|
|
281
|
+
|
|
282
|
+
Full update (templates + hooks + version bump) runs when the stamped
|
|
283
|
+
version is missing or older than the current wl version. When the version
|
|
284
|
+
is current, only cheap staleness checks run.
|
|
285
|
+
|
|
286
|
+
``legacy_wl_dir`` is the pre-#95 / #96 deploy point, resolved from the
|
|
287
|
+
invocation directory by :func:`check_and_update_wl_dir` and passed down to
|
|
288
|
+
the config migration; it defaults to None for direct callers (e.g. tests),
|
|
289
|
+
which therefore never migrate.
|
|
290
|
+
|
|
291
|
+
``deploy_root`` is the root every ``.wl`` lookup below is anchored on. It is
|
|
292
|
+
passed down as a value from :func:`check_and_update_wl_dir` (which no longer
|
|
293
|
+
chdirs to it); direct callers may omit it, and this single-root entry then
|
|
294
|
+
resolves exactly the one root it needs through
|
|
295
|
+
:func:`~wl_cli.utils.root_fallback.current_root` (#160) -- never a
|
|
296
|
+
re-derivation deeper in the chain.
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
True when every executed sub-step succeeded (or nothing needed doing);
|
|
300
|
+
False when a sub-step failed. On failure the caller is responsible for
|
|
301
|
+
keeping/writing the .update_failed marker so the cooldown engages.
|
|
302
|
+
"""
|
|
303
|
+
if deploy_root is None:
|
|
304
|
+
deploy_root = current_root(RootKind.DEPLOY)
|
|
305
|
+
wl_dir = deploy_root.wl_dir()
|
|
306
|
+
config_file = wl_dir / "config.json"
|
|
307
|
+
|
|
308
|
+
if not config_file.exists():
|
|
309
|
+
log_info_pair(
|
|
310
|
+
"Updating .wl directory (missing config.json)...",
|
|
311
|
+
"正在更新 .wl 目录(缺少 config.json)...",
|
|
312
|
+
)
|
|
313
|
+
return _update_wl_directory(legacy_wl_dir, deploy_root)
|
|
314
|
+
|
|
315
|
+
try:
|
|
316
|
+
with open(config_file, encoding="utf-8") as f:
|
|
317
|
+
config = json.load(f)
|
|
318
|
+
|
|
319
|
+
wl_dir_version = config.get("version")
|
|
320
|
+
if not wl_dir_version:
|
|
321
|
+
log_info_pair(
|
|
322
|
+
"Updating .wl directory (missing version in config)...",
|
|
323
|
+
"正在更新 .wl 目录(配置中缺少版本)...",
|
|
324
|
+
)
|
|
325
|
+
return _update_wl_directory(legacy_wl_dir, deploy_root)
|
|
326
|
+
|
|
327
|
+
if compare_versions(wl_dir_version, current_version) < 0:
|
|
328
|
+
log_info_pair(
|
|
329
|
+
f"Updating .wl directory from version {wl_dir_version} to {current_version}...",
|
|
330
|
+
f"正在将 .wl 目录从版本 {wl_dir_version} 更新到 {current_version}...",
|
|
331
|
+
)
|
|
332
|
+
return _update_wl_directory(legacy_wl_dir, deploy_root)
|
|
333
|
+
else:
|
|
334
|
+
# Evaluate each stale flag EXACTLY ONCE per sync_wl_dir call. The
|
|
335
|
+
# template and hook paths are decoupled (generate_configs never
|
|
336
|
+
# re-syncs hooks), so the flags cannot change mid-update.
|
|
337
|
+
from ..commands.initenv.components.project_structure.hooks_manager import (
|
|
338
|
+
evaluate_hooks,
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
# Evaluated here, once, and handed to the sub-step as a value: the
|
|
342
|
+
# sync below consumes the path verdict instead of probing git again
|
|
343
|
+
# (#161).
|
|
344
|
+
hooks_state = evaluate_hooks(deploy_root)
|
|
345
|
+
templates_stale = _templates_need_sync(deploy_root)
|
|
346
|
+
|
|
347
|
+
# Light path: each stale sub-step is attempted independently, then
|
|
348
|
+
# the shared helper owns the partial-failure accounting -- which
|
|
349
|
+
# sub-step failed, the .update_failed marker, and the bilingual
|
|
350
|
+
# report -- the very same helper the full update uses.
|
|
351
|
+
return _apply_substeps(
|
|
352
|
+
partial(_refresh_templates_substep, templates_stale, deploy_root),
|
|
353
|
+
partial(_sync_hooks_substep, hooks_state, deploy_root),
|
|
354
|
+
deploy_root=deploy_root,
|
|
355
|
+
)
|
|
356
|
+
except (json.JSONDecodeError, OSError, ValueError) as e:
|
|
357
|
+
log_info_pair(
|
|
358
|
+
f"Updating .wl directory (error reading config.json: {e})...",
|
|
359
|
+
f"正在更新 .wl 目录(读取 config.json 错误:{e})...",
|
|
360
|
+
)
|
|
361
|
+
return _update_wl_directory(legacy_wl_dir, deploy_root)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _update_failed_marker(deploy_root: Root) -> Path:
|
|
365
|
+
"""Path of the marker written when a heavy .wl sync fails.
|
|
366
|
+
|
|
367
|
+
``deploy_root`` is required: it is the value the public entry resolved
|
|
368
|
+
(#160), never a per-call re-derivation from the process CWD.
|
|
369
|
+
"""
|
|
370
|
+
return deploy_root.wl_dir() / ".update_failed"
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _update_failed_recently(deploy_root: Root) -> bool:
|
|
374
|
+
"""Whether a sync failure marker is fresh (within the cooldown window)."""
|
|
375
|
+
marker = _update_failed_marker(deploy_root)
|
|
376
|
+
if not marker.exists():
|
|
377
|
+
return False
|
|
378
|
+
try:
|
|
379
|
+
return (time.time() - marker.stat().st_mtime) < _UPDATE_FAILED_COOLDOWN_SECONDS
|
|
380
|
+
except OSError:
|
|
381
|
+
return False
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _mark_update_failed(deploy_root: Root) -> None:
|
|
385
|
+
"""Record the cooldown marker, or say out loud that it could not be (#201 F3).
|
|
386
|
+
|
|
387
|
+
Swallowing the ``OSError`` here left the worst failure mode silent: a ``.wl``
|
|
388
|
+
directory the process cannot write (``chmod 000``) also blocks this marker,
|
|
389
|
+
so the cooldown never engages and *every* command retries the broken sync
|
|
390
|
+
with no end state. When the marker cannot be written the cooldown is
|
|
391
|
+
unavailable, so the warning is the only remaining signal -- say which
|
|
392
|
+
directory to repair instead of passing.
|
|
393
|
+
"""
|
|
394
|
+
marker = _update_failed_marker(deploy_root)
|
|
395
|
+
try:
|
|
396
|
+
marker.write_text(str(int(time.time())))
|
|
397
|
+
except OSError as e:
|
|
398
|
+
log_warning_pair(
|
|
399
|
+
f"Warning: could not record the .wl sync failure marker {marker} ({e}); "
|
|
400
|
+
"the cooldown cannot engage, so the sync will be retried on every "
|
|
401
|
+
f"command until {marker.parent} is writable.",
|
|
402
|
+
f"警告:无法写入 .wl 同步失败标记 {marker}({e});冷却无法生效,"
|
|
403
|
+
f"在 {marker.parent} 可写之前每次命令都会重试同步。",
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _clear_update_failed(deploy_root: Root) -> None:
|
|
408
|
+
try:
|
|
409
|
+
_update_failed_marker(deploy_root).unlink()
|
|
410
|
+
except OSError:
|
|
411
|
+
pass
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _apply_substeps(*substeps: Callable[[], bool], deploy_root: Root) -> bool:
|
|
415
|
+
"""Run the sync sub-steps and own the partial-failure accounting.
|
|
416
|
+
|
|
417
|
+
Both the light (staleness) path of :func:`sync_wl_dir` and the full
|
|
418
|
+
:func:`_update_wl_directory` run a sequence of sub-steps that each report
|
|
419
|
+
success with a bool. This is the single place that decides whether *any* of
|
|
420
|
+
them failed, writes the ``.update_failed`` marker when one did, and emits
|
|
421
|
+
the bilingual "partially updated" report -- and it does so *after* every
|
|
422
|
+
sub-step has run (a failed step never short-circuits a later one), so the
|
|
423
|
+
cooldown engages and the next command retries (regression #45).
|
|
424
|
+
|
|
425
|
+
Args:
|
|
426
|
+
*substeps: Sub-steps to run in order. Each has already logged what it is
|
|
427
|
+
about to do and reports its own failure; it returns True on success
|
|
428
|
+
and False on failure.
|
|
429
|
+
deploy_root: The deploy root the failure marker is anchored on, threaded
|
|
430
|
+
in as a value (the caller no longer chdirs to it).
|
|
431
|
+
|
|
432
|
+
Returns:
|
|
433
|
+
True when every sub-step succeeded; False when one or more failed (the
|
|
434
|
+
failure marker has been written).
|
|
435
|
+
"""
|
|
436
|
+
ok = True
|
|
437
|
+
for substep in substeps:
|
|
438
|
+
if not substep():
|
|
439
|
+
ok = False
|
|
440
|
+
|
|
441
|
+
if not ok:
|
|
442
|
+
_mark_update_failed(deploy_root)
|
|
443
|
+
log_info_pair(
|
|
444
|
+
"Warning: .wl directory partially updated; sync will retry after "
|
|
445
|
+
"the cooldown.",
|
|
446
|
+
"警告:.wl 目录部分更新失败;将在冷却后重试。",
|
|
447
|
+
)
|
|
448
|
+
return ok
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _refresh_templates_substep(stale: bool, deploy_root: Root) -> bool:
|
|
452
|
+
"""Light-path sub-step: refresh templates when the cheap check flagged them.
|
|
453
|
+
|
|
454
|
+
A flag of False means there is nothing to do, which counts as success so the
|
|
455
|
+
shared accounting in :func:`_apply_substeps` only reacts to real failures.
|
|
456
|
+
"""
|
|
457
|
+
if not stale:
|
|
458
|
+
return True
|
|
459
|
+
log_info_pair(
|
|
460
|
+
"Updating .wl templates (template migration pending)...",
|
|
461
|
+
"正在更新 .wl 模板(模板迁移待处理)...",
|
|
462
|
+
)
|
|
463
|
+
return _update_templates_directory(deploy_root)
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _sync_hooks_substep(state: HookSyncState, deploy_root: Root) -> bool:
|
|
467
|
+
"""Light-path sub-step: sync hooks when the cheap check flagged them.
|
|
468
|
+
|
|
469
|
+
``state`` is the value :func:`sync_wl_dir` already evaluated; it is handed
|
|
470
|
+
to :func:`_update_hooks_directory` rather than re-derived here, so the light
|
|
471
|
+
path probes git exactly once (#161).
|
|
472
|
+
|
|
473
|
+
A state with ``needs_sync`` False means there is nothing to do, which counts
|
|
474
|
+
as success so the shared accounting in :func:`_apply_substeps` only reacts
|
|
475
|
+
to real failures.
|
|
476
|
+
"""
|
|
477
|
+
if not state.needs_sync:
|
|
478
|
+
return True
|
|
479
|
+
log_info_pair(
|
|
480
|
+
"Updating .wl hooks (stale/missing hook or bad core.hooksPath detected)...",
|
|
481
|
+
"正在更新 .wl 钩子(检测到钩子过期/缺失或 core.hooksPath 失效)...",
|
|
482
|
+
)
|
|
483
|
+
return _update_hooks_directory(state, deploy_root)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _generate_configs_substep(setup_handler: Any, deploy_root: Root) -> bool:
|
|
487
|
+
"""Full-path sub-step: (re)generate the vendored .wl configs.
|
|
488
|
+
|
|
489
|
+
Returns:
|
|
490
|
+
True when generation succeeded -- that is, ``.wl/config.json`` exists
|
|
491
|
+
afterwards; False when it raised or produced no config. The shared
|
|
492
|
+
accounting in :func:`_apply_substeps` then writes the failure marker and
|
|
493
|
+
the caller leaves the config.json version stamp stale. Taking the
|
|
494
|
+
generator's own verdict (instead of "it did not raise") is what stops a
|
|
495
|
+
half-built ``.wl`` -- templates deployed, no config -- from being
|
|
496
|
+
reported as a successful first sync (#202).
|
|
497
|
+
"""
|
|
498
|
+
try:
|
|
499
|
+
# Both regeneration paths share this preparation: set a non-object
|
|
500
|
+
# manifest aside and refuse a rebuild whose save would fail silently
|
|
501
|
+
# (#201 F1/F2/F3).
|
|
502
|
+
if not _prepare_template_manifest_for_rebuild(deploy_root):
|
|
503
|
+
return False
|
|
504
|
+
return bool(setup_handler.config_generator.generate_configs(deploy_root))
|
|
505
|
+
except Exception as e:
|
|
506
|
+
log_info_pair(
|
|
507
|
+
f"Warning: Failed to generate .wl configs: {e}",
|
|
508
|
+
f"警告:生成 .wl 配置失败:{e}",
|
|
509
|
+
)
|
|
510
|
+
return False
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _templates_need_sync(deploy_root: Root) -> bool:
|
|
514
|
+
"""Cheap check: are the tracked config templates still on disk?
|
|
515
|
+
|
|
516
|
+
A missing template manifest means a one-time migration is pending --
|
|
517
|
+
existing .wl config templates must be refreshed to the current source
|
|
518
|
+
(propagates template bug fixes to already-initialized projects).
|
|
519
|
+
|
|
520
|
+
A *present* manifest is not the same answer, though (#190). It only says
|
|
521
|
+
"these files were deployed once"; a template deleted from ``.wl`` afterwards
|
|
522
|
+
(hand cleanup, a botched checkout) stayed missing forever, so ``wl format``
|
|
523
|
+
printed "skipping pre-commit hooks" and went green while ``wl lint`` failed
|
|
524
|
+
with no way to self-heal. Once a manifest exists, every destination it tracks
|
|
525
|
+
is checked with a plain ``exists()`` -- no hashing, so the per-command hot
|
|
526
|
+
path stays cheap and a converged checkout keeps costing the same.
|
|
527
|
+
|
|
528
|
+
Destinations pinned ``user: true`` are deliberately skipped: the tool never
|
|
529
|
+
re-propagates to them, so requiring them would make this check permanently
|
|
530
|
+
true -- trading one never-converging loop for another.
|
|
531
|
+
|
|
532
|
+
A manifest that is *present but unreadable* is not evidence of freshness
|
|
533
|
+
either (#197). ``ConfigGenerator._load_template_manifest`` answers ``{}`` for
|
|
534
|
+
both "no manifest" and "manifest did not parse", so a truncated or
|
|
535
|
+
non-object ``.templates.json`` used to reach this function as an empty
|
|
536
|
+
manifest -- "zero destinations missing" -- and the refresh branch below was
|
|
537
|
+
unreachable for the very corruption it was written for. The manifest is
|
|
538
|
+
therefore classified here, before the generator sees it: a corrupt one is
|
|
539
|
+
reported (``log_warning_pair``) and answered "stale", so the next sync
|
|
540
|
+
rebuilds it through ``generate_configs`` (which rewrites the manifest).
|
|
541
|
+
"""
|
|
542
|
+
from wl_cli.commands.initenv.components.project_structure.config_generator import (
|
|
543
|
+
ConfigGenerator,
|
|
544
|
+
template_manifest_path,
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
manifest_path = template_manifest_path(deploy_root)
|
|
548
|
+
if not manifest_path.exists():
|
|
549
|
+
# Missing is a normal migration state, deliberately not a warning.
|
|
550
|
+
return True
|
|
551
|
+
|
|
552
|
+
try:
|
|
553
|
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
554
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
555
|
+
log_warning_pair(
|
|
556
|
+
f"Warning: template manifest {manifest_path} is unreadable ({e}); "
|
|
557
|
+
"rebuilding it",
|
|
558
|
+
f"警告:模板 manifest {manifest_path} 无法读取({e});正在重建",
|
|
559
|
+
)
|
|
560
|
+
return True
|
|
561
|
+
|
|
562
|
+
if not isinstance(manifest, dict):
|
|
563
|
+
log_warning_pair(
|
|
564
|
+
f"Warning: template manifest {manifest_path} is not a JSON object; "
|
|
565
|
+
"rebuilding it",
|
|
566
|
+
f"警告:模板 manifest {manifest_path} 不是 JSON 对象;正在重建",
|
|
567
|
+
)
|
|
568
|
+
return True
|
|
569
|
+
|
|
570
|
+
try:
|
|
571
|
+
return bool(ConfigGenerator().missing_template_destinations(deploy_root))
|
|
572
|
+
except Exception as e:
|
|
573
|
+
# Any other check failure is still not evidence of freshness: refresh
|
|
574
|
+
# rather than let an error silently answer "up to date", and say so.
|
|
575
|
+
log_warning_pair(
|
|
576
|
+
f"Warning: could not check template destinations ({e}); refreshing",
|
|
577
|
+
f"警告:无法检查模板目标文件({e});正在刷新",
|
|
578
|
+
)
|
|
579
|
+
return True
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _preserve_corrupt_manifest(manifest_path: Path, raw: bytes) -> Path:
|
|
583
|
+
"""Write *raw* into the first free ``.corrupt`` / ``.corrupt.N`` name (#201).
|
|
584
|
+
|
|
585
|
+
The name is claimed with ``O_CREAT | O_EXCL``, so two updaters can never
|
|
586
|
+
select the same file: whichever loses the race moves on to the next free
|
|
587
|
+
number instead of overwriting the first scene's evidence. Existence is
|
|
588
|
+
therefore never checked separately from the claim -- ``exists()`` followed
|
|
589
|
+
by a write is exactly the non-atomic pair this replaces.
|
|
590
|
+
|
|
591
|
+
The bytes written are the *snapshot that was parsed*, not whatever the
|
|
592
|
+
manifest path holds now: a concurrent updater may already have replaced the
|
|
593
|
+
corrupt file with a rebuilt valid manifest, and that manifest must never be
|
|
594
|
+
filed away as corrupt evidence.
|
|
595
|
+
|
|
596
|
+
Raises:
|
|
597
|
+
OSError: no free name could be claimed, or the bytes could not be
|
|
598
|
+
written (the claimed placeholder is removed first).
|
|
599
|
+
"""
|
|
600
|
+
index = 0
|
|
601
|
+
while True:
|
|
602
|
+
suffix = ".corrupt" if index == 0 else f".corrupt.{index}"
|
|
603
|
+
candidate = manifest_path.with_name(manifest_path.name + suffix)
|
|
604
|
+
try:
|
|
605
|
+
fd = os.open(candidate, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
606
|
+
except FileExistsError:
|
|
607
|
+
index += 1
|
|
608
|
+
continue
|
|
609
|
+
try:
|
|
610
|
+
with os.fdopen(fd, "wb") as handle:
|
|
611
|
+
handle.write(raw)
|
|
612
|
+
except OSError:
|
|
613
|
+
candidate.unlink(missing_ok=True)
|
|
614
|
+
raise
|
|
615
|
+
return candidate
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _quarantine_unusable_template_manifest(manifest_path: Path) -> None:
|
|
619
|
+
"""Set aside a manifest that a rebuild could not rewrite in place (#197).
|
|
620
|
+
|
|
621
|
+
``ConfigGenerator`` treats an *unparsable* manifest as ``{}`` and rewrites
|
|
622
|
+
it, but valid JSON of the wrong shape (``[]``, ``"x"``) reaches it as a
|
|
623
|
+
non-mapping and every entry write raises -- so the corrupt file would make
|
|
624
|
+
every rebuild fail, one cooldown at a time, forever. Moving it aside lets
|
|
625
|
+
``generate_configs`` start from a clean slate; the old bytes are kept next
|
|
626
|
+
to it as ``.templates.json.corrupt`` instead of being silently destroyed.
|
|
627
|
+
|
|
628
|
+
Repeated or concurrent corruption must not eat its own evidence (#201): the
|
|
629
|
+
backup name is claimed atomically by :func:`_preserve_corrupt_manifest`, and
|
|
630
|
+
the corrupt bytes are copied rather than the manifest path moved, so a
|
|
631
|
+
scene preserved by one updater cannot be replaced by another.
|
|
632
|
+
"""
|
|
633
|
+
if not manifest_path.exists():
|
|
634
|
+
return
|
|
635
|
+
|
|
636
|
+
try:
|
|
637
|
+
raw = manifest_path.read_bytes()
|
|
638
|
+
except OSError:
|
|
639
|
+
# Unreadable is answered "stale" by the caller; nothing to preserve.
|
|
640
|
+
return
|
|
641
|
+
|
|
642
|
+
try:
|
|
643
|
+
parsed = json.loads(raw.decode("utf-8"))
|
|
644
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
645
|
+
# The generator already handles these as "empty manifest"; no action.
|
|
646
|
+
return
|
|
647
|
+
|
|
648
|
+
if isinstance(parsed, dict):
|
|
649
|
+
return
|
|
650
|
+
|
|
651
|
+
try:
|
|
652
|
+
backup = _preserve_corrupt_manifest(manifest_path, raw)
|
|
653
|
+
except OSError as e:
|
|
654
|
+
log_warning_pair(
|
|
655
|
+
f"Warning: could not set aside unusable template manifest "
|
|
656
|
+
f"{manifest_path} ({e})",
|
|
657
|
+
f"警告:无法移走不可用的模板 manifest {manifest_path}({e})",
|
|
658
|
+
)
|
|
659
|
+
return
|
|
660
|
+
|
|
661
|
+
try:
|
|
662
|
+
manifest_path.unlink()
|
|
663
|
+
except FileNotFoundError:
|
|
664
|
+
# A concurrent updater already removed it; our copy is still evidence.
|
|
665
|
+
pass
|
|
666
|
+
except OSError as e:
|
|
667
|
+
backup.unlink(missing_ok=True)
|
|
668
|
+
log_warning_pair(
|
|
669
|
+
f"Warning: could not remove unusable template manifest "
|
|
670
|
+
f"{manifest_path} ({e})",
|
|
671
|
+
f"警告:无法移除不可用的模板 manifest {manifest_path}({e})",
|
|
672
|
+
)
|
|
673
|
+
return
|
|
674
|
+
|
|
675
|
+
log_warning_pair(
|
|
676
|
+
f"Warning: template manifest {manifest_path} was not a JSON object; "
|
|
677
|
+
f"moved to {backup} and rebuilding",
|
|
678
|
+
f"警告:模板 manifest {manifest_path} 不是 JSON 对象;已移至 {backup} 并重建",
|
|
679
|
+
)
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _template_manifest_is_writable(manifest_path: Path) -> bool:
|
|
683
|
+
"""Whether a present template manifest can be rewritten in place (#201 F3).
|
|
684
|
+
|
|
685
|
+
``ConfigGenerator._save_template_manifest`` used to swallow the ``OSError``
|
|
686
|
+
from its write, and ``generate_configs`` only reported whether
|
|
687
|
+
``config.json`` exists -- so a manifest the process cannot write
|
|
688
|
+
(``chmod 000``) looked like a *successful* refresh. The manifest stayed
|
|
689
|
+
stale, ``_templates_need_sync`` answered "stale" again, and every ``wl``
|
|
690
|
+
command re-ran the rebuild with no cooldown to bound it. Probing write
|
|
691
|
+
access up front turns that silent loop into a failed sync sub-step: a
|
|
692
|
+
visible warning plus the ``.update_failed`` cooldown.
|
|
693
|
+
|
|
694
|
+
A missing manifest answers "writable" here on purpose: the file is created
|
|
695
|
+
by the save that follows, and a directory probe could not answer for that
|
|
696
|
+
write reliably (root/ACL/NFS ignores or masks permission bits, and the
|
|
697
|
+
probe is TOCTOU anyway). That state is covered at the save instead --
|
|
698
|
+
``_save_template_manifest`` now returns its outcome and
|
|
699
|
+
``generate_configs`` propagates it (#201), so a read-only ``.wl`` with no
|
|
700
|
+
manifest fails the sub-step and engages the cooldown too. This probe never
|
|
701
|
+
raises.
|
|
702
|
+
"""
|
|
703
|
+
if not manifest_path.exists():
|
|
704
|
+
return True
|
|
705
|
+
return os.access(manifest_path, os.W_OK)
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def _prepare_template_manifest_for_rebuild(deploy_root: Root) -> bool:
|
|
709
|
+
"""Make the template manifest rebuildable in place.
|
|
710
|
+
|
|
711
|
+
Shared by both paths that regenerate configs -- the light staleness path
|
|
712
|
+
(:func:`_update_templates_directory`) and the full version-outdated path
|
|
713
|
+
(:func:`_generate_configs_substep`) -- because the quarantine used to run
|
|
714
|
+
only on the light one (#201 F2): a non-object manifest on an outdated
|
|
715
|
+
version made ``generate_configs`` raise, the sub-step reported failure and
|
|
716
|
+
the sync wrote ``.update_failed``, so the very corruption the quarantine
|
|
717
|
+
exists for was never set aside there.
|
|
718
|
+
|
|
719
|
+
The manifest path is resolved once here and handed to both helpers, so the
|
|
720
|
+
classification and the writability probe cannot disagree after a mid-sync
|
|
721
|
+
replace, and the manifest is not imported/probed three times.
|
|
722
|
+
|
|
723
|
+
Returns:
|
|
724
|
+
True when a rebuild may proceed; False when the manifest exists but
|
|
725
|
+
cannot be rewritten -- reported as a visible warning so the caller's
|
|
726
|
+
failed sub-step engages the cooldown instead of retrying per command.
|
|
727
|
+
A *missing* manifest proceeds, and a save that then fails is reported
|
|
728
|
+
by the generator's own verdict (#201).
|
|
729
|
+
"""
|
|
730
|
+
from wl_cli.commands.initenv.components.project_structure.config_generator import (
|
|
731
|
+
template_manifest_path,
|
|
732
|
+
)
|
|
733
|
+
|
|
734
|
+
manifest_path = template_manifest_path(deploy_root)
|
|
735
|
+
_quarantine_unusable_template_manifest(manifest_path)
|
|
736
|
+
if _template_manifest_is_writable(manifest_path):
|
|
737
|
+
return True
|
|
738
|
+
|
|
739
|
+
log_warning_pair(
|
|
740
|
+
f"Warning: template manifest {manifest_path} is not writable; the .wl "
|
|
741
|
+
"templates cannot be re-synced until that is fixed (e.g. "
|
|
742
|
+
f"`chmod u+w {manifest_path}`). The sync will retry after the cooldown.",
|
|
743
|
+
f"警告:模板 manifest {manifest_path} 不可写;在修复之前无法重新同步 .wl "
|
|
744
|
+
f"模板(例如 `chmod u+w {manifest_path}`)。同步将在冷却后重试。",
|
|
745
|
+
)
|
|
746
|
+
return False
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def _update_templates_directory(deploy_root: Root) -> bool:
|
|
750
|
+
"""Refresh .wl config templates from the vendored sources.
|
|
751
|
+
|
|
752
|
+
Returns:
|
|
753
|
+
True when generation succeeded (even if nothing changed); False when it
|
|
754
|
+
raised, or when it produced no ``.wl/config.json`` -- the caller treats
|
|
755
|
+
that as a failed sync sub-step (#202).
|
|
756
|
+
"""
|
|
757
|
+
try:
|
|
758
|
+
from wl_cli.commands.initenv.components.project_structure.setup_handler import (
|
|
759
|
+
ProjectStructureSetup,
|
|
760
|
+
)
|
|
761
|
+
|
|
762
|
+
if not _prepare_template_manifest_for_rebuild(deploy_root):
|
|
763
|
+
return False
|
|
764
|
+
setup = ProjectStructureSetup()
|
|
765
|
+
return bool(setup.config_generator.generate_configs(deploy_root))
|
|
766
|
+
except Exception as e:
|
|
767
|
+
log_info_pair(
|
|
768
|
+
f"Warning: Failed to update .wl templates: {e}",
|
|
769
|
+
f"警告: 更新 .wl 模板失败:{e}",
|
|
770
|
+
)
|
|
771
|
+
return False
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def compare_versions(version1: str, version2: str) -> int:
|
|
775
|
+
"""Public API to compare two version strings.
|
|
776
|
+
|
|
777
|
+
Thin wrapper over :class:`~wl_cli.utils.version.version_comparator.VersionComparator`:
|
|
778
|
+
the unparsable-version policy lives here (an unparsable string is treated
|
|
779
|
+
as outdated so a corrupted .wl version stamp gets healed by a full update,
|
|
780
|
+
instead of silently comparing equal and never updating).
|
|
781
|
+
|
|
782
|
+
Args:
|
|
783
|
+
version1: First version string (e.g., "0.2.24")
|
|
784
|
+
version2: Second version string (e.g., "0.3.0")
|
|
785
|
+
|
|
786
|
+
Returns:
|
|
787
|
+
int: -1 if version1 < version2, 0 if equal, 1 if version1 > version2
|
|
788
|
+
"""
|
|
789
|
+
from wl_cli.utils.version.version_comparator import VersionComparator
|
|
790
|
+
|
|
791
|
+
try:
|
|
792
|
+
return cast("int", VersionComparator.compare_versions(version1, version2))
|
|
793
|
+
except ValueError:
|
|
794
|
+
return -1
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def _update_wl_directory(legacy_wl_dir: Path | None, deploy_root: Root) -> bool:
|
|
798
|
+
"""
|
|
799
|
+
Update the .wl directory with the latest files from vendors.
|
|
800
|
+
|
|
801
|
+
Order matters: hooks and templates are updated first, and the config.json
|
|
802
|
+
version stamp is bumped LAST, so a failure leaves the stamp stale and the
|
|
803
|
+
next command retries the sync.
|
|
804
|
+
|
|
805
|
+
``deploy_root`` is threaded in as a value (see :func:`sync_wl_dir`); it has
|
|
806
|
+
no default, so no layer can silently re-derive one from the process CWD
|
|
807
|
+
(#160).
|
|
808
|
+
|
|
809
|
+
Returns:
|
|
810
|
+
True only when hooks AND config generation both succeeded -- the
|
|
811
|
+
version stamp is bumped and the failure marker cleared. On any sub-step
|
|
812
|
+
failure the shared accounting in :func:`_apply_substeps` keeps the stamp
|
|
813
|
+
stale (the marker is written), so the cooldown engages and the sync is
|
|
814
|
+
retried after it.
|
|
815
|
+
"""
|
|
816
|
+
from wl_cli.commands.initenv.components.project_structure.setup_handler import (
|
|
817
|
+
ProjectStructureSetup,
|
|
818
|
+
)
|
|
819
|
+
|
|
820
|
+
try:
|
|
821
|
+
setup_handler = ProjectStructureSetup()
|
|
822
|
+
|
|
823
|
+
# Create .wl directory if it doesn't exist (deploy root, not CWD)
|
|
824
|
+
wl_dir = deploy_root.wl_dir()
|
|
825
|
+
wl_dir.mkdir(parents=True, exist_ok=True)
|
|
826
|
+
|
|
827
|
+
# Migrate a pre-#95/#96 config before anything reads or writes one, so a
|
|
828
|
+
# divergent legacy `.wl` is carried over rather than silently dropped.
|
|
829
|
+
_migrate_legacy_config_if_needed(wl_dir, legacy_wl_dir)
|
|
830
|
+
|
|
831
|
+
# Evaluate the hook verdict once and hand it to the hooks sub-step: the
|
|
832
|
+
# full path must not probe core.hooksPath a second time inside the sync
|
|
833
|
+
# (#161).
|
|
834
|
+
from ..commands.initenv.components.project_structure.hooks_manager import (
|
|
835
|
+
evaluate_hooks,
|
|
836
|
+
)
|
|
837
|
+
|
|
838
|
+
hooks_state = evaluate_hooks(deploy_root)
|
|
839
|
+
|
|
840
|
+
# Update hooks (hash-aware: only re-copies changed hooks), then other
|
|
841
|
+
# .wl files (templates copied only if missing). Both always run -- the
|
|
842
|
+
# shared helper never short-circuits -- and owns the partial-failure
|
|
843
|
+
# accounting (the .update_failed marker) for the pair.
|
|
844
|
+
ok = _apply_substeps(
|
|
845
|
+
partial(_update_hooks_directory, hooks_state, deploy_root),
|
|
846
|
+
partial(_generate_configs_substep, setup_handler, deploy_root),
|
|
847
|
+
deploy_root=deploy_root,
|
|
848
|
+
)
|
|
849
|
+
if not ok:
|
|
850
|
+
# Keep the stamp stale so the cooldown engages (do NOT bump).
|
|
851
|
+
return False
|
|
852
|
+
|
|
853
|
+
# Bump version LAST so failures above leave a stale stamp → retried.
|
|
854
|
+
# A failed stamp write is itself a failed sub-step (#177): keep the
|
|
855
|
+
# marker and report False so the cooldown engages. Reporting success
|
|
856
|
+
# while the stamp stayed stale made every later command re-run the
|
|
857
|
+
# full sync on a read-only checkout.
|
|
858
|
+
if not _update_config_version(deploy_root):
|
|
859
|
+
_mark_update_failed(deploy_root)
|
|
860
|
+
log_warning_pair(
|
|
861
|
+
"Warning: failed to update the .wl version stamp; the sync will "
|
|
862
|
+
"retry after the cooldown.",
|
|
863
|
+
"警告:更新 .wl 版本戳失败;将在冷却后重试同步。",
|
|
864
|
+
)
|
|
865
|
+
return False
|
|
866
|
+
|
|
867
|
+
_clear_update_failed(deploy_root)
|
|
868
|
+
|
|
869
|
+
log_info_pair(
|
|
870
|
+
"✓ .wl directory updated successfully",
|
|
871
|
+
"✓ .wl 目录更新成功",
|
|
872
|
+
)
|
|
873
|
+
return True
|
|
874
|
+
|
|
875
|
+
except Exception as e:
|
|
876
|
+
_mark_update_failed(deploy_root)
|
|
877
|
+
log_info_pair(
|
|
878
|
+
f"Warning: Failed to update .wl directory: {e}",
|
|
879
|
+
f"警告:更新 .wl 目录失败:{e}",
|
|
880
|
+
)
|
|
881
|
+
return False
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
def _update_config_version(deploy_root: Root) -> bool:
|
|
885
|
+
"""
|
|
886
|
+
Update the version in config.json.
|
|
887
|
+
|
|
888
|
+
``deploy_root`` is required (#160): the stamp lands in the very ``.wl`` the
|
|
889
|
+
caller resolved, never in one re-derived from the process CWD.
|
|
890
|
+
|
|
891
|
+
Returns:
|
|
892
|
+
True when the stamp was written (or intentionally skipped in a test
|
|
893
|
+
environment); False when the direct write failed *and* the fallback
|
|
894
|
+
could not write this very ``config.json``. The caller treats False as a
|
|
895
|
+
failed sub-step (#177) -- previously this swallowed the error and the
|
|
896
|
+
sync claimed success with a stale stamp.
|
|
897
|
+
"""
|
|
898
|
+
import json
|
|
899
|
+
|
|
900
|
+
from wl_cli import __version__
|
|
901
|
+
|
|
902
|
+
wl_dir = deploy_root.wl_dir()
|
|
903
|
+
config_file = wl_dir / "config.json"
|
|
904
|
+
|
|
905
|
+
try:
|
|
906
|
+
# Read existing config
|
|
907
|
+
with open(config_file, encoding="utf-8") as f:
|
|
908
|
+
config = json.load(f)
|
|
909
|
+
|
|
910
|
+
# Update only the version field
|
|
911
|
+
config["version"] = __version__
|
|
912
|
+
|
|
913
|
+
# Save back the config
|
|
914
|
+
with open(config_file, "w", encoding="utf-8") as f:
|
|
915
|
+
json.dump(config, f, indent=2, ensure_ascii=False)
|
|
916
|
+
f.write("\n")
|
|
917
|
+
return True
|
|
918
|
+
except (json.JSONDecodeError, OSError):
|
|
919
|
+
# If there's an error, fall back to using config manager
|
|
920
|
+
from .config import get_config_manager
|
|
921
|
+
|
|
922
|
+
config_manager = get_config_manager()
|
|
923
|
+
# ...but only when that manager writes THIS config.json. Its
|
|
924
|
+
# ``config_file`` is whatever its own resolution produced --
|
|
925
|
+
# ``WL_CONFIG_PATH``, or a process CWD outside the checkout -- and
|
|
926
|
+
# ``set()`` then reports a successful write into an unrelated file while
|
|
927
|
+
# ``.wl/config.json`` stays absent. Counting that as success was the
|
|
928
|
+
# proxy predicate behind #190: the next command found "missing config",
|
|
929
|
+
# re-ran the whole sync, and the sync never converged (no cooldown, no
|
|
930
|
+
# end state). Refuse to certify a write we cannot see; the caller turns
|
|
931
|
+
# False into the failure marker, so the retry is bounded by the cooldown.
|
|
932
|
+
if not _same_path(Path(config_manager.config_file), config_file):
|
|
933
|
+
log_warning_pair(
|
|
934
|
+
f"Warning: cannot update the .wl version stamp: the active config "
|
|
935
|
+
f"file is {config_manager.config_file}, not {config_file}.",
|
|
936
|
+
f"警告:无法更新 .wl 版本戳:当前生效的配置文件是 "
|
|
937
|
+
f"{config_manager.config_file},而不是 {config_file}。",
|
|
938
|
+
)
|
|
939
|
+
return False
|
|
940
|
+
if not config_manager.set("version", __version__):
|
|
941
|
+
return False
|
|
942
|
+
# ``set`` answers True for a *deliberate* no-op in a test environment
|
|
943
|
+
# (ConfigSaver's guard). Certify the stamp from the bytes on disk, or a
|
|
944
|
+
# pytest-process sync reports a stamped ``.wl`` it never wrote (#202).
|
|
945
|
+
try:
|
|
946
|
+
with open(config_file, encoding="utf-8") as f:
|
|
947
|
+
stamped = json.load(f)
|
|
948
|
+
except (json.JSONDecodeError, OSError):
|
|
949
|
+
return False
|
|
950
|
+
if not isinstance(stamped, dict):
|
|
951
|
+
return False
|
|
952
|
+
return bool(stamped.get("version") == __version__)
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
def _update_hooks_directory(state: HookSyncState, deploy_root: Root) -> bool:
|
|
956
|
+
"""
|
|
957
|
+
Update the hooks directory with the latest files from vendors.
|
|
958
|
+
|
|
959
|
+
``state`` is the :class:`HookSyncState` the caller already evaluated (the
|
|
960
|
+
light path in :func:`sync_wl_dir`, the full path in
|
|
961
|
+
:func:`_update_wl_directory`); it is consumed by ``apply_hook_sync``, which
|
|
962
|
+
also repairs a stale ``core.hooksPath`` (unset, dangling, or pointing
|
|
963
|
+
elsewhere) even when no hook file changed -- without probing git again
|
|
964
|
+
(#161). ``deploy_root`` is threaded through as a value so the hooks leg is
|
|
965
|
+
anchored on it rather than re-derived from the process CWD.
|
|
966
|
+
|
|
967
|
+
Returns:
|
|
968
|
+
True when syncing succeeded (even if nothing changed); False when it
|
|
969
|
+
raised -- the caller treats that as a failed sync sub-step.
|
|
970
|
+
"""
|
|
971
|
+
try:
|
|
972
|
+
# Use the existing hook syncing logic from hooks_manager
|
|
973
|
+
from ..commands.initenv.components.project_structure.hooks_manager import (
|
|
974
|
+
apply_hook_sync,
|
|
975
|
+
)
|
|
976
|
+
|
|
977
|
+
# Update hooks directory with the already computed verdict
|
|
978
|
+
apply_hook_sync(state, deploy_root)
|
|
979
|
+
return True
|
|
980
|
+
|
|
981
|
+
except Exception as e:
|
|
982
|
+
log_info_pair(
|
|
983
|
+
f"Warning: Failed to update hooks directory: {e}",
|
|
984
|
+
f"警告:更新钩子目录失败:{e}",
|
|
985
|
+
)
|
|
986
|
+
return False
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
def _get_components_to_update(
|
|
990
|
+
component_id: str | None,
|
|
991
|
+
component_manager,
|
|
992
|
+
dry_run: bool,
|
|
993
|
+
result: dict,
|
|
994
|
+
) -> list:
|
|
995
|
+
"""Get list of components to update.
|
|
996
|
+
|
|
997
|
+
Args:
|
|
998
|
+
component_id: Specific component ID to update or None for all
|
|
999
|
+
component_manager: ComponentManager instance
|
|
1000
|
+
dry_run: Whether this is a dry run
|
|
1001
|
+
result: Result dictionary to update in case of errors
|
|
1002
|
+
|
|
1003
|
+
Returns:
|
|
1004
|
+
List of tuples (component_id, component_info) to update, or empty list if error
|
|
1005
|
+
"""
|
|
1006
|
+
if component_id:
|
|
1007
|
+
if component_manager.component_exists(component_id):
|
|
1008
|
+
return [(component_id, component_manager.get_component(component_id))]
|
|
1009
|
+
else:
|
|
1010
|
+
result["errors"].append(f"Component {component_id} not found")
|
|
1011
|
+
result["message"] = f"Component {component_id} not found"
|
|
1012
|
+
result["message_zh"] = f"组件 {component_id} 不存在"
|
|
1013
|
+
return []
|
|
1014
|
+
else:
|
|
1015
|
+
return list(component_manager.get_all_components().items())
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
def _update_vendor_files(
|
|
1019
|
+
components_to_update: list,
|
|
1020
|
+
package_path: Path,
|
|
1021
|
+
wl_dir: Path,
|
|
1022
|
+
dry_run: bool,
|
|
1023
|
+
) -> list:
|
|
1024
|
+
"""Copy vendor files for all components.
|
|
1025
|
+
|
|
1026
|
+
``ComponentManager``'s ``source_path`` is package-relative
|
|
1027
|
+
(``"vendors/git/"``, ``"vendors/config/"``), so the *same* relative spelling
|
|
1028
|
+
is joined on both sides: ``<package>/<source_path>`` -> ``<.wl>/<source_path>``.
|
|
1029
|
+
Joining the source onto a separate ``vendors`` segment produced
|
|
1030
|
+
``.wl/vendors/vendors/git/...`` while ``.wl/vendors/git/`` never appeared --
|
|
1031
|
+
and the vendor stamp was written anyway, so the wrong tree was certified
|
|
1032
|
+
fresh and never repaired (#190).
|
|
1033
|
+
|
|
1034
|
+
Args:
|
|
1035
|
+
components_to_update: List of components to update
|
|
1036
|
+
package_path: Path to the installed package root
|
|
1037
|
+
wl_dir: Path to .wl directory
|
|
1038
|
+
dry_run: Whether this is a dry run
|
|
1039
|
+
|
|
1040
|
+
Returns:
|
|
1041
|
+
List of copied file paths
|
|
1042
|
+
"""
|
|
1043
|
+
all_files_copied = []
|
|
1044
|
+
for comp_id, comp_info in components_to_update:
|
|
1045
|
+
source_path = package_path / comp_info["source_path"]
|
|
1046
|
+
target_path = wl_dir / comp_info["source_path"]
|
|
1047
|
+
|
|
1048
|
+
if source_path.exists():
|
|
1049
|
+
files = _copy_vendor_files(source_path, target_path, dry_run)
|
|
1050
|
+
all_files_copied.extend(files)
|
|
1051
|
+
else:
|
|
1052
|
+
if dry_run:
|
|
1053
|
+
log_info_pair(
|
|
1054
|
+
f"Component {comp_id} source not found: {source_path}",
|
|
1055
|
+
f"组件 {comp_id} 源文件不存在:{source_path}",
|
|
1056
|
+
)
|
|
1057
|
+
return all_files_copied
|
|
1058
|
+
|
|
1059
|
+
|
|
1060
|
+
def _set_vendor_result(
|
|
1061
|
+
result: dict,
|
|
1062
|
+
all_files_copied: list,
|
|
1063
|
+
dry_run: bool,
|
|
1064
|
+
) -> None:
|
|
1065
|
+
"""Update result dictionary based on copied files.
|
|
1066
|
+
|
|
1067
|
+
Args:
|
|
1068
|
+
result: Result dictionary to update
|
|
1069
|
+
all_files_copied: List of copied file paths
|
|
1070
|
+
dry_run: Whether this is a dry run
|
|
1071
|
+
|
|
1072
|
+
Returns:
|
|
1073
|
+
None
|
|
1074
|
+
"""
|
|
1075
|
+
if all_files_copied:
|
|
1076
|
+
result["updated"] = True
|
|
1077
|
+
result["success"] = True
|
|
1078
|
+
if dry_run:
|
|
1079
|
+
result["message"] = f"Dry run: Would copy {len(all_files_copied)} files"
|
|
1080
|
+
result["message_zh"] = f"干运行:将复制 {len(all_files_copied)} 个文件"
|
|
1081
|
+
else:
|
|
1082
|
+
result["message"] = (
|
|
1083
|
+
f"Successfully updated vendors ({len(all_files_copied)} files copied)"
|
|
1084
|
+
)
|
|
1085
|
+
result["message_zh"] = (
|
|
1086
|
+
f"成功更新 vendors(复制了 {len(all_files_copied)} 个文件)"
|
|
1087
|
+
)
|
|
1088
|
+
log_info_pair(
|
|
1089
|
+
"✓ Vendors updated successfully",
|
|
1090
|
+
"✓ Vendors 更新成功",
|
|
1091
|
+
)
|
|
1092
|
+
else:
|
|
1093
|
+
result["success"] = True
|
|
1094
|
+
if dry_run:
|
|
1095
|
+
result["message"] = "No files would be copied"
|
|
1096
|
+
result["message_zh"] = "没有文件需要复制"
|
|
1097
|
+
else:
|
|
1098
|
+
result["message"] = "No files were copied"
|
|
1099
|
+
result["message_zh"] = "没有文件被复制"
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
def _vendor_relative_files(root: Path) -> set[Path]:
|
|
1103
|
+
"""Every file a vendor copy leg would ship, relative to *root*.
|
|
1104
|
+
|
|
1105
|
+
Mirrors :func:`_copy_vendor_files`' traversal (``os.walk``) so the manifest
|
|
1106
|
+
side and the copied side are spelled the same way -- a comparison against a
|
|
1107
|
+
differently-enumerated tree would be comparing two inventories, not the
|
|
1108
|
+
source against its copy.
|
|
1109
|
+
"""
|
|
1110
|
+
if not root.is_dir():
|
|
1111
|
+
return set()
|
|
1112
|
+
return {
|
|
1113
|
+
Path(dirpath, name).relative_to(root)
|
|
1114
|
+
for dirpath, _dirs, files in os.walk(root)
|
|
1115
|
+
for name in files
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
|
|
1119
|
+
def _missing_vendor_targets(components: list, wl_dir: Path) -> list[tuple[str, Path]]:
|
|
1120
|
+
"""Components whose copied tree is incomplete in ``.wl`` (#190, #197).
|
|
1121
|
+
|
|
1122
|
+
``.wl/vendors.version`` is a proxy predicate: it records *that* a copy leg
|
|
1123
|
+
ran, not that the tree it claims to describe is still there. That proxy was
|
|
1124
|
+
actively harmful here -- projects stamped by the old double-``vendors``
|
|
1125
|
+
spelling hold ``.wl/vendors/vendors/...`` and no ``.wl/vendors/git/``, yet
|
|
1126
|
+
answer "already up to date" forever. Verify the target of every component
|
|
1127
|
+
that ships files before trusting the stamp.
|
|
1128
|
+
|
|
1129
|
+
A bare ``target.is_dir()`` was the second proxy of the same kind (#197): an
|
|
1130
|
+
empty ``.wl/vendors/git/`` or a half-copied tree satisfied it, so a botched
|
|
1131
|
+
copy stayed certified fresh forever. The manifest here is the package
|
|
1132
|
+
source tree: every file it holds must be present under the target
|
|
1133
|
+
(``expected <= actual``, set comparison by relative path). Extra files in
|
|
1134
|
+
the target are tolerated -- the tool never prunes user additions -- and
|
|
1135
|
+
content is deliberately not hashed, so the per-command hot path stays a
|
|
1136
|
+
walk of two small trees.
|
|
1137
|
+
|
|
1138
|
+
A component whose package source is absent or holds no files is skipped:
|
|
1139
|
+
there is nothing to copy, so requiring a target would never converge.
|
|
1140
|
+
|
|
1141
|
+
Args:
|
|
1142
|
+
components: ``(component_id, component_info)`` pairs to check.
|
|
1143
|
+
wl_dir: Path to the .wl directory.
|
|
1144
|
+
|
|
1145
|
+
Returns:
|
|
1146
|
+
``(component_id, target_path)`` for every incomplete tree, in input
|
|
1147
|
+
order.
|
|
1148
|
+
"""
|
|
1149
|
+
package_path = _get_package_path()
|
|
1150
|
+
missing: list[tuple[str, Path]] = []
|
|
1151
|
+
for comp_id, comp_info in components:
|
|
1152
|
+
source = package_path / comp_info["source_path"]
|
|
1153
|
+
expected = _vendor_relative_files(source)
|
|
1154
|
+
if not expected:
|
|
1155
|
+
continue
|
|
1156
|
+
target = wl_dir / comp_info["source_path"]
|
|
1157
|
+
if not expected <= _vendor_relative_files(target):
|
|
1158
|
+
missing.append((comp_id, target))
|
|
1159
|
+
return missing
|
|
1160
|
+
|
|
1161
|
+
|
|
1162
|
+
def check_and_update_vendors(
|
|
1163
|
+
dry_run: bool = False, component_id: str | None = None, force: bool = False
|
|
1164
|
+
) -> dict:
|
|
1165
|
+
"""
|
|
1166
|
+
Check and update vendors directory.
|
|
1167
|
+
|
|
1168
|
+
Freshness is tracked by the independent ``.wl/vendors.version`` stamp
|
|
1169
|
+
written after the last successful copy (#177), not by
|
|
1170
|
+
``config.json.version``: the self-sync bumps the latter on every command,
|
|
1171
|
+
which made the copy leg unreachable when invoked through ``wl update``.
|
|
1172
|
+
|
|
1173
|
+
Args:
|
|
1174
|
+
dry_run: If True, only show what would be done without actually making changes.
|
|
1175
|
+
component_id: Optional component ID to update only a specific component.
|
|
1176
|
+
force: Copy even when the vendor stamp is already current.
|
|
1177
|
+
|
|
1178
|
+
Returns:
|
|
1179
|
+
Dictionary with update results.
|
|
1180
|
+
"""
|
|
1181
|
+
from wl_cli.commands.update.services.component_manager import (
|
|
1182
|
+
ComponentManager,
|
|
1183
|
+
)
|
|
1184
|
+
|
|
1185
|
+
result: dict[str, Any] = {
|
|
1186
|
+
"success": False,
|
|
1187
|
+
"updated": False,
|
|
1188
|
+
"message": "",
|
|
1189
|
+
"files_copied": [],
|
|
1190
|
+
"errors": [],
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
# Root-anchored (#125): the deploy root's ``.wl``, resolved once here, the
|
|
1194
|
+
# public entry (#160). Vendors need only the deploy root, so this is a
|
|
1195
|
+
# single-root consumer and uses ``current_root`` rather than building a
|
|
1196
|
+
# ``RootContext`` it would not fully use. ``_update_config_version`` (called
|
|
1197
|
+
# below) stamps that very directory, so the vendor files and the version
|
|
1198
|
+
# stamp land in one ``.wl``. Was a bare ``Path(".wl")`` against the process
|
|
1199
|
+
# CWD, so ``wl update vendors`` from a subdirectory wrote the vendor tree
|
|
1200
|
+
# into the subdirectory's ``.wl`` and stamped the wrong config.
|
|
1201
|
+
deploy_root = current_root(RootKind.DEPLOY)
|
|
1202
|
+
wl_dir = _deploy_wl_dir(deploy_root)
|
|
1203
|
+
package_vendors_path = _get_package_vendors_path()
|
|
1204
|
+
|
|
1205
|
+
if not package_vendors_path.exists():
|
|
1206
|
+
result["message"] = "Package vendors directory not found"
|
|
1207
|
+
result["message_zh"] = "包内 vendors 目录不存在"
|
|
1208
|
+
return result
|
|
1209
|
+
|
|
1210
|
+
component_manager = ComponentManager()
|
|
1211
|
+
|
|
1212
|
+
try:
|
|
1213
|
+
# Resolve the requested components first: the freshness verdict below is
|
|
1214
|
+
# about *them*, and a bogus component id must say "not found" rather than
|
|
1215
|
+
# be masked by an up-to-date stamp.
|
|
1216
|
+
components_to_update = _get_components_to_update(
|
|
1217
|
+
component_id, component_manager, dry_run, result
|
|
1218
|
+
)
|
|
1219
|
+
if not components_to_update and result["errors"]:
|
|
1220
|
+
return result
|
|
1221
|
+
|
|
1222
|
+
# Check if update is needed (independent vendor stamp, #177). ``force``
|
|
1223
|
+
# deliberately bypasses the freshness check -- the flag was declared but
|
|
1224
|
+
# never read before.
|
|
1225
|
+
#
|
|
1226
|
+
# A current stamp is only believed when the tree it claims to describe is
|
|
1227
|
+
# actually present (#190): the old double-``vendors`` spelling stamped
|
|
1228
|
+
# projects whose `.wl/vendors/git/` never existed, and "already up to
|
|
1229
|
+
# date" then made the defect permanent.
|
|
1230
|
+
missing_targets = (
|
|
1231
|
+
[] if force else _missing_vendor_targets(components_to_update, wl_dir)
|
|
1232
|
+
)
|
|
1233
|
+
stamp_stale = _should_update_vendors(wl_dir)
|
|
1234
|
+
if not force and not stamp_stale and not missing_targets:
|
|
1235
|
+
if dry_run:
|
|
1236
|
+
result["message"] = "Vendors are already up to date"
|
|
1237
|
+
result["message_zh"] = "Vendors 已经是最新的"
|
|
1238
|
+
log_info_pair("Vendors are already up to date", "Vendors 已经是最新的")
|
|
1239
|
+
else:
|
|
1240
|
+
result["success"] = True
|
|
1241
|
+
result["message"] = "Vendors are already up to date"
|
|
1242
|
+
result["message_zh"] = "Vendors 已经是最新的"
|
|
1243
|
+
return result
|
|
1244
|
+
|
|
1245
|
+
if missing_targets and not stamp_stale:
|
|
1246
|
+
log_info_pair(
|
|
1247
|
+
f"Vendors are incomplete: {len(missing_targets)} component "
|
|
1248
|
+
"tree(s) are missing even though the vendor stamp is current.",
|
|
1249
|
+
f"Vendors 不完整:vendor 版本戳已是最新,但有 "
|
|
1250
|
+
f"{len(missing_targets)} 个组件目录缺失。",
|
|
1251
|
+
)
|
|
1252
|
+
|
|
1253
|
+
if dry_run:
|
|
1254
|
+
log_info_pair(
|
|
1255
|
+
"Dry run: Checking vendors for update...",
|
|
1256
|
+
"干运行:检查 vendors 更新...",
|
|
1257
|
+
)
|
|
1258
|
+
else:
|
|
1259
|
+
log_info_pair(
|
|
1260
|
+
"Updating vendors directory...",
|
|
1261
|
+
"正在更新 vendors 目录...",
|
|
1262
|
+
)
|
|
1263
|
+
|
|
1264
|
+
# Ensure vendors directory exists
|
|
1265
|
+
if not dry_run:
|
|
1266
|
+
(wl_dir / "vendors").mkdir(parents=True, exist_ok=True)
|
|
1267
|
+
|
|
1268
|
+
# Copy vendor files
|
|
1269
|
+
all_files_copied = _update_vendor_files(
|
|
1270
|
+
components_to_update, _get_package_path(), wl_dir, dry_run
|
|
1271
|
+
)
|
|
1272
|
+
|
|
1273
|
+
result["files_copied"] = all_files_copied
|
|
1274
|
+
|
|
1275
|
+
_set_vendor_result(result, all_files_copied, dry_run)
|
|
1276
|
+
|
|
1277
|
+
if dry_run and all_files_copied:
|
|
1278
|
+
log_info_pair(
|
|
1279
|
+
f"Dry run: would copy {len(all_files_copied)} vendor file(s)",
|
|
1280
|
+
f"干运行:将复制 {len(all_files_copied)} 个 vendor 文件",
|
|
1281
|
+
)
|
|
1282
|
+
|
|
1283
|
+
if not dry_run:
|
|
1284
|
+
# Stamp only a copy leg that actually left every requested tree in
|
|
1285
|
+
# place. A stamp written over a still-incomplete tree is the same
|
|
1286
|
+
# "certified by a proxy" failure this ticket is about (#190).
|
|
1287
|
+
still_missing = _missing_vendor_targets(components_to_update, wl_dir)
|
|
1288
|
+
if still_missing:
|
|
1289
|
+
result["success"] = False
|
|
1290
|
+
result["errors"].append(
|
|
1291
|
+
"Vendors are still incomplete after copying: "
|
|
1292
|
+
+ ", ".join(str(target) for _comp_id, target in still_missing)
|
|
1293
|
+
)
|
|
1294
|
+
elif all_files_copied and not _write_vendors_version(wl_dir):
|
|
1295
|
+
# The files landed but the stamp did not: report it instead of
|
|
1296
|
+
# leaving the caller with a plain success (#177 review).
|
|
1297
|
+
result["success"] = False
|
|
1298
|
+
result["errors"].append(
|
|
1299
|
+
"Vendors were copied but the vendor version stamp could not be written"
|
|
1300
|
+
)
|
|
1301
|
+
|
|
1302
|
+
except Exception as e:
|
|
1303
|
+
result["errors"].append(str(e))
|
|
1304
|
+
result["message"] = f"Failed to update vendors: {e}"
|
|
1305
|
+
result["message_zh"] = f"更新 vendors 失败:{e}"
|
|
1306
|
+
log_info_pair(
|
|
1307
|
+
f"Warning: {result['message']}",
|
|
1308
|
+
f"警告:{result['message_zh']}",
|
|
1309
|
+
)
|
|
1310
|
+
|
|
1311
|
+
return result
|
|
1312
|
+
|
|
1313
|
+
|
|
1314
|
+
def _get_package_path() -> Path:
|
|
1315
|
+
"""
|
|
1316
|
+
Get the path to the installed package root.
|
|
1317
|
+
|
|
1318
|
+
Returns:
|
|
1319
|
+
Path object pointing to the package directory.
|
|
1320
|
+
"""
|
|
1321
|
+
import wl_cli
|
|
1322
|
+
|
|
1323
|
+
return Path(wl_cli.__file__).parent
|
|
1324
|
+
|
|
1325
|
+
|
|
1326
|
+
def _get_package_vendors_path() -> Path:
|
|
1327
|
+
"""
|
|
1328
|
+
Get the path to the vendors directory in the package.
|
|
1329
|
+
|
|
1330
|
+
Returns:
|
|
1331
|
+
Path object pointing to the package's vendors directory.
|
|
1332
|
+
"""
|
|
1333
|
+
return _get_package_path() / "vendors"
|
|
1334
|
+
|
|
1335
|
+
|
|
1336
|
+
def _copy_vendor_files(
|
|
1337
|
+
source_path: Path, target_path: Path, dry_run: bool = False
|
|
1338
|
+
) -> list[str]:
|
|
1339
|
+
"""
|
|
1340
|
+
Copy vendor files from source to target.
|
|
1341
|
+
|
|
1342
|
+
Args:
|
|
1343
|
+
source_path: Source directory path.
|
|
1344
|
+
target_path: Target directory path.
|
|
1345
|
+
dry_run: If True, only show what would be done.
|
|
1346
|
+
|
|
1347
|
+
Returns:
|
|
1348
|
+
List of copied (or, in a dry run, would-be copied) file paths.
|
|
1349
|
+
"""
|
|
1350
|
+
copied_files: list[str] = []
|
|
1351
|
+
|
|
1352
|
+
if not source_path.exists():
|
|
1353
|
+
return copied_files
|
|
1354
|
+
|
|
1355
|
+
for root, _dirs, files in os.walk(source_path):
|
|
1356
|
+
for file in files:
|
|
1357
|
+
source_file = Path(root) / file
|
|
1358
|
+
relative_path = source_file.relative_to(source_path)
|
|
1359
|
+
target_file = target_path / relative_path
|
|
1360
|
+
|
|
1361
|
+
if dry_run:
|
|
1362
|
+
log_info_pair(
|
|
1363
|
+
f"Would copy: {source_file} -> {target_file}",
|
|
1364
|
+
f"将复制:{source_file} -> {target_file}",
|
|
1365
|
+
)
|
|
1366
|
+
# Count the file in the dry run too: otherwise the caller's
|
|
1367
|
+
# "nothing to copy" verdict contradicts the per-file lines above
|
|
1368
|
+
# (#177 F2).
|
|
1369
|
+
copied_files.append(str(target_file))
|
|
1370
|
+
else:
|
|
1371
|
+
target_file.parent.mkdir(parents=True, exist_ok=True)
|
|
1372
|
+
shutil.copy2(source_file, target_file)
|
|
1373
|
+
copied_files.append(str(target_file))
|
|
1374
|
+
|
|
1375
|
+
return copied_files
|
|
1376
|
+
|
|
1377
|
+
|
|
1378
|
+
#: Independent vendor freshness stamp: the ``__version__`` of the last
|
|
1379
|
+
#: successful vendor copy, as plain text (#177).
|
|
1380
|
+
VENDORS_VERSION_FILE = "vendors.version"
|
|
1381
|
+
|
|
1382
|
+
|
|
1383
|
+
def _vendors_version_path(wl_dir: Path) -> Path:
|
|
1384
|
+
"""Path of the independent vendor freshness stamp (#177)."""
|
|
1385
|
+
return wl_dir / VENDORS_VERSION_FILE
|
|
1386
|
+
|
|
1387
|
+
|
|
1388
|
+
def _write_vendors_version(wl_dir: Path) -> bool:
|
|
1389
|
+
"""Record the current wl version after a successful vendor copy.
|
|
1390
|
+
|
|
1391
|
+
Returns:
|
|
1392
|
+
True when the stamp was written; False when the write failed (logged as
|
|
1393
|
+
a warning -- the next run then simply copies again).
|
|
1394
|
+
"""
|
|
1395
|
+
from wl_cli import __version__
|
|
1396
|
+
|
|
1397
|
+
try:
|
|
1398
|
+
_vendors_version_path(wl_dir).write_text(f"{__version__}\n", encoding="utf-8")
|
|
1399
|
+
return True
|
|
1400
|
+
except OSError as e:
|
|
1401
|
+
log_warning_pair(
|
|
1402
|
+
f"Warning: failed to write the vendor version stamp: {e}",
|
|
1403
|
+
f"警告:写入 vendor 版本戳失败:{e}",
|
|
1404
|
+
)
|
|
1405
|
+
return False
|
|
1406
|
+
|
|
1407
|
+
|
|
1408
|
+
def _should_update_vendors(wl_dir: Path | None = None) -> bool:
|
|
1409
|
+
"""
|
|
1410
|
+
Check if vendors directory needs to be updated.
|
|
1411
|
+
|
|
1412
|
+
Freshness is tracked by the independent ``.wl/vendors.version`` stamp, not
|
|
1413
|
+
by ``config.json``'s ``version`` field: the self-sync advances the latter
|
|
1414
|
+
whenever it runs, so reading it here reported "up to date" without ever
|
|
1415
|
+
inspecting the copied vendor files (#177). A missing, unreadable, empty, or
|
|
1416
|
+
older stamp means the vendors are stale.
|
|
1417
|
+
|
|
1418
|
+
Args:
|
|
1419
|
+
wl_dir: Path to the .wl directory (defaults to the deploy root's
|
|
1420
|
+
``.wl``, via :func:`~wl_cli.utils.root_fallback.current_root`).
|
|
1421
|
+
|
|
1422
|
+
Returns:
|
|
1423
|
+
True if update is needed, False otherwise.
|
|
1424
|
+
"""
|
|
1425
|
+
from wl_cli import __version__
|
|
1426
|
+
|
|
1427
|
+
# Root-anchored default (#125): the deploy root's ``.wl`` -- see the note in
|
|
1428
|
+
# `check_and_update_vendors`. A deep caller may still omit the argument, so
|
|
1429
|
+
# the default resolves the one root it needs through the single policy
|
|
1430
|
+
# (#160) rather than through a deleted local helper.
|
|
1431
|
+
stamp_file = _vendors_version_path(wl_dir or current_root(RootKind.DEPLOY).wl_dir())
|
|
1432
|
+
try:
|
|
1433
|
+
stamp = stamp_file.read_text(encoding="utf-8").strip()
|
|
1434
|
+
except OSError:
|
|
1435
|
+
return True
|
|
1436
|
+
|
|
1437
|
+
if not stamp:
|
|
1438
|
+
return True
|
|
1439
|
+
|
|
1440
|
+
return compare_versions(stamp, __version__) < 0
|