py-wlcli 0.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (320) hide show
  1. py_wlcli-0.8.0.dist-info/METADATA +139 -0
  2. py_wlcli-0.8.0.dist-info/RECORD +320 -0
  3. py_wlcli-0.8.0.dist-info/WHEEL +4 -0
  4. py_wlcli-0.8.0.dist-info/entry_points.txt +2 -0
  5. py_wlcli-0.8.0.dist-info/licenses/LICENSE +21 -0
  6. wl_cli/__init__.py +27 -0
  7. wl_cli/__main__.py +130 -0
  8. wl_cli/capabilities.py +79 -0
  9. wl_cli/cc/__init__.py +5 -0
  10. wl_cli/cc/checks.py +1019 -0
  11. wl_cli/cc/cli.py +370 -0
  12. wl_cli/cc/mutate.py +1350 -0
  13. wl_cli/cli_click.py +156 -0
  14. wl_cli/commands/__init__.py +84 -0
  15. wl_cli/commands/base.py +80 -0
  16. wl_cli/commands/buildcommands/__init__.py +1 -0
  17. wl_cli/commands/buildcommands/build.py +71 -0
  18. wl_cli/commands/buildcommands/build_utils.py +47 -0
  19. wl_cli/commands/buildcommands/builders/__init__.py +18 -0
  20. wl_cli/commands/buildcommands/builders/cleanup.py +35 -0
  21. wl_cli/commands/buildcommands/builders/coordinator.py +122 -0
  22. wl_cli/commands/buildcommands/builders/environment.py +81 -0
  23. wl_cli/commands/buildcommands/builders/rust_builder.py +39 -0
  24. wl_cli/commands/buildcommands/builders/stubs.py +58 -0
  25. wl_cli/commands/buildcommands/dist.py +326 -0
  26. wl_cli/commands/buildcommands/test.py +89 -0
  27. wl_cli/commands/clean/__init__.py +1 -0
  28. wl_cli/commands/clean/build.py +151 -0
  29. wl_cli/commands/clean/clean.py +294 -0
  30. wl_cli/commands/clean/clean_utils.py +77 -0
  31. wl_cli/commands/clean/core.py +236 -0
  32. wl_cli/commands/clean/dead_code/__init__.py +0 -0
  33. wl_cli/commands/clean/dead_code/dead_code_command.py +70 -0
  34. wl_cli/commands/clean/dead_code/detector.py +161 -0
  35. wl_cli/commands/clean/dead_code/fixer.py +176 -0
  36. wl_cli/commands/clean/dead_code/reporter.py +295 -0
  37. wl_cli/commands/clean/dead_code/utils.py +0 -0
  38. wl_cli/commands/clean/lfs.py +215 -0
  39. wl_cli/commands/clean/python.py +81 -0
  40. wl_cli/commands/clean/rust.py +91 -0
  41. wl_cli/commands/clean/utils/__init__.py +34 -0
  42. wl_cli/commands/clean/venv.py +91 -0
  43. wl_cli/commands/cleanup.py +73 -0
  44. wl_cli/commands/click_base.py +79 -0
  45. wl_cli/commands/config/__init__.py +1 -0
  46. wl_cli/commands/config/config.py +291 -0
  47. wl_cli/commands/debug/__init__.py +6 -0
  48. wl_cli/commands/debug/debug.py +309 -0
  49. wl_cli/commands/debug/debug_cache.py +86 -0
  50. wl_cli/commands/debug/debug_subagent.py +156 -0
  51. wl_cli/commands/debug/output_formatter.py +165 -0
  52. wl_cli/commands/debug/source_hasher.py +92 -0
  53. wl_cli/commands/format/__init__.py +13 -0
  54. wl_cli/commands/format/format_cache_adapter.py +125 -0
  55. wl_cli/commands/format/format_command.py +197 -0
  56. wl_cli/commands/format/format_coordinator.py +191 -0
  57. wl_cli/commands/format/format_executor.py +470 -0
  58. wl_cli/commands/format/format_outcome.py +44 -0
  59. wl_cli/commands/format/format_path_handler.py +1016 -0
  60. wl_cli/commands/format/format_progress.py +62 -0
  61. wl_cli/commands/format/format_reporter.py +31 -0
  62. wl_cli/commands/format/python_formatter.py +506 -0
  63. wl_cli/commands/format/rust_formatter.py +106 -0
  64. wl_cli/commands/gate/__init__.py +1 -0
  65. wl_cli/commands/gate/gate_command.py +862 -0
  66. wl_cli/commands/gate/preflight.py +445 -0
  67. wl_cli/commands/git/__init__.py +1 -0
  68. wl_cli/commands/git/config.py +169 -0
  69. wl_cli/commands/git/git_facts.py +60 -0
  70. wl_cli/commands/git/git_flow.py +86 -0
  71. wl_cli/commands/git/hook.py +112 -0
  72. wl_cli/commands/git/policy.py +216 -0
  73. wl_cli/commands/git/push_update.py +82 -0
  74. wl_cli/commands/initenv/__init__.py +1 -0
  75. wl_cli/commands/initenv/common/__init__.py +23 -0
  76. wl_cli/commands/initenv/common/check_git_bash.py +78 -0
  77. wl_cli/commands/initenv/common/error_handler.py +68 -0
  78. wl_cli/commands/initenv/common/exceptions.py +52 -0
  79. wl_cli/commands/initenv/components/__init__.py +4 -0
  80. wl_cli/commands/initenv/components/cargo_sync.py +240 -0
  81. wl_cli/commands/initenv/components/config_manager.py +106 -0
  82. wl_cli/commands/initenv/components/directory_utils.py +71 -0
  83. wl_cli/commands/initenv/components/git/__init__.py +9 -0
  84. wl_cli/commands/initenv/components/git/branch_creator.py +143 -0
  85. wl_cli/commands/initenv/components/git/git_flow_interface.py +24 -0
  86. wl_cli/commands/initenv/components/git/git_flow_manager.py +159 -0
  87. wl_cli/commands/initenv/components/git/git_lfs_manager.py +272 -0
  88. wl_cli/commands/initenv/components/git/git_utility.py +254 -0
  89. wl_cli/commands/initenv/components/git/gitignore_manager.py +53 -0
  90. wl_cli/commands/initenv/components/git_initializer.py +212 -0
  91. wl_cli/commands/initenv/components/i18n_manager.py +104 -0
  92. wl_cli/commands/initenv/components/log_manager.py +241 -0
  93. wl_cli/commands/initenv/components/project_structure/__init__.py +23 -0
  94. wl_cli/commands/initenv/components/project_structure/config_generator.py +360 -0
  95. wl_cli/commands/initenv/components/project_structure/directory_creator.py +184 -0
  96. wl_cli/commands/initenv/components/project_structure/hook_source.py +110 -0
  97. wl_cli/commands/initenv/components/project_structure/hooks_manager.py +995 -0
  98. wl_cli/commands/initenv/components/project_structure/readme_handler.py +56 -0
  99. wl_cli/commands/initenv/components/project_structure/rust_files_handler.py +30 -0
  100. wl_cli/commands/initenv/components/project_structure/setup_handler.py +103 -0
  101. wl_cli/commands/initenv/components/project_structure/sop_generator.py +73 -0
  102. wl_cli/commands/initenv/components/project_structure/template_copier.py +104 -0
  103. wl_cli/commands/initenv/components/project_structure/template_manager.py +195 -0
  104. wl_cli/commands/initenv/components/pyproject_generator.py +194 -0
  105. wl_cli/commands/initenv/components/rust_initializer.py +145 -0
  106. wl_cli/commands/initenv/components/symlink_cleaner.py +99 -0
  107. wl_cli/commands/initenv/components/symlink_manager.py +52 -0
  108. wl_cli/commands/initenv/components/venv_manager.py +118 -0
  109. wl_cli/commands/initenv/initenv.py +169 -0
  110. wl_cli/commands/initenv/services/__init__.py +11 -0
  111. wl_cli/commands/initenv/services/environment_initializer.py +288 -0
  112. wl_cli/commands/initenv/services/git_lfs_initializer.py +26 -0
  113. wl_cli/commands/initenv/services/project_structure_initializer.py +52 -0
  114. wl_cli/commands/lfs/README.md +221 -0
  115. wl_cli/commands/lfs/__init__.py +5 -0
  116. wl_cli/commands/lfs/cli.py +106 -0
  117. wl_cli/commands/lfs/config.py +164 -0
  118. wl_cli/commands/lfs/hook.py +61 -0
  119. wl_cli/commands/lfs/initializer.py +531 -0
  120. wl_cli/commands/lfs/manager.py +95 -0
  121. wl_cli/commands/lfs/policy.py +165 -0
  122. wl_cli/commands/lfs/watcher.py +294 -0
  123. wl_cli/commands/lint/__init__.py +1 -0
  124. wl_cli/commands/lint/lint_command.py +297 -0
  125. wl_cli/commands/lint/lint_executor.py +409 -0
  126. wl_cli/commands/lint/lint_reporter.py +579 -0
  127. wl_cli/commands/lint/lint_run.py +258 -0
  128. wl_cli/commands/lint/mypy_config.py +90 -0
  129. wl_cli/commands/registry.py +103 -0
  130. wl_cli/commands/self/__init__.py +7 -0
  131. wl_cli/commands/self/common/__init__.py +8 -0
  132. wl_cli/commands/self/common/capabilities.py +30 -0
  133. wl_cli/commands/self/common/config.py +27 -0
  134. wl_cli/commands/self/common/version_cache.py +85 -0
  135. wl_cli/commands/self/handlers/__init__.py +19 -0
  136. wl_cli/commands/self/handlers/base_handler.py +28 -0
  137. wl_cli/commands/self/handlers/install_handler.py +85 -0
  138. wl_cli/commands/self/handlers/status_handler.py +66 -0
  139. wl_cli/commands/self/handlers/uninstall_handler.py +58 -0
  140. wl_cli/commands/self/handlers/update_handler.py +102 -0
  141. wl_cli/commands/self/handlers/version_handler.py +16 -0
  142. wl_cli/commands/self/self_command.py +237 -0
  143. wl_cli/commands/self/services/__init__.py +15 -0
  144. wl_cli/commands/self/services/installation_manager.py +187 -0
  145. wl_cli/commands/self/services/local_installer.py +165 -0
  146. wl_cli/commands/self/services/pypi_updater.py +278 -0
  147. wl_cli/commands/self/services/windows_update.py +174 -0
  148. wl_cli/commands/testcommands/__init__.py +8 -0
  149. wl_cli/commands/testcommands/base_dependency_analyzer.py +198 -0
  150. wl_cli/commands/testcommands/batch_processor.py +278 -0
  151. wl_cli/commands/testcommands/cache_manager.py +1156 -0
  152. wl_cli/commands/testcommands/coverage_analyzer.py +955 -0
  153. wl_cli/commands/testcommands/coverage_cache_manager.py +183 -0
  154. wl_cli/commands/testcommands/progress_manager.py +79 -0
  155. wl_cli/commands/testcommands/python_env_detector.py +207 -0
  156. wl_cli/commands/testcommands/report_generator.py +101 -0
  157. wl_cli/commands/testcommands/single_test_executor.py +250 -0
  158. wl_cli/commands/testcommands/speed_checker.py +118 -0
  159. wl_cli/commands/testcommands/test_command_builder.py +165 -0
  160. wl_cli/commands/testcommands/test_commands.py +786 -0
  161. wl_cli/commands/testcommands/test_reporter.py +181 -0
  162. wl_cli/commands/testcommands/test_result_handler.py +96 -0
  163. wl_cli/commands/testcommands/test_runner.py +1343 -0
  164. wl_cli/commands/testcommands/test_utils.py +197 -0
  165. wl_cli/commands/testcommands/utils/__init__.py +17 -0
  166. wl_cli/commands/testcommands/utils/coverage_parser.py +98 -0
  167. wl_cli/commands/testcommands/utils/env_info.py +36 -0
  168. wl_cli/commands/testcommands/utils/junit_parser.py +75 -0
  169. wl_cli/commands/testcommands/utils/markdown_utils.py +20 -0
  170. wl_cli/commands/testcommands/utils/project_root.py +61 -0
  171. wl_cli/commands/update/__init__.py +9 -0
  172. wl_cli/commands/update/services/__init__.py +21 -0
  173. wl_cli/commands/update/services/backup_manager.py +207 -0
  174. wl_cli/commands/update/services/component_manager.py +83 -0
  175. wl_cli/commands/update/services/conflict_resolver.py +189 -0
  176. wl_cli/commands/update/services/vendors_updater.py +85 -0
  177. wl_cli/commands/update/services/version_tracker.py +127 -0
  178. wl_cli/commands/update/update_command.py +270 -0
  179. wl_cli/commands/update/utils/__init__.py +3 -0
  180. wl_cli/commands/version/__init__.py +9 -0
  181. wl_cli/commands/version/version_command.py +155 -0
  182. wl_cli/commands/workflow.py +477 -0
  183. wl_cli/env/__init__.py +5 -0
  184. wl_cli/env/cli.py +167 -0
  185. wl_cli/env_status.py +157 -0
  186. wl_cli/envelope.py +91 -0
  187. wl_cli/exceptions.py +85 -0
  188. wl_cli/lib/__init__.py +3 -0
  189. wl_cli/lib/rust_utils.py +35 -0
  190. wl_cli/mcp/__init__.py +6 -0
  191. wl_cli/mcp/capabilities.py +19 -0
  192. wl_cli/mcp/cli.py +22 -0
  193. wl_cli/mcp/gitlab.py +553 -0
  194. wl_cli/mcp/k8s.py +344 -0
  195. wl_cli/mcp/protocol.py +159 -0
  196. wl_cli/platform.py +139 -0
  197. wl_cli/quality/__init__.py +18 -0
  198. wl_cli/utils/__init__.py +7 -0
  199. wl_cli/utils/cache.py +491 -0
  200. wl_cli/utils/cache_ledger.py +355 -0
  201. wl_cli/utils/cache_store.py +546 -0
  202. wl_cli/utils/config/__init__.py +27 -0
  203. wl_cli/utils/config/config_loader.py +163 -0
  204. wl_cli/utils/config/core.py +862 -0
  205. wl_cli/utils/config/defaults.py +142 -0
  206. wl_cli/utils/config/env_exporter.py +86 -0
  207. wl_cli/utils/config/env_parser.py +108 -0
  208. wl_cli/utils/config/validation.py +280 -0
  209. wl_cli/utils/error_handler.py +165 -0
  210. wl_cli/utils/file_copier.py +56 -0
  211. wl_cli/utils/file_operations.py +506 -0
  212. wl_cli/utils/language_utils.py +122 -0
  213. wl_cli/utils/lazy_import.py +240 -0
  214. wl_cli/utils/log_levels.py +35 -0
  215. wl_cli/utils/log_rotators.py +74 -0
  216. wl_cli/utils/logging.py +458 -0
  217. wl_cli/utils/output_context.py +142 -0
  218. wl_cli/utils/performance/__init__.py +1 -0
  219. wl_cli/utils/performance/decorators.py +111 -0
  220. wl_cli/utils/performance/exporter.py +38 -0
  221. wl_cli/utils/performance/models.py +16 -0
  222. wl_cli/utils/performance/monitor.py +161 -0
  223. wl_cli/utils/performance_monitor.py +32 -0
  224. wl_cli/utils/platform_adapter.py +131 -0
  225. wl_cli/utils/pre_commit.py +266 -0
  226. wl_cli/utils/pre_commit_config.py +72 -0
  227. wl_cli/utils/root.py +521 -0
  228. wl_cli/utils/root_fallback.py +256 -0
  229. wl_cli/utils/run_evidence.py +236 -0
  230. wl_cli/utils/structured_logger.py +18 -0
  231. wl_cli/utils/structured_logging/__init__.py +19 -0
  232. wl_cli/utils/structured_logging/config.py +337 -0
  233. wl_cli/utils/structured_logging/core.py +177 -0
  234. wl_cli/utils/structured_logging/env.py +56 -0
  235. wl_cli/utils/structured_logging/filtering.py +75 -0
  236. wl_cli/utils/structured_logging/filters.py +55 -0
  237. wl_cli/utils/structured_logging/formatters.py +251 -0
  238. wl_cli/utils/structured_logging/handlers.py +308 -0
  239. wl_cli/utils/structured_logging/logger.py +354 -0
  240. wl_cli/utils/subprocess.py +71 -0
  241. wl_cli/utils/subprocess_utils.py +260 -0
  242. wl_cli/utils/test_utils/__init__.py +5 -0
  243. wl_cli/utils/test_utils/test_filter.py +36 -0
  244. wl_cli/utils/uv_run.py +79 -0
  245. wl_cli/utils/uv_tool.py +117 -0
  246. wl_cli/utils/uv_tree.py +71 -0
  247. wl_cli/utils/uv_workspace.py +174 -0
  248. wl_cli/utils/version/__init__.py +15 -0
  249. wl_cli/utils/version/pypi_version_checker.py +95 -0
  250. wl_cli/utils/version/version_comparator.py +133 -0
  251. wl_cli/utils/version/version_detectors.py +312 -0
  252. wl_cli/utils/version/version_service.py +131 -0
  253. wl_cli/utils/version/version_updater.py +198 -0
  254. wl_cli/utils/wl_dir_updater.py +1440 -0
  255. wl_cli/utils/workspace_detector/__init__.py +28 -0
  256. wl_cli/utils/workspace_detector/core.py +161 -0
  257. wl_cli/utils/workspace_detector/detection_strategies.py +107 -0
  258. wl_cli/utils/workspace_detector/exceptions.py +5 -0
  259. wl_cli/utils/workspace_detector/types.py +17 -0
  260. wl_cli/utils/workspace_detector/venv_resolver.py +84 -0
  261. wl_cli/vendors/config/.codespellrc +2 -0
  262. wl_cli/vendors/config/dependencies.toml +10 -0
  263. wl_cli/vendors/config/mypy.ini +62 -0
  264. wl_cli/vendors/config/pyproject_defaults.toml +28 -0
  265. wl_cli/vendors/config/template_config.toml +24 -0
  266. wl_cli/vendors/config/tool_configs.toml +76 -0
  267. wl_cli/vendors/git/.gitignore +122 -0
  268. wl_cli/vendors/git/.pre-commit-config.yaml +37 -0
  269. wl_cli/vendors/hooks/applypatch-msg.sample +15 -0
  270. wl_cli/vendors/hooks/commit-msg.sample +24 -0
  271. wl_cli/vendors/hooks/fsmonitor-watchman.sample +174 -0
  272. wl_cli/vendors/hooks/post-update.sample +8 -0
  273. wl_cli/vendors/hooks/pre-applypatch.sample +14 -0
  274. wl_cli/vendors/hooks/pre-commit +45 -0
  275. wl_cli/vendors/hooks/pre-commit.legacy +31 -0
  276. wl_cli/vendors/hooks/pre-commit.sample +49 -0
  277. wl_cli/vendors/hooks/pre-merge-commit.sample +13 -0
  278. wl_cli/vendors/hooks/pre-push.sample +60 -0
  279. wl_cli/vendors/hooks/pre-rebase.sample +169 -0
  280. wl_cli/vendors/hooks/pre-receive.sample +24 -0
  281. wl_cli/vendors/hooks/prepare-commit-msg.sample +42 -0
  282. wl_cli/vendors/hooks/push-to-checkout.sample +78 -0
  283. wl_cli/vendors/hooks/sendemail-validate.sample +77 -0
  284. wl_cli/vendors/hooks/unix/applypatch-msg.sample +15 -0
  285. wl_cli/vendors/hooks/unix/check_lockfile.py +58 -0
  286. wl_cli/vendors/hooks/unix/check_lockfile.sh +36 -0
  287. wl_cli/vendors/hooks/unix/commit-msg.sample +24 -0
  288. wl_cli/vendors/hooks/unix/fsmonitor-watchman.sample +174 -0
  289. wl_cli/vendors/hooks/unix/post-update.sample +8 -0
  290. wl_cli/vendors/hooks/unix/pre-applypatch.sample +14 -0
  291. wl_cli/vendors/hooks/unix/pre-commit +190 -0
  292. wl_cli/vendors/hooks/unix/pre-commit.legacy +31 -0
  293. wl_cli/vendors/hooks/unix/pre-commit.py +81 -0
  294. wl_cli/vendors/hooks/unix/pre-commit.sample +49 -0
  295. wl_cli/vendors/hooks/unix/pre-merge-commit.sample +13 -0
  296. wl_cli/vendors/hooks/unix/pre-push +417 -0
  297. wl_cli/vendors/hooks/unix/pre-push.sample +53 -0
  298. wl_cli/vendors/hooks/unix/pre-rebase.sample +169 -0
  299. wl_cli/vendors/hooks/unix/pre-receive.sample +24 -0
  300. wl_cli/vendors/hooks/unix/prepare-commit-msg.sample +42 -0
  301. wl_cli/vendors/hooks/update.sample +128 -0
  302. wl_cli/vendors/hooks/win/work.bat +63 -0
  303. wl_cli/vendors/project/.gitignore +90 -0
  304. wl_cli/vendors/project/.python-version +1 -0
  305. wl_cli/vendors/project/LICENSE +21 -0
  306. wl_cli/vendors/project/README.md +0 -0
  307. wl_cli/vendors/project/pyproject.toml +27 -0
  308. wl_cli/vendors/project/pyproject_no_workspace.toml +16 -0
  309. wl_cli/vendors/project/pyproject_workspace.toml +14 -0
  310. wl_cli/vendors/readme/README.md +120 -0
  311. wl_cli/vendors/rust/Cargo.toml +14 -0
  312. wl_cli/vendors/rust/lib/__init__.py +3 -0
  313. wl_cli/vendors/rust/lib/rust_utils.py +31 -0
  314. wl_cli/vendors/rust/src/lib.rs +13 -0
  315. wl_cli/vendors/rust/src/main.rs +3 -0
  316. wl_cli/vendors/rust/tests/test_rust_fallback.py +40 -0
  317. wl_cli/vendors/sop_doc/git-dev-sop.md +220 -0
  318. wl_cli/vendors/sop_doc/industrial-standard-sop.md +145 -0
  319. wl_cli/vendors/sop_doc/python-sop.md +342 -0
  320. wl_cli/vendors/sop_doc/rust-readme.md +219 -0
@@ -0,0 +1,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