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,995 @@
1
+ """Git hooks manager utilities for project structure setup."""
2
+
3
+ import hashlib
4
+ import json
5
+ import shutil
6
+ import subprocess
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ from .....utils.logging import log_debug_pair, log_info_pair
11
+ from .....utils.root import Root, RootKind
12
+ from .....utils.root_fallback import current_root, current_worktree
13
+ from ...common.check_git_bash import check_git_bash_installed
14
+ from .hook_source import HookSource, locate_hook_source
15
+
16
+ #: Hooks the tool owns and keeps in sync with the vendored templates.
17
+ #: Unknown files in .wl/hooks/ are left untouched (user's own hooks survive).
18
+ #:
19
+ #: Only ``pre-push`` carries a version marker among these (see
20
+ #: :data:`_HOOK_MARKER_PREFIX`): the per-command gate's marker comparison covers
21
+ #: that hook alone, and the other three rely on the release-time hash sync
22
+ #: (:func:`apply_hook_sync`, called unconditionally by ``wl_dir_updater`` -- the
23
+ #: ``sync_hooks`` compatibility entry wraps it) for body drift (#159/m2).
24
+ KNOWN_HOOKS = frozenset(
25
+ {"pre-commit", "pre-push", "check_lockfile.py", "check_lockfile.sh"}
26
+ )
27
+
28
+ #: Prefix of the per-hook version marker line (``# WL-Pre-Push-Hook-Version: …``)
29
+ #: that the cheap per-command gate reads from a hook's first 512 bytes. The gate
30
+ #: compares the marker's *value* between the deployed hook and the vendored
31
+ #: template, so a template whose *body* changed under an un-bumped marker is not
32
+ #: invisible -- a prefix-only check could not see that drift (#158/B3).
33
+ #:
34
+ #: The prefix is ``pre-push``-specific on purpose, and only that hook carries a
35
+ #: marker: it is the single :data:`KNOWN_HOOKS` member whose body drift the
36
+ #: per-command gate can see. Extending the marker to the other three templates is
37
+ #: a change to the marker protocol itself (a shared prefix, a bump rule, and
38
+ #: every deployed copy re-syncing once), not a widening of this constant, so it
39
+ #: was left out of #159/m2 in favour of stating the coverage.
40
+ _HOOK_MARKER_PREFIX = "WL-Pre-Push-Hook-Version:"
41
+
42
+ #: The value written to ``core.hooksPath`` is no longer a shared-config literal:
43
+ #: it is the **absolute** deploy path carried by
44
+ #: :attr:`HookSource.worktree_hooks_path` (ADR-0009) and written with
45
+ #: ``git config --worktree``.
46
+
47
+ #: Key in ``.wl/config.json`` holding the **sentinel** (#274, carried into the
48
+ #: worktree scope by #276): the ``core.hooksPath`` value ``wl`` itself wrote last
49
+ #: for *this* worktree. The value lives in the per-worktree config, which another
50
+ #: tool or a human may also set -- and ``wl`` must not overwrite a value it did
51
+ #: not write (wlcc ADR-0011, decision 2 / decision 4). The sentinel is what makes
52
+ #: that distinguishable: a worktree-scoped value that is neither empty nor equal
53
+ #: to this record belongs to somebody else.
54
+ #:
55
+ #: A missing key is deliberately read as "never written by ``wl``" -- the default
56
+ #: for every ``config.json`` written before this key existed (backward
57
+ #: compatibility). Such a worktree is not locked out: an unset worktree value is
58
+ #: claimed, and a value equal to the path ``wl`` would write is recognised as its
59
+ #: own, because rewriting it cannot change what anybody else asked for.
60
+ HOOKS_PATH_SENTINEL_KEY = "core_hooks_path"
61
+
62
+ #: The repository-format extension that makes a per-worktree config file
63
+ #: (``config.worktree``) legal. It has to be set **before** ``git config
64
+ #: --worktree`` writes anything: without it git silently treats ``--worktree`` as
65
+ #: the shared local scope, which is exactly the cross-worktree coupling #276
66
+ #: removes. Setting it is idempotent and one-time: :func:`_ensure_worktree_config`
67
+ #: reads it first and only writes when it is not already true.
68
+ _WORKTREE_CONFIG_EXTENSION = "extensions.worktreeConfig"
69
+
70
+
71
+ def _git_top_level() -> Path | None:
72
+ """git's own work-tree root, or None when not inside a git work tree.
73
+
74
+ A ``WORKTREE``-kind lookup through the root module, whose git seam is the
75
+ ``git rev-parse --show-toplevel`` call the old git probe used.
76
+ Unlike the deploy root there is no fallback to spell out: the old resolver
77
+ also answered ``None`` when there was no work tree, and ``resolve`` reports
78
+ exactly that -- which is why this cannot go through ``current_root``.
79
+ """
80
+ root = current_worktree()
81
+ return root.path if root is not None else None
82
+
83
+
84
+ def _is_git_work_tree() -> bool:
85
+ """Whether the current directory is inside a git work tree."""
86
+ return _git_top_level() is not None
87
+
88
+
89
+ def _git_config_get(*scope_args: str) -> str | None:
90
+ """Read one ``git config`` value, returning None when unset/unreadable.
91
+
92
+ ``scope_args`` is the flag list that selects the scope under test
93
+ (``--worktree`` / ``--local``). Reading through git -- never by parsing
94
+ ``.git/config`` text -- is the #93 rule; the per-scope flags are what make
95
+ the *worktree* value distinguishable from the shared one (#276).
96
+ """
97
+ try:
98
+ result = subprocess.run(
99
+ ["git", "config", *scope_args, "--get", "core.hooksPath"],
100
+ capture_output=True,
101
+ text=True,
102
+ check=False,
103
+ )
104
+ except OSError:
105
+ return None
106
+ # No shape guard: the result is a real ``CompletedProcess`` here. Tests that
107
+ # drive this function patch ``hooks_manager.subprocess.run`` with a real one,
108
+ # so the #105/#106 ``getattr``/``isinstance`` defence -- which existed only
109
+ # because a globally patched ``subprocess.run`` could hand back something
110
+ # else, and which had to stay in step with the root module's git probe --
111
+ # has nothing left to defend against. It was removed with that probe (#123).
112
+ if result.returncode != 0:
113
+ return None
114
+ return result.stdout.strip() or None
115
+
116
+
117
+ def _get_worktree_hooks_path() -> str | None:
118
+ """Read the **worktree-scoped** ``core.hooksPath``, never the effective one.
119
+
120
+ This is the only value ``wl`` may claim or repair (#276): the effective
121
+ answer would silently include the shared config, which ``wl`` deliberately
122
+ leaves untouched (ADR-0009). Before ``extensions.worktreeConfig`` is enabled
123
+ git answers "unset" here -- the read is harmless and the write path enables
124
+ the extension first.
125
+
126
+ Returns:
127
+ The worktree-scoped value, or None when it is unset/unreadable.
128
+ """
129
+ return _git_config_get("--worktree")
130
+
131
+
132
+ def _get_shared_hooks_path() -> str | None:
133
+ """Read the **shared** (repository-wide) ``core.hooksPath``.
134
+
135
+ Used only to tell the user, in the log, that an older shared value is still
136
+ there as a fallback and how to remove it once nothing depends on it. Never
137
+ used for a decision: the shared config is not ``wl``'s to change (ADR-0009,
138
+ decision 2). ``--local`` is the shared repo config even in a linked
139
+ worktree -- it is the file every worktree reads.
140
+ """
141
+ return _git_config_get("--local")
142
+
143
+
144
+ def _worktree_hooks_path_origin() -> str | None:
145
+ """Which config file supplies the *worktree-scoped* ``core.hooksPath``.
146
+
147
+ Used only for the one-line explanation printed when ``wl`` refuses to touch
148
+ somebody else's value (#274, carried into the worktree scope by #276) --
149
+ "who set it" answered by git itself through ``--show-origin``
150
+ (``file:.../.git/worktrees/<id>/config.worktree``, ``command line:``, …)
151
+ rather than guessed. Never used for a decision.
152
+
153
+ Returns:
154
+ The origin git reports, or None when it is unreadable. Callers word the
155
+ "unknown" case separately per language, so this keeps returning None.
156
+ """
157
+ try:
158
+ result = subprocess.run(
159
+ [
160
+ "git",
161
+ "config",
162
+ "--worktree",
163
+ "--show-origin",
164
+ "--get",
165
+ "core.hooksPath",
166
+ ],
167
+ capture_output=True,
168
+ text=True,
169
+ check=False,
170
+ )
171
+ except OSError:
172
+ return None
173
+ if result.returncode != 0 or not result.stdout:
174
+ return None
175
+ # ``--show-origin`` prints ``<origin>\t<value>``; only the origin is wanted
176
+ # here (the value was already read by _get_worktree_hooks_path).
177
+ origin = result.stdout.splitlines()[0].split("\t", 1)[0].strip()
178
+ return origin or None
179
+
180
+
181
+ def _worktree_config_is_enabled() -> bool:
182
+ """Whether ``extensions.worktreeConfig`` is already on.
183
+
184
+ Read with ``--bool`` so any spelling git accepts (``true`` / ``yes`` / ``1``)
185
+ counts; anything unset/unreadable/invalid reads as "not enabled", which makes
186
+ the caller attempt the (idempotent) enable write.
187
+ """
188
+ try:
189
+ result = subprocess.run(
190
+ ["git", "config", "--bool", "--get", _WORKTREE_CONFIG_EXTENSION],
191
+ capture_output=True,
192
+ text=True,
193
+ check=False,
194
+ )
195
+ except OSError:
196
+ return False
197
+ return result.returncode == 0 and result.stdout.strip() == "true"
198
+
199
+
200
+ def _ensure_worktree_config() -> bool:
201
+ """Turn on ``extensions.worktreeConfig`` — once, idempotently.
202
+
203
+ The extension must be enabled **before** any ``git config --worktree``
204
+ write: without it git silently writes the *shared* local config, which is
205
+ exactly the cross-worktree coupling #276 removes. (Verified against
206
+ git 2.56: a pre-enable ``--worktree`` write landed in ``.git/config``.)
207
+
208
+ This is the one write ``wl`` makes to the shared config, and it is a
209
+ repository-format switch, not a ``core.hooksPath`` value: ``git`` itself
210
+ requires it in the common config. The read-first check keeps it a one-time
211
+ write.
212
+
213
+ Returns:
214
+ True when the extension is enabled (already or just now); False when git
215
+ refused the write, in which case no pointer is written either.
216
+ """
217
+ if _worktree_config_is_enabled():
218
+ return True
219
+ try:
220
+ subprocess.run(
221
+ ["git", "config", _WORKTREE_CONFIG_EXTENSION, "true"],
222
+ check=True,
223
+ capture_output=False,
224
+ )
225
+ except (subprocess.CalledProcessError, OSError) as e:
226
+ log_info_pair(
227
+ f"Warning: failed to enable {_WORKTREE_CONFIG_EXTENSION}: {e}",
228
+ f"警告: 启用 {_WORKTREE_CONFIG_EXTENSION} 失败: {e}",
229
+ )
230
+ return False
231
+ log_debug_pair(
232
+ f"✓ Enabled per-worktree git config ({_WORKTREE_CONFIG_EXTENSION}).",
233
+ f"✓ 已启用按 worktree 分离的 git 配置({_WORKTREE_CONFIG_EXTENSION})。",
234
+ )
235
+ return True
236
+
237
+
238
+ def _read_hooks_path_sentinel(deploy_root: Root) -> str | None:
239
+ """The **worktree-scoped** ``core.hooksPath`` value ``wl`` recorded last.
240
+
241
+ ``deploy_root`` anchors the ``.wl/config.json`` this is read from -- the same
242
+ deploy root the pointer itself is written for, so the record and the write it
243
+ describes can never name different ``.wl`` directories. Because ``.wl`` is
244
+ per-checkout, each worktree keeps its own record of its own absolute path
245
+ (#274 in the worktree scope, #276).
246
+
247
+ A missing file, unreadable file, non-object JSON, or missing/non-string key
248
+ all answer None. That is the deliberate backward-compatibility rule for a
249
+ ``config.json`` written before the sentinel existed: "no record" is read as
250
+ "``wl`` has not recorded a claim", never as an error.
251
+ """
252
+ try:
253
+ with open(deploy_root.wl_dir() / "config.json", encoding="utf-8") as f:
254
+ data = json.load(f)
255
+ except (OSError, json.JSONDecodeError):
256
+ return None
257
+ if not isinstance(data, dict):
258
+ return None
259
+ value = data.get(HOOKS_PATH_SENTINEL_KEY)
260
+ return value if isinstance(value, str) and value else None
261
+
262
+
263
+ def _record_hooks_path_sentinel(deploy_root: Root, value: str) -> bool:
264
+ """Record ``value`` as the ``core.hooksPath`` value ``wl`` just wrote.
265
+
266
+ Read-modify-write of the existing ``.wl/config.json``, preserving every other
267
+ key: the sentinel is one field of the user's config, not a file of its own.
268
+
269
+ The file is never *created* here. ``ConfigGenerator`` owns the "materialise a
270
+ full default config" step, and creating a config stub before it runs would
271
+ make that step see an existing file and skip the defaults (#202's half-built
272
+ ``.wl`` shape). When there is no record to update, the write still stands --
273
+ the next successful write will record it, and until then the value is
274
+ recognised as ``wl``'s own by equality with the expected absolute path.
275
+
276
+ Returns:
277
+ True when the record already held ``value`` or was updated; False when
278
+ the config could not be read/written. A False is not fatal: the value on
279
+ disk is already the one ``wl`` wanted, so the worst case is that the
280
+ record is attempted again on the next write.
281
+ """
282
+ config_file = deploy_root.wl_dir() / "config.json"
283
+ try:
284
+ with open(config_file, encoding="utf-8") as f:
285
+ data = json.load(f)
286
+ if not isinstance(data, dict):
287
+ return False
288
+ if data.get(HOOKS_PATH_SENTINEL_KEY) == value:
289
+ return True
290
+ data[HOOKS_PATH_SENTINEL_KEY] = value
291
+ # Same serialisation as `_update_config_version` (wl_dir_updater): indent=2,
292
+ # raw non-ASCII, trailing newline -- one house format for this file.
293
+ with open(config_file, "w", encoding="utf-8") as f:
294
+ json.dump(data, f, indent=2, ensure_ascii=False)
295
+ f.write("\n")
296
+ return True
297
+ except (OSError, json.JSONDecodeError):
298
+ return False
299
+
300
+
301
+ def _main_worktree_path() -> Path | None:
302
+ """The main worktree's checkout path, or None when git cannot say.
303
+
304
+ ``git worktree list --porcelain`` documents the main worktree as the first
305
+ entry. Used only by :func:`_inherited_from_main`.
306
+ """
307
+ try:
308
+ result = subprocess.run(
309
+ ["git", "worktree", "list", "--porcelain"],
310
+ capture_output=True,
311
+ text=True,
312
+ check=False,
313
+ )
314
+ except OSError:
315
+ return None
316
+ if result.returncode != 0:
317
+ return None
318
+ prefix = "worktree "
319
+ for line in result.stdout.splitlines():
320
+ if line.startswith(prefix):
321
+ return Path(line[len(prefix) :])
322
+ return None
323
+
324
+
325
+ def _same_path(left: Path, right: Path) -> bool:
326
+ """Whether two paths name the same directory, tolerating spelling aliases."""
327
+ try:
328
+ return left.resolve() == right.resolve()
329
+ except (OSError, RuntimeError):
330
+ return left == right
331
+
332
+
333
+ def _inherited_from_main(current: str, deploy_root: Root) -> bool:
334
+ """Whether ``current`` is wl's own value **copied in by ``git worktree add``**.
335
+
336
+ Git copies the main worktree's ``config.worktree`` into every new linked
337
+ worktree (verified on git 2.56 by inspecting the file before any write).
338
+ With the extension on that copy includes our ``core.hooksPath``, so a
339
+ freshly created worktree starts with the MAIN checkout's absolute path and,
340
+ because its ``.wl`` is a fresh checkout, no local sentinel.
341
+
342
+ Treating that value as a stranger would leave the new worktree pointing at
343
+ another checkout's hooks forever -- the exact "wrong checkout's hooks"
344
+ failure #276 removes. The main worktree's sentinel is what tells the copy
345
+ apart from a value a human set: only an exact match is adopted, and
346
+ rewriting it then changes only wl's own pointer.
347
+
348
+ Quiet and cheap: called only when the value is neither unset, the local
349
+ sentinel, nor the expected path, i.e. never on a converged worktree.
350
+ """
351
+ main = _main_worktree_path()
352
+ if main is None or _same_path(main, deploy_root.path):
353
+ return False
354
+ main_sentinel = _read_hooks_path_sentinel(Root(main, RootKind.WORKTREE))
355
+ return main_sentinel is not None and main_sentinel == current
356
+
357
+
358
+ def _worktree_hooks_path_is_ours(
359
+ current: str | None,
360
+ sentinel: str | None,
361
+ worktree_hooks_path: str,
362
+ deploy_root: Root,
363
+ ) -> bool:
364
+ """Whether the worktree-scoped ``core.hooksPath`` may be rewritten (#274/#276).
365
+
366
+ The ticket's rule, applied to the **worktree** scope: "current value is
367
+ empty, or equal to the sentinel". Two equality shortcuts are added, both
368
+ describing a value ``wl`` itself wrote and therefore unable to overwrite
369
+ anybody else's intent:
370
+
371
+ * ``current == worktree_hooks_path`` -- exactly what a write would produce
372
+ for this worktree. Allowing the write only records the claim (and repairs
373
+ a directory that went missing). This covers a ``config.json`` whose
374
+ sentinel was lost while the pointer itself is still correct.
375
+ * ``current == the MAIN worktree's sentinel`` -- the value ``git worktree
376
+ add`` copied from the main checkout's ``config.worktree`` (see
377
+ :func:`_inherited_from_main`). Without this, every worktree created after
378
+ the main checkout was claimed would start with the main checkout's path
379
+ and be stuck pointing at it.
380
+
381
+ Unlike the shared-value rule this replaces, there is **no** legacy-absolute
382
+ or ``.wl/hooks``-literal shortcut: no released ``wl`` ever wrote the
383
+ worktree scope, so a value there came either from somebody else or from
384
+ git's copy of wl's own shared-with-main setting.
385
+
386
+ Everything else -- a dangling path, a directory belonging to another tool, a
387
+ hand-edited value -- is a value ``wl`` did not write and must not silently
388
+ replace.
389
+ """
390
+ if current is None:
391
+ return True
392
+ if current == sentinel or current == worktree_hooks_path:
393
+ return True
394
+ return _inherited_from_main(current, deploy_root)
395
+
396
+
397
+ def _hash_file(path: Path) -> str:
398
+ """SHA-256 of a file's bytes; empty string when unreadable."""
399
+ try:
400
+ return hashlib.sha256(path.read_bytes()).hexdigest()
401
+ except OSError:
402
+ return ""
403
+
404
+
405
+ def _read_hook_marker(path: Path) -> str | None:
406
+ """Return the :data:`_HOOK_MARKER_PREFIX` marker *value* in ``path``'s head.
407
+
408
+ Reads at most the first 512 bytes -- the window the cheap gate has always
409
+ used -- and returns the text after the marker prefix (stripped), or ``None``
410
+ when the file is unreadable or carries no marker line. Comparing this value
411
+ (not merely the prefix) is what lets a template whose *body* changed under
412
+ an un-bumped marker trip :func:`hooks_need_sync` (#158/B3); a prefix-only
413
+ check could never see that drift.
414
+ """
415
+ try:
416
+ head = path.read_bytes()[:512].decode("utf-8", errors="replace")
417
+ except OSError:
418
+ return None
419
+ for line in head.splitlines():
420
+ if _HOOK_MARKER_PREFIX in line:
421
+ return line.split(_HOOK_MARKER_PREFIX, 1)[1].strip()
422
+ return None
423
+
424
+
425
+ def _sync_known_hooks(
426
+ hooks_src: Path,
427
+ hooks_dest: Path,
428
+ *,
429
+ changed_files: list[str] | None = None,
430
+ pruned: list[str] | None = None,
431
+ ) -> bool:
432
+ """Copy known hooks that are missing or differ from the template.
433
+
434
+ A known hook that the template no longer ships is pruned from the
435
+ destination. Unknown files in hooks_dest are never touched.
436
+
437
+ ``changed_files`` / ``pruned``, when supplied, receive the names involved in
438
+ this same pass -- copied/updated, and removed respectively -- so
439
+ :func:`apply_hook_sync` can report *what* changed without a second hash
440
+ sweep (#161: the verdict is computed once). Both are optional so the plain
441
+ 2-argument form keeps its "did anything change?" contract.
442
+
443
+ Returns:
444
+ True if any hook file changed (copied, updated, or pruned).
445
+ """
446
+ changed = False
447
+
448
+ for hook_name in KNOWN_HOOKS:
449
+ src = hooks_src / hook_name
450
+ dest = hooks_dest / hook_name
451
+
452
+ if src.exists():
453
+ if not dest.exists() or _hash_file(src) != _hash_file(dest):
454
+ shutil.copy2(src, dest)
455
+ dest.chmod(0o755)
456
+ changed = True
457
+ if changed_files is not None:
458
+ changed_files.append(hook_name)
459
+ else:
460
+ # Template no longer ships this known hook -> prune installed copy
461
+ if dest.exists():
462
+ dest.unlink()
463
+ changed = True
464
+ if pruned is not None:
465
+ pruned.append(hook_name)
466
+
467
+ return changed
468
+
469
+
470
+ def _log_shared_fallback_note() -> None:
471
+ """Tell the user, once per claim, that a shared value is still around (#276).
472
+
473
+ ADR-0009 decision 2 keeps the shared ``core.hooksPath`` byte-for-byte as it
474
+ was: it remains the fallback for checkouts that never ran this ``wl``
475
+ version, and cleaning it up is the human's call. Printing the value and the
476
+ exact removal command here is the "cleanup lives in the log, not in a new
477
+ subcommand" half of that decision.
478
+
479
+ Quiet when the shared config has no value (the common case).
480
+ """
481
+ shared = _get_shared_hooks_path()
482
+ if shared is None:
483
+ return
484
+ log_info_pair(
485
+ f"The shared (repository-wide) core.hooksPath still holds {shared!r}; "
486
+ "wl leaves it unchanged as a fallback for checkouts that have not run "
487
+ "wl. This worktree's own value takes precedence here. Remove the shared "
488
+ "value with `git config --unset core.hooksPath` once no checkout "
489
+ "depends on it.",
490
+ f"共享 core.hooksPath 仍为 {shared!r};wl 原样保留,作为尚未运行过 wl 的"
491
+ "检出的回退。本 worktree 自己的值在此处优先。待无检出依赖它时,可用 "
492
+ "`git config --unset core.hooksPath` 清除。",
493
+ )
494
+
495
+
496
+ def _configure_git_hooks_path(worktree_hooks_path: str, deploy_root: Root) -> bool:
497
+ """Point **this worktree's** ``core.hooksPath`` at the wl-managed directory.
498
+
499
+ Writes ``worktree_hooks_path`` -- the locator's absolute deploy path
500
+ (:attr:`HookSource.worktree_hooks_path`), never a string in the shared
501
+ config -- with ``git config --worktree`` (ADR-0009, superseding ADR-0005).
502
+ Because the value lives in the per-worktree config it is removed together
503
+ with the worktree: deleting a linked checkout can no longer leave every
504
+ other checkout pointing at a directory that is gone.
505
+
506
+ The shared config is left exactly as it was, with one deliberate exception:
507
+ ``extensions.worktreeConfig=true`` must be set for a per-worktree config to
508
+ exist at all, so :func:`_ensure_worktree_config` turns it on -- read-first,
509
+ one time. Any pre-existing shared ``core.hooksPath`` stays as a fallback and
510
+ :func:`_log_shared_fallback_note` reports it with its removal command.
511
+
512
+ The write is idempotent: the same absolute path is stored every time, so a
513
+ worktree whose value already matches is never written again. A worktree
514
+ whose value is ``wl``'s own but stale -- the worktree was moved, or the
515
+ hooks directory vanished -- is repointed once by
516
+ :func:`hooks_path_is_stale`'s verdict.
517
+
518
+ The worktree-scoped value shadows any shared one for this checkout only.
519
+ That is intended: the shared legacy value is somebody's fallback, not this
520
+ worktree's pointer.
521
+
522
+ **Never overwrites a value ``wl`` did not write** (#274 in the worktree
523
+ scope, #276 decision 4). The current *worktree-scoped* value is read before
524
+ writing and passed through :func:`_worktree_hooks_path_is_ours`: empty or
525
+ equal to the sentinel ``wl`` recorded in ``.wl/config.json`` -> write;
526
+ anything else -> leave it untouched and print one line naming the value, the
527
+ config file that supplies it (``git config --worktree --show-origin``) and
528
+ how to hand the pointer to ``wl``.
529
+
530
+ ``deploy_root`` anchors the sentinel (``<deploy_root>/.wl/config.json``) --
531
+ each worktree records its own value under its own ``.wl``. It is required: a
532
+ default would let a caller write a record into a ``.wl`` other than the one
533
+ the pointer describes.
534
+
535
+ Returns:
536
+ True when the git config write succeeded; False when it was skipped
537
+ (not inside a git work tree), the worktree config could not be enabled,
538
+ git refused the write, or the value on disk belongs to somebody else.
539
+ """
540
+ # The gate is "did the WORKTREE lookup resolve to a real work tree?" --
541
+ # i.e. is there a checkout for git to run the hooks from at all. Outside one
542
+ # the deploy root is the project-root fallback, which no repository's config
543
+ # should be pointed at.
544
+ if not _is_git_work_tree():
545
+ # Nothing to configure (e.g. a non-git directory): stay quiet.
546
+ log_debug_pair(
547
+ "Skipping core.hooksPath configuration (not a git work tree).",
548
+ "跳过 core.hooksPath 配置(不在 git 工作树中)。",
549
+ )
550
+ return False
551
+
552
+ # Enable the per-worktree config *before* reading or writing it: a
553
+ # `--worktree` write without the extension lands in the shared config, which
554
+ # is precisely the defect #276 removes.
555
+ if not _ensure_worktree_config():
556
+ return False
557
+
558
+ # The ownership gate (#274/#276). Reading the current value here costs one
559
+ # `git config --get --worktree` -- but only on a write attempt, which is
560
+ # rare (a first claim, a hook-file change, or a stale path), never on the
561
+ # converged per-command path.
562
+ current = _get_worktree_hooks_path()
563
+ sentinel = _read_hooks_path_sentinel(deploy_root)
564
+ if not _worktree_hooks_path_is_ours(
565
+ current, sentinel, worktree_hooks_path, deploy_root
566
+ ):
567
+ origin = _worktree_hooks_path_origin()
568
+ # One line, English + Chinese, per the ticket: what the value is, which
569
+ # config file supplies it, and the explicit way to hand it to wl. That
570
+ # value itself is *not* unset and not modified.
571
+ log_info_pair(
572
+ f"core.hooksPath is set to {current!r} by "
573
+ f"{origin or 'an unknown scope'} in this worktree's config; wl does "
574
+ "not overwrite it. To let wl manage git hooks here, run "
575
+ "`git config --worktree --unset core.hooksPath` and re-run wl.",
576
+ f"本 worktree 的 core.hooksPath 已由 {origin or '未知作用域'} 设为 "
577
+ f"{current!r};wl 不会覆盖它。若要在本 worktree 让 wl 接管钩子,请先执行 "
578
+ "`git config --worktree --unset core.hooksPath`,再重跑 wl。",
579
+ )
580
+ return False
581
+
582
+ try:
583
+ subprocess.run(
584
+ ["git", "config", "--worktree", "core.hooksPath", worktree_hooks_path],
585
+ check=True,
586
+ capture_output=False,
587
+ )
588
+ # Record what wl just wrote, so the next run can tell it from a value
589
+ # somebody else sets later. A failure to record is not a failed write:
590
+ # the value on disk is already wl's (see the helper's contract).
591
+ _record_hooks_path_sentinel(deploy_root, worktree_hooks_path)
592
+ _log_shared_fallback_note()
593
+ log_debug_pair(
594
+ f"✓ Configured Git to use custom hooks directory: {worktree_hooks_path}",
595
+ f"✓ 配置Git使用自定义钩子目录: {worktree_hooks_path}",
596
+ )
597
+ return True
598
+ except subprocess.CalledProcessError as e:
599
+ log_info_pair(
600
+ f"Warning: Failed to configure custom hooks directory: {e}",
601
+ f"警告: 配置自定义钩子目录失败: {e}",
602
+ )
603
+ return False
604
+ except Exception as e:
605
+ log_info_pair(
606
+ f"Warning: Failed to configure custom hooks directory: {e}",
607
+ f"警告: 配置自定义钩子目录失败: {e}",
608
+ )
609
+ return False
610
+
611
+
612
+ def _log_missing_hooks_directory(source: HookSource) -> None:
613
+ """Log warning if the located template directory is missing (#162).
614
+
615
+ The "missing" verdict is the locator's explicit
616
+ :attr:`HookSource.template_missing` -- never a fallback to another
617
+ platform's directory.
618
+ """
619
+ if source.template_missing:
620
+ log_info_pair(
621
+ f" Warning: Hooks template directory not found at {source.template_dir}",
622
+ f" 警告: 未找到hooks模板目录 {source.template_dir}",
623
+ )
624
+
625
+
626
+ def hooks_path_is_stale(
627
+ worktree_hooks_path: str, deploy_root: Root | None = None
628
+ ) -> bool:
629
+ """Whether this worktree's ``core.hooksPath`` needs ``wl`` to (re)write it.
630
+
631
+ Read-only. ``worktree_hooks_path`` is the expected absolute value, supplied
632
+ by the caller from the locator (:attr:`HookSource.worktree_hooks_path`) so
633
+ the comparison side names the value exactly where the write side does
634
+ (#162).
635
+
636
+ **Only the worktree scope is consulted (#276).** The effective value would
637
+ also match a legacy shared ``core.hooksPath`` -- and writing because of it
638
+ is exactly what ADR-0009 forbids (the shared config is not ``wl``'s to
639
+ change). The read is ``git config --get --worktree core.hooksPath``, so a
640
+ shared value is invisible here; it stays as the fallback it always was.
641
+
642
+ **Ownership first (#274).** A worktree value that was not written by ``wl``
643
+ is not stale: it is somebody else's, and asking for a rewrite would ask for
644
+ exactly what the ticket forbids. The value counts as ``wl``'s own when it is
645
+ unset, equal to the sentinel recorded in ``.wl/config.json``, or equal to the
646
+ path ``wl`` would write -- see :func:`_worktree_hooks_path_is_ours`. Anything
647
+ else answers False: no write is attempted, and a pointer ``wl`` has decided
648
+ to leave alone is not reported as stale on every command either.
649
+
650
+ For a value that *is* ``wl``'s own, it is stale when either holds:
651
+
652
+ * the **raw** configured string is not exactly ``worktree_hooks_path``
653
+ (e.g. the path of the checkout before it was moved/renamed) -- repoint it
654
+ once;
655
+ * the expected directory does not exist (a first claim whose deploy has not
656
+ happened yet, or a moved repository).
657
+
658
+ The first test compares the raw ``git config --get --worktree`` output
659
+ against the expected value with **neither side normalised**. The expected
660
+ value is already absolute, so there is no "every run rewrites" trap: a value
661
+ produced by a write compares equal to itself forever, and any other spelling
662
+ converges in one write.
663
+
664
+ ``deploy_root`` anchors the sentinel. The sync chain passes the root it
665
+ already resolved; direct callers may omit it, and this single-root entry then
666
+ resolves exactly the one root it needs through
667
+ :func:`~wl_cli.utils.root_fallback.current_root` -- the same compatibility
668
+ pattern as :func:`hooks_need_sync` (#160).
669
+
670
+ Outside a git work tree this always reports False. Nothing can be configured
671
+ there, and a machine-wide ``core.hooksPath`` (from ``~/.gitconfig`` or the
672
+ system config) would otherwise look stale forever: reported on every ``wl``
673
+ run, retried on every ``wl`` run, and never repairable.
674
+
675
+ Cost: inside a git work tree this is two git subprocesses (``rev-parse
676
+ --show-toplevel`` and ``config --get --worktree``) plus one read of
677
+ ``.wl/config.json``; outside one it is a single ``rev-parse``.
678
+ """
679
+ # Also the "can we repair anything here?" guard for the branch below: a
680
+ # global core.hooksPath is visible via `git config --get` even outside a
681
+ # repository, and is not ours to fix.
682
+ if _git_top_level() is None:
683
+ return False
684
+
685
+ current = _get_worktree_hooks_path()
686
+ if current is None:
687
+ # Unclaimed: wl may claim it, and there is nothing of anybody else's to
688
+ # preserve.
689
+ return True
690
+
691
+ if deploy_root is None:
692
+ deploy_root = current_root(RootKind.DEPLOY)
693
+ sentinel = _read_hooks_path_sentinel(deploy_root)
694
+ if not _worktree_hooks_path_is_ours(
695
+ current, sentinel, worktree_hooks_path, deploy_root
696
+ ):
697
+ # Somebody else's pointer: not stale, because "stale" would authorise a
698
+ # write that `_configure_git_hooks_path` must refuse anyway.
699
+ return False
700
+
701
+ if current != worktree_hooks_path:
702
+ # wl's own value in another spelling (a moved/renamed worktree): one
703
+ # rewrite to the expected absolute path, then it matches.
704
+ return True
705
+
706
+ # The value is the expected path; ask whether it actually points at a
707
+ # directory. It is absolute, so git's work-tree-relative resolution rules do
708
+ # not apply -- `Path.is_dir()` is the same question git asks.
709
+ return not Path(worktree_hooks_path).is_dir()
710
+
711
+
712
+ @dataclass(frozen=True)
713
+ class HookSyncState:
714
+ """Hook-staleness verdict computed once by :func:`evaluate_hooks` (#161).
715
+
716
+ Replaces the old ``bool`` from ``hooks_need_sync``: :func:`apply_hook_sync`
717
+ consumes this value, so the ``core.hooksPath`` probe paid for here is never
718
+ repeated inside the sync.
719
+ """
720
+
721
+ #: Known hooks absent from the deployed directory (sorted).
722
+ missing: tuple[str, ...]
723
+ #: The deployed pre-push's version marker is absent or differs in *value*
724
+ #: from the vendored template's (#158/B3).
725
+ marker_stale: bool
726
+ #: ``core.hooksPath`` is not the wl-managed directory (see
727
+ #: :func:`hooks_path_is_stale`). Always known: the probe runs even when a
728
+ #: hook is already missing/marker-stale, so there is no ``None``/"unknown"
729
+ #: state for the applier to resolve.
730
+ path_stale: bool
731
+
732
+ @property
733
+ def needs_sync(self) -> bool:
734
+ """Whether any of the three conditions fired."""
735
+ return bool(self.missing) or self.marker_stale or self.path_stale
736
+
737
+
738
+ @dataclass(frozen=True)
739
+ class SyncReport:
740
+ """What :func:`apply_hook_sync` actually changed (#161).
741
+
742
+ Keeping the outcomes separate is what makes "only the path was repaired"
743
+ assertable through the public interface: ``path_repaired is True`` with
744
+ ``files_changed is False`` and ``pruned == ()``.
745
+
746
+ That is an *outcome* distinction, not a diagnosis: any file change --
747
+ including a prune-only pass -- also attempts to write ``core.hooksPath``
748
+ (see :func:`apply_hook_sync`), so ``path_repaired is True`` only means that
749
+ write succeeded. It does **not** mean the path was stale, and outside a git
750
+ work tree the write is not even attempted
751
+ (``_configure_git_hooks_path`` returns ``False`` and writes nothing).
752
+ """
753
+
754
+ #: A known hook was copied or updated from the template.
755
+ files_changed: bool
756
+ #: ``core.hooksPath`` was (re)written successfully. Attempted whenever *any*
757
+ #: known hook file changed (copy/update **or** a prune-only pass) as well as
758
+ #: when the evaluated path was stale, so ``True`` here does not imply the
759
+ #: path had been stale. ``_configure_git_hooks_path`` returns ``False``
760
+ #: outside a git work tree, where nothing is written and this stays
761
+ #: ``False``; see :func:`apply_hook_sync`.
762
+ path_repaired: bool
763
+ #: Known hooks removed because the template no longer ships them (sorted).
764
+ pruned: tuple[str, ...] = ()
765
+
766
+ @property
767
+ def changed(self) -> bool:
768
+ """Whether anything at all changed."""
769
+ return self.files_changed or self.path_repaired or bool(self.pruned)
770
+
771
+
772
+ def evaluate_hooks(deploy_root: Root) -> HookSyncState:
773
+ """Evaluate hook staleness once, in full (#161).
774
+
775
+ Reports the three conditions the old ``hooks_need_sync`` bool collapsed:
776
+
777
+ * ``missing`` -- which of :data:`KNOWN_HOOKS` is absent from
778
+ ``<deploy_root>/.wl/hooks``;
779
+ * ``marker_stale`` -- the deployed ``pre-push``'s version marker is missing
780
+ or differs from the vendored template's. The comparison is on the marker
781
+ **value** -- the text after :data:`_HOOK_MARKER_PREFIX` -- not merely the
782
+ prefix's presence: a template whose body changed is expected to bump that
783
+ value, and the value comparison is what turns such a bump into a real
784
+ re-sync (#158/B3). The template's marker is consulted only once the
785
+ deployed hook has one; an unreadable template (e.g. an install whose
786
+ located ``template_dir`` is missing) keeps the old prefix-present verdict
787
+ rather than forcing a sync that could not repair anything;
788
+ * ``path_stale`` -- :func:`hooks_path_is_stale` (the *worktree-scoped*
789
+ value is unset, dangling, or not exactly the absolute deploy path, for a
790
+ value ``wl`` owns). A value somebody else set in the worktree config is
791
+ never stale: #274 forbids overwriting it, so reporting staleness would
792
+ only authorise a write the applier must refuse. Outside a git work tree
793
+ this is always False.
794
+
795
+ The path probe runs even when a hook is already known missing or
796
+ marker-stale. That is the deliberate choice recorded on #161: one git
797
+ subprocess buys a *complete* state, so the applier never re-probes and the
798
+ state never has to represent "unknown".
799
+
800
+ Coverage is deliberately narrow: of :data:`KNOWN_HOOKS` only ``pre-push``
801
+ carries a version marker, so the value comparison above guards that hook
802
+ alone. A body change to ``pre-commit`` / ``check_lockfile.py`` /
803
+ ``check_lockfile.sh`` under an unchanged marker is therefore invisible *to
804
+ this gate* -- but it is bounded, not silent: :func:`apply_hook_sync` runs
805
+ unconditionally on every ``wl`` release (``wl_dir_updater``) and compares
806
+ every known hook by hash, so such a drift survives at most one release.
807
+ Widening the comparison to all of :data:`KNOWN_HOOKS` means extending the
808
+ marker protocol to three more vendored templates (a shared prefix, a bump
809
+ rule, one re-sync of every deployed copy), which is a protocol change rather
810
+ than a docstring one; #159/m2 states the coverage instead of making it.
811
+
812
+ Cost: two 512-byte reads (deployed + template) and one path probe -- one or
813
+ two git subprocesses (~a few ms) -- on every ``wl`` command. No hook file is
814
+ hashed.
815
+ """
816
+ # One locator call supplies both directories: the deploy directory the gate
817
+ # inspects and the template the marker is read from (#162).
818
+ source = locate_hook_source(deploy_root)
819
+ hooks_dir = source.deploy_dir
820
+
821
+ missing = tuple(
822
+ hook_name
823
+ for hook_name in sorted(KNOWN_HOOKS)
824
+ if not (hooks_dir / hook_name).exists()
825
+ )
826
+
827
+ marker_stale = False
828
+ pre_push = hooks_dir / "pre-push"
829
+ if pre_push.exists():
830
+ deployed_marker = _read_hook_marker(pre_push)
831
+ if deployed_marker is None:
832
+ marker_stale = True
833
+ else:
834
+ template_marker = _read_hook_marker(source.template_dir / "pre-push")
835
+ if template_marker is not None and template_marker != deployed_marker:
836
+ marker_stale = True
837
+
838
+ # Always probed: see the docstring. This is the single call site of
839
+ # hooks_path_is_stale on the sync path -- apply_hook_sync consumes the value
840
+ # instead of asking git again (#161).
841
+ path_stale = hooks_path_is_stale(source.worktree_hooks_path, deploy_root)
842
+
843
+ return HookSyncState(
844
+ missing=missing,
845
+ marker_stale=marker_stale,
846
+ path_stale=path_stale,
847
+ )
848
+
849
+
850
+ def apply_hook_sync(state: HookSyncState, deploy_root: Root) -> SyncReport:
851
+ """Apply an already evaluated :class:`HookSyncState` (#161).
852
+
853
+ 1. Copies known hook templates from the located template directory into the
854
+ deploy directory when they are missing or differ from the template
855
+ (hash-based).
856
+ 2. Prunes known hooks the template no longer ships.
857
+ 3. Attempts to point **this worktree's** ``git config --worktree
858
+ core.hooksPath`` at :attr:`HookSource.worktree_hooks_path` (the absolute
859
+ ``.wl/hooks`` deploy path, ADR-0009) when the hook files changed OR
860
+ ``state.path_stale`` -- so a pure path fault (a moved worktree, a moved
861
+ repository, a hand-edited config) is repaired even though no hook file
862
+ changed. *Any* hook-file change counts, including a prune-only pass, so a
863
+ ``True`` :attr:`SyncReport.path_repaired` records a successful write, not
864
+ a stale-path diagnosis. Outside a git work tree
865
+ ``_configure_git_hooks_path`` returns ``False`` and nothing is written.
866
+
867
+ The write is additionally gated on ownership (#274 in the worktree
868
+ scope, #276): when the current worktree-scoped ``core.hooksPath`` was not
869
+ written by ``wl`` (not empty, not the sentinel in ``.wl/config.json``,
870
+ not the exact path wl would write), the pointer is left exactly as it is
871
+ and one explanatory line is logged. A file-change pass still deploys the
872
+ hook files in that case -- only that pointer is somebody else's to decide.
873
+
874
+ Steps 1-2 are skipped entirely when the located
875
+ :attr:`HookSource.template_missing` is set: a missing template directory is
876
+ "cannot tell", never "the template ships no hooks", and pruning on it would
877
+ delete every deployed known hook off a broken install. The warning is still
878
+ logged, and a stale ``core.hooksPath`` is still repaired.
879
+
880
+ Both directories, and the literal written to ``core.hooksPath``, come from
881
+ :func:`locate_hook_source` -- the single definition of where hooks come
882
+ from and go (#162).
883
+
884
+ It never calls :func:`hooks_path_is_stale`: the verdict is the one
885
+ :func:`evaluate_hooks` already paid for, which is the recomputation this
886
+ change removes.
887
+
888
+ The file sync always runs, even when ``state.needs_sync`` is False, for the
889
+ same reason the old ``sync_hooks`` did: it is the hash-based safety net that
890
+ catches body drift in the hooks carrying no version marker, on the release
891
+ path.
892
+ """
893
+ # Check for Git Bash (only runs on Windows)
894
+ check_git_bash_installed()
895
+
896
+ source = locate_hook_source(deploy_root)
897
+ source.deploy_dir.mkdir(parents=True, exist_ok=True)
898
+ # ``source.worktree_hooks_path`` is the same absolute deploy path in git's
899
+ # spelling; creating it above makes that path real before the pointer is
900
+ # written.
901
+
902
+ # Copy/prune known hooks (hash-aware), recording what moved in this pass.
903
+ copied: list[str] = []
904
+ pruned: list[str] = []
905
+ any_file_change = False
906
+ if source.template_missing:
907
+ # A missing template directory says nothing about which hooks the
908
+ # installation *ships*: ``_sync_known_hooks`` would read every absent
909
+ # ``src`` as "the template no longer ships this hook" and prune all four
910
+ # deployed hooks off a broken install. Warn and leave the deploy
911
+ # directory exactly as it is. The flag is this call's snapshot, not a
912
+ # fresh stat, so it cannot change between this branch and the warning
913
+ # just below -- but ``evaluate_hooks`` located its own, earlier, snapshot
914
+ # independently, so the two calls may still disagree if the template
915
+ # directory changed between them.
916
+ _log_missing_hooks_directory(source)
917
+ else:
918
+ any_file_change = _sync_known_hooks(
919
+ source.template_dir,
920
+ source.deploy_dir,
921
+ changed_files=copied,
922
+ pruned=pruned,
923
+ )
924
+
925
+ # (Re)configure the path when the files changed or the evaluated path was
926
+ # stale. ``state.path_stale`` replaces the old in-sync probe. The absolute
927
+ # path comes from the locator's field -- the same value the comparison side
928
+ # used, so write and compare cannot drift apart.
929
+ path_repaired = False
930
+ if any_file_change or state.path_stale:
931
+ # ``bool`` keeps the report a plain boolean even when a caller stubs
932
+ # the helper with a truthy object.
933
+ path_repaired = bool(
934
+ _configure_git_hooks_path(source.worktree_hooks_path, deploy_root)
935
+ )
936
+
937
+ return SyncReport(
938
+ files_changed=bool(copied),
939
+ path_repaired=path_repaired,
940
+ pruned=tuple(sorted(pruned)),
941
+ )
942
+
943
+
944
+ def hooks_need_sync(deploy_root: Root | None = None) -> bool:
945
+ """Per-command hook staleness check, as a bool (compatibility wrapper).
946
+
947
+ Equivalent to ``evaluate_hooks(deploy_root).needs_sync`` (#161). Kept for
948
+ callers that only need the yes/no; code that will also sync should call
949
+ :func:`evaluate_hooks` once and hand the state to :func:`apply_hook_sync`.
950
+
951
+ ``deploy_root`` is threaded through as a value by the self-sync chain, which
952
+ no longer chdirs to the deploy root; when it is omitted the single deploy
953
+ root this check needs is resolved from :func:`current_root` -- once, at this
954
+ public entry (#160).
955
+ """
956
+ if deploy_root is None:
957
+ deploy_root = current_root(RootKind.DEPLOY)
958
+ return evaluate_hooks(deploy_root).needs_sync
959
+
960
+
961
+ def sync_hooks(deploy_root: Root | None = None) -> bool:
962
+ """Sync known git hooks for the project (compatibility wrapper).
963
+
964
+ Evaluates **once** (:func:`evaluate_hooks`), applies that same state
965
+ (:func:`apply_hook_sync`), and returns ``report.changed`` -- so the
966
+ ``core.hooksPath`` probe is not repeated inside the sync (#161). The work
967
+ it performs:
968
+
969
+ 1. Copies known hook templates from the located template directory into the
970
+ deploy directory when they are missing or differ from it (hash-based).
971
+ 2. Prunes known hooks the template no longer ships.
972
+ 3. Points this worktree's ``git config --worktree core.hooksPath`` at
973
+ :attr:`HookSource.worktree_hooks_path` (the absolute ``.wl/hooks``
974
+ deploy path, ADR-0009) when the hook files changed OR the configured path
975
+ is stale -- so a pure path fault (a moved worktree, a moved repository, a
976
+ hand-edited config) is repaired even though no hook file changed.
977
+
978
+ ``deploy_root`` is the root the hook files are deployed under, threaded in
979
+ as a value by the self-sync chain (which no longer chdirs to it); when it is
980
+ omitted the deploy root comes from :func:`current_root`, resolved once at
981
+ this public entry (#160).
982
+
983
+ Upgrade note (#276): the first ``wl`` run after the upgrade enables
984
+ ``extensions.worktreeConfig`` (once) and claims the empty worktree scope
985
+ with this checkout's absolute path -- two ``git config`` writes. The shared
986
+ config's old ``core.hooksPath`` is left untouched as a fallback; later runs
987
+ find the worktree value matching and write nothing.
988
+
989
+ Returns:
990
+ True if any hook file changed or the git hooks path was (re)configured.
991
+ """
992
+ if deploy_root is None:
993
+ deploy_root = current_root(RootKind.DEPLOY)
994
+ state = evaluate_hooks(deploy_root)
995
+ return apply_hook_sync(state, deploy_root).changed