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,360 @@
1
+ """WL configuration generator."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any, cast
5
+
6
+ import tomllib
7
+
8
+ from .....utils.file_copier import FileCopier
9
+ from .....utils.logging import log_debug_pair, log_info, log_info_pair
10
+ from .....utils.root import Root, RootKind
11
+ from .....utils.root_fallback import current_root
12
+ from ..config_manager import ConfigManager
13
+
14
+ #: A bare relative :class:`Root`: the ``.wl`` layout has one definition, in the
15
+ #: root module, so its *relative* spelling is derived rather than re-typed here
16
+ #: (#118). The kind is irrelevant -- the derived-path methods only join fixed
17
+ #: names onto ``path`` -- so PROJECT is the neutral placeholder, not a claim.
18
+ _LAYOUT = Root(Path("."), RootKind.PROJECT)
19
+
20
+ #: The manifest filename under ``.wl``: the one spelling shared by the absolute
21
+ #: :func:`template_manifest_path` and the relative
22
+ #: :attr:`ConfigGenerator.TEMPLATE_MANIFEST`, so the two cannot name different
23
+ #: files.
24
+ _MANIFEST_NAME = ".templates.json"
25
+
26
+
27
+ def template_manifest_path(deploy_root: Root | None = None) -> Path:
28
+ """Path of the config-template deployment manifest under the deploy root.
29
+
30
+ ``<hooks deploy root>/.wl/.templates.json`` -- anchored exactly like the
31
+ templates it tracks, so the manifest and the files it describes always share
32
+ one ``.wl``.
33
+
34
+ ``deploy_root`` may be supplied as a value by the self-sync chain, which no
35
+ longer chdirs to it; when omitted this single-root entry resolves exactly
36
+ the one root it needs through
37
+ :func:`~wl_cli.utils.root_fallback.current_root` (#160).
38
+ """
39
+ root = deploy_root if deploy_root is not None else current_root(RootKind.DEPLOY)
40
+ return root.wl_dir() / _MANIFEST_NAME
41
+
42
+
43
+ class ConfigGenerator:
44
+ """Generate configuration files in .wl directory using config-driven approach."""
45
+
46
+ #: Manifest tracking which config template source content was last deployed,
47
+ #: relative to the ``.wl`` at the hooks deploy root (see
48
+ #: :func:`template_manifest_path`). Kept a relative class constant so that
49
+ #: constructing a generator never shells out to git; it is anchored at use
50
+ #: time by :meth:`_manifest_path`. Tests override it on the instance.
51
+ #:
52
+ #: The ``.wl`` half is derived from the root module (#118) rather than
53
+ #: re-spelled here, so the control-directory name has a single definition;
54
+ #: the filename half is shared with :func:`template_manifest_path` through
55
+ #: :data:`_MANIFEST_NAME`, so the two can never name different files.
56
+ TEMPLATE_MANIFEST = _LAYOUT.wl_dir() / _MANIFEST_NAME
57
+
58
+ def __init__(self):
59
+ self.config_manager = ConfigManager()
60
+ self.file_copier = FileCopier()
61
+ self.config = {}
62
+ self._load_config()
63
+
64
+ def _load_config(self):
65
+ config_path = self.config_manager.get_vendor_config_path(
66
+ "config/template_config.toml"
67
+ )
68
+ if config_path.exists():
69
+ try:
70
+ with open(config_path, "rb") as f:
71
+ self.config = tomllib.load(f)
72
+ except Exception as e:
73
+ log_info(f"Warning: Failed to load template config: {e}")
74
+ self.config = {}
75
+ else:
76
+ self.config = {}
77
+
78
+ def generate_configs(self, deploy_root: Root | None = None) -> bool:
79
+ """Generate configuration files in .wl directory.
80
+
81
+ Pure template migration: this never re-syncs git hooks. Callers that
82
+ need hooks synced invoke :func:`hooks_manager.sync_hooks` explicitly at
83
+ their orchestration site.
84
+
85
+ ``deploy_root`` is the root the ``.wl`` files are written under, threaded
86
+ in as a value by the self-sync chain (which no longer chdirs to it); when
87
+ omitted this single-root entry resolves exactly the one root it needs
88
+ through :func:`~wl_cli.utils.root_fallback.current_root` (#160).
89
+
90
+ Returns:
91
+ True when ``.wl/config.json`` exists once this returns *and* the
92
+ template manifest was saved. The verdict is read off the disk, not
93
+ off the creation attempt: under pytest the incidental-save guard
94
+ used to answer "written" for a file it never created, so the sync
95
+ deployed templates onto a ``.wl`` with no config and then failed the
96
+ version stamp (#202). The manifest save reports its own outcome
97
+ (#201), so an unwritable manifest cannot leave the sync claiming a
98
+ refresh it did not persist. Callers take False as a failed config
99
+ sub-step instead of a silently half-built ``.wl``.
100
+ """
101
+ if deploy_root is None:
102
+ deploy_root = current_root(RootKind.DEPLOY)
103
+ wl_dir = deploy_root.wl_dir()
104
+ log_dir = wl_dir / "log"
105
+ config_file = wl_dir / "config.json"
106
+
107
+ log_dir.mkdir(parents=True, exist_ok=True)
108
+ log_debug_pair("✓ Created .wl/log directory", "✓ 创建 .wl/log 目录")
109
+
110
+ if not config_file.exists():
111
+ try:
112
+ from .....utils.config import ConfigManager as ProjectConfigManager
113
+ from .....utils.config import get_config_manager
114
+
115
+ config_manager = get_config_manager()
116
+ if _same_file(Path(config_manager.config_file), config_file):
117
+ materialised = config_manager.ensure_project_config()
118
+ else:
119
+ # The process-wide manager writes wherever its own resolution
120
+ # points -- ``WL_CONFIG_PATH``, or a CWD outside the checkout.
121
+ # Asking it to "ensure the project config" then materialised
122
+ # the defaults in that unrelated file while this ``.wl`` kept
123
+ # no ``config.json`` at all: the self-sync read "missing
124
+ # config" on every command and never converged (#190). Write
125
+ # the defaults at the ``.wl`` this sync actually owns.
126
+ log_info_pair(
127
+ f"Creating the default config at {config_file} (the active "
128
+ f"config file is {config_manager.config_file}).",
129
+ f"正在 {config_file} 创建默认配置(当前生效的配置文件是 "
130
+ f"{config_manager.config_file})。",
131
+ )
132
+ materialised = ProjectConfigManager(
133
+ config_file=str(config_file)
134
+ ).ensure_project_config()
135
+ # ``ensure_project_config`` already answers by existence; the
136
+ # re-check keeps the contract honest even for a mocked or
137
+ # subclassed manager that only claims success (#202).
138
+ if not materialised or not config_file.exists():
139
+ raise RuntimeError(f"no config.json was created at {config_file}")
140
+ log_debug_pair(
141
+ f"✓ Created default config file at {config_file}",
142
+ f"✓ 在 {config_file} 创建默认配置文件",
143
+ )
144
+ except Exception as e:
145
+ log_info_pair(
146
+ f"Warning: Failed to create default config file: {e}",
147
+ f"警告: 创建默认配置文件失败: {e}",
148
+ )
149
+
150
+ templates_saved = self._copy_config_templates(deploy_root.path)
151
+
152
+ # Report from the bytes on disk, never from "a creation was attempted":
153
+ # a half-built ``.wl`` (templates yes, config no) is what let the first
154
+ # worktree run fail its version stamp under the test-environment save
155
+ # guard (#202). The manifest's own save is part of the verdict too: a
156
+ # stale manifest forces the next command to rebuild, so claiming success
157
+ # for a save that failed is the silent-retry loop of #201.
158
+ return config_file.exists() and templates_saved
159
+
160
+ def _copy_config_templates(self, deploy_root: Path | None = None) -> bool:
161
+ """Copy config templates, refreshing tool-deployed ones when the source
162
+ template changed, while preserving files the user has modified.
163
+
164
+ Deployment is tracked in ``.wl/.templates.json``. Each entry is either a
165
+ legacy scalar (source sha256 last deployed, treated as tool-owned) or a
166
+ v2 record ``{"src": <sha>, "user": bool}``:
167
+
168
+ - ``user: true`` -> the destination is (or became) user-modified: it is
169
+ never refreshed (bug #44). The tool stops auto-propagating to it.
170
+ - ``user: false`` -> tool-owned: refreshed when the vendored source
171
+ changes AND the destination is still byte-identical to the last
172
+ deployed source; once the destination diverges (the user touched it)
173
+ it is re-pinned as ``user: true`` and preserved.
174
+
175
+ A pre-existing ``.wl`` without a manifest entry is migrated once:
176
+ destinations already matching the source are recorded as tool-owned;
177
+ destinations that differ may be user-modified, so they are recorded as
178
+ ``user: true`` and never clobbered.
179
+
180
+ Returns:
181
+ Whether the manifest was persisted; the template copies themselves
182
+ stay best-effort (their failures are already reported per file).
183
+ """
184
+ # Direct callers (tests) may omit the path; the single root this helper
185
+ # needs then comes from the one fallback policy. The real chain always
186
+ # passes ``generate_configs``' resolved root down (#160).
187
+ if deploy_root is None:
188
+ deploy_root = current_root(RootKind.DEPLOY).path
189
+
190
+ config_templates = self.config.get("config_templates", {})
191
+ manifest = self._load_template_manifest(deploy_root)
192
+
193
+ for name, template in config_templates.items():
194
+ src = self.config_manager.get_vendor_config_path(template["src"])
195
+ dest = deploy_root / template["dest"]
196
+
197
+ if not src.exists():
198
+ continue
199
+
200
+ src_hash = _sha256(src)
201
+ if not dest.exists():
202
+ if self._copy_template(name, src, dest):
203
+ manifest[name] = {"src": src_hash, "user": False}
204
+ continue
205
+
206
+ if name not in manifest:
207
+ # Pre-manifest deployment: record intent; never clobber here.
208
+ if _sha256(dest) == src_hash:
209
+ manifest[name] = {"src": src_hash, "user": False}
210
+ else:
211
+ # Existing dest differing from source may be user-modified
212
+ # (bug #44): preserve it and pin it as user-owned so the
213
+ # tool never auto-propagates over it.
214
+ manifest[name] = {"src": src_hash, "user": True}
215
+ continue
216
+
217
+ entry = self._normalize_entry(manifest[name])
218
+ if entry["user"]:
219
+ # User-owned: never refresh, never clobber (bug #44).
220
+ continue
221
+
222
+ dest_hash = _sha256(dest)
223
+ if entry["src"] == src_hash:
224
+ # Source unchanged since last deploy; detect a post-deploy edit.
225
+ if dest_hash != entry["src"]:
226
+ manifest[name] = {"src": src_hash, "user": True}
227
+ else:
228
+ # Vendored source changed: refresh only a pristine tool copy.
229
+ if dest_hash == entry["src"]:
230
+ if self._copy_template(name, src, dest):
231
+ manifest[name] = {"src": src_hash, "user": False}
232
+ else:
233
+ # Destination diverged from the last deployed source.
234
+ manifest[name] = {"src": src_hash, "user": True}
235
+
236
+ return self._save_template_manifest(manifest, deploy_root)
237
+
238
+ def missing_template_destinations(self, deploy_root: Root) -> list[Path]:
239
+ """Destinations the manifest tracks as tool-owned that are now missing (#190).
240
+
241
+ The manifest's *presence* used to be the whole freshness answer, so a
242
+ template deleted from ``.wl`` was never copied back: ``wl format`` then
243
+ silently skipped its hooks and ``wl lint`` failed with no way to heal
244
+ itself. A per-entry ``exists()`` is what the manifest can actually be
245
+ held to here.
246
+
247
+ Deliberately **not** a content/hash check: an existing destination stays
248
+ untouched, so the "a manifest means do not overwrite user edits" policy
249
+ is preserved. Entries pinned as ``user: true`` are skipped -- the tool
250
+ never re-propagates to them, and recreating them would make the check
251
+ permanently true (a fresh never-converging loop).
252
+
253
+ Args:
254
+ deploy_root: The root the destinations were deployed under.
255
+
256
+ Returns:
257
+ The missing destination paths, in manifest order.
258
+ """
259
+ deploy_path = deploy_root.path
260
+ manifest = self._load_template_manifest(deploy_path)
261
+ templates = self.config.get("config_templates", {})
262
+
263
+ missing: list[Path] = []
264
+ for name, value in manifest.items():
265
+ if self._normalize_entry(value)["user"]:
266
+ continue
267
+ template = templates.get(name)
268
+ if template is None:
269
+ # The template is no longer shipped; a missing dest is expected.
270
+ continue
271
+ dest = deploy_path / template["dest"]
272
+ if not dest.exists():
273
+ missing.append(dest)
274
+ return missing
275
+
276
+ @staticmethod
277
+ def _normalize_entry(value):
278
+ """Normalize a manifest entry (legacy scalar or v2 record) to v2."""
279
+ if isinstance(value, dict):
280
+ return {"src": value.get("src", ""), "user": bool(value.get("user", False))}
281
+ if isinstance(value, str):
282
+ return {"src": value, "user": False}
283
+ return {"src": "", "user": False}
284
+
285
+ def _copy_template(self, name: str, src: Path, dest: Path) -> bool:
286
+ # Refreshing a template intentionally overwrites the destination; the
287
+ # caller has already decided (see _copy_config_templates) that the
288
+ # destination is a pristine tool copy, not user-modified.
289
+ result = self.file_copier.copy_file(src, dest, overwrite=True)
290
+ if result.success:
291
+ log_debug_pair(f"✓ Copied {name} from template", f"✓ 从模板复制 {name}")
292
+ return True
293
+ log_info(f"Warning: Failed to copy {name}: {result.message}")
294
+ return False
295
+
296
+ def _manifest_path(self, deploy_root: Path) -> Path:
297
+ """The manifest path resolved under ``deploy_root`` when it is relative.
298
+
299
+ ``TEMPLATE_MANIFEST`` stays a relative class constant so constructors
300
+ never run git; an absolute override -- as the tests supply -- is used
301
+ unchanged.
302
+ """
303
+ manifest = self.TEMPLATE_MANIFEST
304
+ return manifest if manifest.is_absolute() else deploy_root / manifest
305
+
306
+ def _load_template_manifest(self, deploy_root: Path) -> dict[str, Any]:
307
+ import json
308
+
309
+ try:
310
+ return cast(
311
+ "dict[str, Any]",
312
+ json.loads(
313
+ self._manifest_path(deploy_root).read_text(encoding="utf-8")
314
+ ),
315
+ )
316
+ except (OSError, json.JSONDecodeError):
317
+ return {}
318
+
319
+ def _save_template_manifest(self, manifest: dict, deploy_root: Path) -> bool:
320
+ """Persist the manifest; report (not swallow) a save that failed.
321
+
322
+ The save is the only place that knows whether the manifest actually
323
+ landed, so its verdict is returned instead of logged and dropped (#201):
324
+ a caller that answers "refreshed" from ``config.json`` alone reports
325
+ success for a manifest the process could not write, and every later
326
+ command re-runs the rebuild. Returning False lets :meth:`generate_configs`
327
+ fail the sub-step and engage the cooldown -- including the
328
+ *missing-manifest-in-a-read-only-``.wl``* state, where no path probe can
329
+ answer for the write that will actually be attempted.
330
+ """
331
+ import json
332
+
333
+ path = self._manifest_path(deploy_root)
334
+ try:
335
+ path.parent.mkdir(parents=True, exist_ok=True)
336
+ path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
337
+ except OSError as e:
338
+ log_info_pair(
339
+ f"Warning: Failed to save template manifest: {e}",
340
+ f"警告: 保存模板 manifest 失败: {e}",
341
+ )
342
+ return False
343
+ return True
344
+
345
+
346
+ def _same_file(a: Path, b: Path) -> bool:
347
+ """Whether two paths name the same file (tolerating symlinks)."""
348
+ try:
349
+ return a.resolve() == b.resolve()
350
+ except OSError:
351
+ return a == b
352
+
353
+
354
+ def _sha256(path: Path) -> str:
355
+ import hashlib
356
+
357
+ try:
358
+ return hashlib.sha256(path.read_bytes()).hexdigest()
359
+ except OSError:
360
+ return ""
@@ -0,0 +1,184 @@
1
+ """Project directory structure creator."""
2
+
3
+ import re
4
+ from pathlib import Path
5
+
6
+ from .....utils.logging import log_debug_pair, log_info_pair
7
+ from ..config_manager import ConfigManager
8
+
9
+
10
+ def normalize_project_name(project_name: str) -> str:
11
+ """Normalize a project name into a valid Python package identifier.
12
+
13
+ This is the single public implementation (#175) shared by directory
14
+ creation, template rendering and pyproject generation. It merges the
15
+ former ``_normalize_project_name`` and ``clean_python_package_name``
16
+ semantics: hyphens, dots, spaces and any other invalid characters
17
+ collapse to one underscore, leading/trailing underscores are stripped,
18
+ a leading digit is prefixed with ``_``, and an empty result falls back
19
+ to ``python_package``.
20
+ """
21
+ cleaned = re.sub(r"[^a-zA-Z0-9_]+", "_", project_name).strip("_")
22
+ if not cleaned:
23
+ return "python_package"
24
+ if cleaned[0].isdigit():
25
+ return f"_{cleaned}"
26
+ return cleaned
27
+
28
+
29
+ # Backwards-compatible private spelling: one implementation behind both names.
30
+ _normalize_project_name = normalize_project_name
31
+
32
+
33
+ def _create_required_directories(project_name: str) -> None:
34
+ """Create required project directories."""
35
+ normalized_name = _normalize_project_name(project_name)
36
+ directories = [
37
+ "src",
38
+ f"src/{normalized_name}",
39
+ f"src/{normalized_name}/lib", # 添加lib目录以支持Rust扩展
40
+ "tests",
41
+ "docs",
42
+ "examples",
43
+ "rust",
44
+ "dist",
45
+ ]
46
+
47
+ for directory in directories:
48
+ Path(directory).mkdir(parents=True, exist_ok=True)
49
+
50
+
51
+ def _create_init_files(project_name: str) -> None:
52
+ """Create __init__.py files for Python packages."""
53
+ normalized_name = normalize_project_name(project_name)
54
+ directories = [
55
+ "src",
56
+ f"src/{normalized_name}",
57
+ f"src/{normalized_name}/lib",
58
+ "tests",
59
+ "docs",
60
+ "examples",
61
+ ]
62
+
63
+ for directory in directories:
64
+ if Path(directory).exists():
65
+ init_file = Path(directory) / "__init__.py"
66
+ if not init_file.exists():
67
+ init_file.touch()
68
+
69
+
70
+ def _create_readme(project_name: str) -> None:
71
+ """Create README.md file if it doesn't exist or is empty."""
72
+ readme_file = Path("README.md")
73
+
74
+ # Only create README if it doesn't exist or is empty
75
+ should_create = (
76
+ not readme_file.exists()
77
+ or readme_file.read_text(encoding="utf-8").strip() == ""
78
+ )
79
+
80
+ if should_create:
81
+ # Try to get project description from config
82
+ try:
83
+ config_manager = ConfigManager()
84
+ description = config_manager.get(
85
+ "project_description", f"A Python project: {project_name}"
86
+ )
87
+ except Exception:
88
+ # Fallback to default description if config fails
89
+ description = f"A Python project: {project_name}"
90
+
91
+ readme_content = f"""# {project_name}
92
+
93
+ {description}
94
+
95
+ ## Installation
96
+
97
+ ```bash
98
+ pip install {project_name}
99
+ ```
100
+
101
+ ## Usage
102
+
103
+ ```python
104
+ import {project_name}
105
+ ```
106
+
107
+ ## Contributing
108
+
109
+ 1. Fork the repository
110
+ 2. Create a feature branch
111
+ 3. Make your changes
112
+ 4. Add tests
113
+ 5. Submit a pull request
114
+
115
+ ## License
116
+
117
+ MIT
118
+ """
119
+
120
+ readme_file.write_text(readme_content, encoding="utf-8")
121
+ log_debug_pair("✓ README.md created successfully", "✓ README.md 创建成功")
122
+
123
+
124
+ def setup_project_structure(project_name: str) -> None:
125
+ """Setup complete project structure by calling all required functions."""
126
+ _create_required_directories(project_name)
127
+ _create_init_files(project_name)
128
+ _create_readme(project_name)
129
+
130
+ # Copy and configure git hooks
131
+ from .hooks_manager import sync_hooks
132
+
133
+ sync_hooks()
134
+
135
+
136
+ class DirectoryCreator:
137
+ """Create project directory structure."""
138
+
139
+ def create_structure(self, project_name: str, workspace_mode: bool = False) -> None:
140
+ """Create main project directory structure.
141
+
142
+ Args:
143
+ project_name: Name of the project
144
+ workspace_mode: If True, create workspace layout (packages/{name}/)
145
+ If False, create flat layout (src/{name}/)
146
+ """
147
+ if workspace_mode:
148
+ _create_workspace_directories(project_name)
149
+ else:
150
+ _create_required_directories(project_name)
151
+ _create_init_files(project_name)
152
+
153
+ log_info_pair("✓ Project structure created successfully", "✓ 项目结构创建成功")
154
+
155
+
156
+ def _create_workspace_directories(project_name: str) -> None:
157
+ """Create workspace-style project directories under packages/{name}/."""
158
+ normalized_name = normalize_project_name(project_name)
159
+ package_dir = f"packages/{normalized_name}"
160
+ directories = [
161
+ package_dir,
162
+ f"{package_dir}/src",
163
+ f"{package_dir}/src/{normalized_name}",
164
+ f"{package_dir}/src/{normalized_name}/lib",
165
+ f"{package_dir}/rust",
166
+ f"{package_dir}/rust/src",
167
+ f"{package_dir}/tests",
168
+ "docs",
169
+ ]
170
+
171
+ for directory in directories:
172
+ Path(directory).mkdir(parents=True, exist_ok=True)
173
+
174
+ # Create __init__.py files for package
175
+ init_dirs = [
176
+ f"{package_dir}/src",
177
+ f"{package_dir}/src/{normalized_name}",
178
+ f"{package_dir}/src/{normalized_name}/lib",
179
+ f"{package_dir}/tests",
180
+ ]
181
+ for directory in init_dirs:
182
+ init_file = Path(directory) / "__init__.py"
183
+ if not init_file.exists():
184
+ init_file.touch()
@@ -0,0 +1,110 @@
1
+ """The one place that says where the git-hook templates live (#162).
2
+
3
+ Three spellings of "the vendored hooks template directory" used to coexist --
4
+ the deployment call, the staleness gate's private accessor, and a CWD-relative
5
+ literal in ``clean lfs`` -- so a layout change could be made in one place and
6
+ silently missed in another. :func:`locate_hook_source` is now the only
7
+ definition; every consumer takes its directories from the value it returns.
8
+
9
+ **Platform policy, made explicit.** All platforms deploy the ``unix`` templates
10
+ (Git Bash executes them on Windows). That used to be expressed only by passing
11
+ the already platform-qualified key ``hooks/unix`` to
12
+ :meth:`ConfigManager.get_vendor_config_path`, whose platform substitution fires
13
+ only for keys *without* a ``/win`` or ``/unix`` segment -- i.e. the decision was
14
+ encoded in the shape of a string, and the one place that *did* pass the bare
15
+ ``hooks`` key silently resolved to ``hooks/win`` on Windows. Here the policy is
16
+ the point of the module: the key is ``hooks/unix`` on every platform, and a
17
+ missing unix directory is reported as :attr:`HookSource.template_missing` -- it
18
+ never falls back to the bare key, which would put a different template set
19
+ behind the same locator.
20
+ """
21
+
22
+ from dataclasses import dataclass
23
+ from pathlib import Path
24
+
25
+ from .....utils.root import Root
26
+ from ..config_manager import ConfigManager
27
+
28
+ #: The vendored template subtree every platform deploys from. Explicitly
29
+ #: unix-qualified so ``get_vendor_config_path``'s platform substitution -- which
30
+ #: only fires for keys without a ``/win`` or ``/unix`` segment -- never rewrites
31
+ #: it. See the module docstring for why that is the policy, not an accident.
32
+ _HOOK_TEMPLATE_KEY = "hooks/unix"
33
+
34
+ #: There is no shared-config literal any more (ADR-0009 supersedes ADR-0005):
35
+ #: ``wl`` writes a **worktree-scoped** ``core.hooksPath``, and that value is
36
+ #: derived from the deploy root by :func:`locate_hook_source` -- see
37
+ #: :attr:`HookSource.worktree_hooks_path`. Keeping one derived value for both
38
+ #: the write side and the comparison side is what stops them drifting apart.
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class HookSource:
43
+ """Where hooks come from and where they go, in one value (#162).
44
+
45
+ Frozen because it is a snapshot: fields are read once at construction and
46
+ every later read returns that same answer, so one consumer cannot observe
47
+ the filesystem changing underneath it. That is why
48
+ :attr:`template_missing` is an ordinary field -- probed **once**, here at
49
+ construction -- rather than a property that re-stats the filesystem on
50
+ every access. ``apply_hook_sync`` branches on the flag and then warns on it;
51
+ a property would let a template directory that appeared or vanished in
52
+ between flip one read and not the other, producing either a silently
53
+ skipped copy/prune pass (branch saw "missing", warning saw "present") or a
54
+ prune pass off a template it then reports as missing (the reverse).
55
+ ``evaluate_hooks`` and ``apply_hook_sync`` call :func:`locate_hook_source`
56
+ independently, so each gets its own snapshot; the guarantee here is
57
+ consistency *within* one call, not sameness across the two.
58
+ """
59
+
60
+ #: Vendored template directory (``hooks/unix``) -- the same on every
61
+ #: platform, because Git Bash runs the unix hooks on Windows too.
62
+ template_dir: Path
63
+ #: The deploy root's ``.wl/hooks`` -- the directory ``core.hooksPath``
64
+ #: resolves to in the checkout being deployed into.
65
+ deploy_dir: Path
66
+ #: The **absolute** path written to the worktree-scoped ``core.hooksPath``
67
+ #: and compared against it (ADR-0009). Derived from the same deploy root as
68
+ #: :attr:`deploy_dir`, so "where the hooks were deployed" and "where the
69
+ #: pointer says they are" cannot disagree. Consumed by ``hooks_manager`` on
70
+ #: both the write side (``_configure_git_hooks_path``) and the comparison
71
+ #: side (``hooks_path_is_stale``), so this object really is the value's only
72
+ #: source.
73
+ #:
74
+ #: Absolute on purpose: the value no longer lives in the repository's shared
75
+ #: ``.git/config`` (where one checkout's absolute path would be invalid in
76
+ #: every other checkout) but in the *per-worktree* config, which is removed
77
+ #: together with the worktree it describes. ``as_posix()`` keeps the
78
+ #: forward-slash spelling git accepts on every platform.
79
+ worktree_hooks_path: str
80
+ #: Whether :attr:`template_dir` was absent when this snapshot was taken.
81
+ #: The explicit "template missing" result (#162): it exists so callers can
82
+ #: warn (or skip) instead of :func:`locate_hook_source` silently
83
+ #: re-resolving to another platform's directory -- the ``hooks/win``
84
+ #: fallback the bare ``hooks`` key used to produce on Windows.
85
+ template_missing: bool
86
+
87
+
88
+ def locate_hook_source(deploy_root: Root) -> HookSource:
89
+ """Locate the hook source and deployment target for *deploy_root*.
90
+
91
+ ``deploy_root`` is the root the hooks deploy under, supplied as a value by
92
+ the caller (the root module's ``DEPLOY`` kind, resolved once at the public
93
+ entry) rather than re-derived here -- a default would let a forgotten
94
+ argument silently fall back to the process CWD (#160).
95
+
96
+ A missing template directory is *not* an error: the result carries
97
+ :attr:`HookSource.template_missing`, so the read-only staleness gate can
98
+ keep a "cannot tell, do not force a sync" verdict and the applier can warn
99
+ (and must not read the missing directory as "the template no longer ships
100
+ these hooks"). The flag is probed here, once, for the same reason every
101
+ other field is: the result is a snapshot. Nothing is created or written
102
+ here.
103
+ """
104
+ template_dir = ConfigManager().get_vendor_config_path(_HOOK_TEMPLATE_KEY)
105
+ return HookSource(
106
+ template_dir=template_dir,
107
+ deploy_dir=deploy_root.hooks_dir(),
108
+ worktree_hooks_path=deploy_root.hooks_dir().as_posix(),
109
+ template_missing=not template_dir.is_dir(),
110
+ )