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,165 @@
1
+ """LFS Lock policy validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import getpass
6
+ import os
7
+ from dataclasses import dataclass, field
8
+ from typing import TYPE_CHECKING, Literal
9
+
10
+ if TYPE_CHECKING:
11
+ from .config import LfsLockConfig
12
+ from .manager import LfsLockManager
13
+
14
+ PolicyStatus = Literal["OK", "WARN", "BLOCKED"]
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class LockCheckResult:
19
+ ok: bool
20
+ status: PolicyStatus
21
+ locked_files: frozenset[str] = field(default_factory=frozenset)
22
+ unlocked_files: frozenset[str] = field(default_factory=frozenset)
23
+ locked_by_others: frozenset[str] = field(default_factory=frozenset)
24
+ config_source: str = ""
25
+
26
+
27
+ class LfsLockPolicyValidator:
28
+ """Validates that changed LFS files are properly locked before push."""
29
+
30
+ LOCK_TIMEOUT = 10 # seconds
31
+
32
+ def __init__(
33
+ self, config: LfsLockConfig, manager: LfsLockManager | None = None
34
+ ) -> None:
35
+ self.config = config
36
+ self._manager = manager
37
+
38
+ def validate(self, changed_files: frozenset[str]) -> LockCheckResult:
39
+ if self.config.mode == "off":
40
+ return LockCheckResult(
41
+ ok=True,
42
+ status="OK",
43
+ config_source=self.config.config_source,
44
+ )
45
+
46
+ manager = self._manager or LfsLockManager()
47
+ lfs_files = manager.get_lfs_files(changed_files)
48
+
49
+ if not lfs_files:
50
+ return LockCheckResult(
51
+ ok=True,
52
+ status="OK",
53
+ config_source=self.config.config_source,
54
+ )
55
+
56
+ locks = manager.get_locks(timeout=self.LOCK_TIMEOUT)
57
+ locked_paths = frozenset(lock["path"] for lock in locks)
58
+ current_user = getpass.getuser()
59
+
60
+ locked_by_current: set[str] = set()
61
+ locked_by_others: set[str] = set()
62
+ unlocked: set[str] = set()
63
+
64
+ for f in lfs_files:
65
+ if f in locked_paths:
66
+ lock_owner = next(
67
+ (lock["owner"]["name"] for lock in locks if lock["path"] == f),
68
+ "",
69
+ )
70
+ if lock_owner == current_user:
71
+ locked_by_current.add(f)
72
+ else:
73
+ locked_by_others.add(f)
74
+ else:
75
+ unlocked.add(f)
76
+
77
+ if locked_by_others and not self.config.locked_by_self:
78
+ return LockCheckResult(
79
+ ok=False,
80
+ status="BLOCKED",
81
+ locked_files=frozenset(locked_by_current),
82
+ unlocked_files=frozenset(unlocked),
83
+ locked_by_others=frozenset(locked_by_others),
84
+ config_source=self.config.config_source,
85
+ )
86
+
87
+ if unlocked:
88
+ if self.config.mode == "strict":
89
+ return LockCheckResult(
90
+ ok=False,
91
+ status="BLOCKED",
92
+ locked_files=frozenset(locked_by_current),
93
+ unlocked_files=frozenset(unlocked),
94
+ locked_by_others=frozenset(locked_by_others),
95
+ config_source=self.config.config_source,
96
+ )
97
+ else:
98
+ return LockCheckResult(
99
+ ok=True,
100
+ status="WARN",
101
+ locked_files=frozenset(locked_by_current),
102
+ unlocked_files=frozenset(unlocked),
103
+ locked_by_others=frozenset(locked_by_others),
104
+ config_source=self.config.config_source,
105
+ )
106
+
107
+ return LockCheckResult(
108
+ ok=True,
109
+ status="OK",
110
+ locked_files=frozenset(locked_by_current),
111
+ unlocked_files=frozenset(),
112
+ locked_by_others=frozenset(),
113
+ config_source=self.config.config_source,
114
+ )
115
+
116
+ def validate_with_fallback(self, changed_files: frozenset[str]) -> LockCheckResult:
117
+ """Validate LFS locks, auto-locking unlocked files if fallback is enabled.
118
+
119
+ In CI environments (CI=true), fallback is skipped to avoid stale locks.
120
+ """
121
+ # Run normal validation first
122
+ result = self.validate(changed_files)
123
+ if result.ok:
124
+ return result
125
+
126
+ # Only apply fallback if configured and not in CI
127
+ if not self.config.auto_lock_fallback:
128
+ return result
129
+
130
+ in_ci = os.environ.get("CI", "") == "true"
131
+ if in_ci:
132
+ return result
133
+
134
+ # Skip fallback when files are locked by others
135
+ if result.locked_by_others and self.config.auto_lock_skip_locked_by_others:
136
+ return result
137
+
138
+ # Auto-lock unlocked files that nobody else has locked
139
+ manager = self._manager or LfsLockManager()
140
+ files_to_lock = [
141
+ f for f in result.unlocked_files if f not in result.locked_by_others
142
+ ]
143
+ if not files_to_lock:
144
+ return result
145
+
146
+ lock_results = manager.lock_files(files_to_lock)
147
+ failed = [f for f, ok in lock_results.items() if not ok]
148
+ if failed:
149
+ return LockCheckResult(
150
+ ok=False,
151
+ status="BLOCKED",
152
+ locked_files=result.locked_files,
153
+ unlocked_files=frozenset(failed),
154
+ locked_by_others=result.locked_by_others,
155
+ config_source=self.config.config_source,
156
+ )
157
+
158
+ return LockCheckResult(
159
+ ok=True,
160
+ status="OK",
161
+ locked_files=result.locked_files | frozenset(files_to_lock),
162
+ unlocked_files=frozenset(),
163
+ locked_by_others=frozenset(),
164
+ config_source=self.config.config_source,
165
+ )
@@ -0,0 +1,294 @@
1
+ """LFS file watcher — auto-lock on file modification, auto-unlock on push detection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import time
7
+ from dataclasses import dataclass
8
+ from typing import TYPE_CHECKING, cast
9
+
10
+ import click
11
+
12
+ from ...utils.root import Root, RootKind
13
+ from ...utils.root_fallback import current_root
14
+ from .config import LfsLockConfig
15
+ from .manager import LfsLockManager
16
+
17
+ if TYPE_CHECKING:
18
+ from pathlib import Path
19
+
20
+ try:
21
+ import watchdog.events
22
+
23
+ _WATCHDOG_AVAILABLE = True
24
+ except ImportError:
25
+ _WATCHDOG_AVAILABLE = False
26
+
27
+
28
+ @dataclass
29
+ class WatchSessionStats:
30
+ """Session summary printed on exit."""
31
+
32
+ files_locked: int = 0
33
+ files_unlocked: int = 0
34
+ files_skipped: int = 0
35
+
36
+
37
+ class LfsWatcher:
38
+ """Watches the filesystem for LFS file modifications and auto-locks them.
39
+
40
+ Also polls git refs to detect successful pushes and auto-unlock.
41
+ """
42
+
43
+ REF_POLL_INTERVAL = 5 # seconds between ref polls
44
+
45
+ def __init__(
46
+ self,
47
+ project_root: Path | None = None,
48
+ verbose: bool = False,
49
+ ) -> None:
50
+ # Root-anchored (#125): the *project* root the CWD belongs to, not the
51
+ # CWD itself, so ``wl lfs watch`` from a subdirectory watches the whole
52
+ # project and reads the root's ``.wl/lfs-lock.yml`` (see ``_wl_dir``)
53
+ # rather than a subdirectory that has no ``.wl`` of its own.
54
+ self.project_root = project_root or current_root(RootKind.PROJECT).path
55
+ self.verbose = verbose
56
+ self.manager = LfsLockManager(cwd=str(self.project_root))
57
+ self.config = LfsLockConfig(config_path=self._wl_dir() / "lfs-lock.yml")
58
+ self._debounce_registry: dict[str, float] = {}
59
+ self._debounce_seconds = 2.0
60
+ self._running = True
61
+ self._ref_hash_cache: dict[str, str] = {}
62
+ self.stats = WatchSessionStats()
63
+
64
+ def _wl_dir(self) -> Path:
65
+ """The ``.wl`` control directory at this watcher's root.
66
+
67
+ Built from :class:`~wl_cli.utils.root.Root` so the control-directory
68
+ name has a single definition (#114/#121). The anchor is this watcher's
69
+ ``project_root`` -- exactly the directory the pre-migration code joined
70
+ the name onto, so behaviour is unchanged.
71
+
72
+ #123 re-checked the cache-invalidation question this raises: the watcher
73
+ *creates* this directory (``_write_pid_file``), and a newly created
74
+ ``.wl`` can change what a ``PROJECT``/``DEPLOY`` lookup would answer.
75
+ No invalidation is needed. This constructs a :class:`Root` directly and
76
+ never calls ``resolve``, so nothing here reads or writes the root memo
77
+ -- that memo lives in :mod:`wl_cli.utils.root` and is per-process, and
78
+ this process never asks it. Re-check again if the watcher ever starts
79
+ resolving instead of joining.
80
+ """
81
+ return Root(self.project_root, RootKind.PROJECT).wl_dir()
82
+
83
+ def log(self, message: str) -> None:
84
+ """Print message if verbose, always print to internal buffer."""
85
+ ts = time.strftime("%H:%M:%S")
86
+ line = f" [{ts}] {message}"
87
+ click.echo(line)
88
+
89
+ def _startup_scan(self) -> None:
90
+ """Scan for already-modified LFS files and lock them."""
91
+ git_dir = self.project_root / ".git"
92
+ if not git_dir.is_dir():
93
+ return
94
+
95
+ # Check if .gitattributes exists
96
+ gitattr = self.project_root / ".gitattributes"
97
+ if not gitattr.is_file():
98
+ return
99
+
100
+ result = self.manager._run_git(["diff", "--name-only", "HEAD"])
101
+ if result.returncode != 0 or not result.stdout.strip():
102
+ return
103
+
104
+ changed = [f for f in result.stdout.strip().split("\n") if f]
105
+ lfs_files = [f for f in changed if self.manager.is_file_lfs_tracked(f)]
106
+ if not lfs_files:
107
+ return
108
+
109
+ self.log(f"Startup scan: {len(lfs_files)} modified LFS file(s)")
110
+ results = self.manager.lock_files(lfs_files)
111
+ for f, (ok, reason) in results.items():
112
+ if ok:
113
+ self.log(f" Locked: {f}")
114
+ self.stats.files_locked += 1
115
+ else:
116
+ self.log(f" Failed to lock: {f} ({reason})")
117
+
118
+ def _should_debounce(self, file_path: str) -> bool:
119
+ """Return True if this file's event should be debounced."""
120
+ now = time.time()
121
+ last = self._debounce_registry.get(file_path, 0)
122
+ if now - last < self._debounce_seconds:
123
+ return True
124
+ self._debounce_registry[file_path] = now
125
+ return False
126
+
127
+ def _is_already_running(self) -> bool:
128
+ """Check if another watch process is already running via PID file."""
129
+ pid_file = self._wl_dir() / "lfs-watch.pid"
130
+ if not pid_file.exists():
131
+ return False
132
+ try:
133
+ pid = int(pid_file.read_text().strip())
134
+ os.kill(pid, 0) # signal 0: check if process exists
135
+ return True
136
+ except (ValueError, OSError):
137
+ pid_file.unlink(missing_ok=True)
138
+ return False
139
+
140
+ def _write_pid_file(self) -> None:
141
+ """Write current PID to lock file."""
142
+ self._wl_dir().mkdir(exist_ok=True)
143
+ pid_file = self._wl_dir() / "lfs-watch.pid"
144
+ pid_file.write_text(str(os.getpid()))
145
+
146
+ def _cleanup_pid_file(self) -> None:
147
+ """Remove PID file on shutdown."""
148
+ pid_file = self._wl_dir() / "lfs-watch.pid"
149
+ pid_file.unlink(missing_ok=True)
150
+
151
+ def _get_current_ref(self, branch: str) -> str | None:
152
+ """Get current remote ref hash for a branch. Returns None if no remote."""
153
+ result = self.manager._run_git(["rev-parse", f"refs/remotes/origin/{branch}"])
154
+ if result.returncode != 0:
155
+ return None
156
+ return cast("str", result.stdout.strip())
157
+
158
+ def _poll_ref(self, branch: str) -> None:
159
+ """Poll remote ref for changes. If detected, auto-unlock pushed files."""
160
+ if not self.config.auto_unlock_on_push:
161
+ return
162
+
163
+ new_hash = self._get_current_ref(branch)
164
+ if new_hash is None:
165
+ return
166
+
167
+ old_hash = self._ref_hash_cache.get(branch)
168
+ if old_hash and old_hash == new_hash:
169
+ return
170
+ if old_hash is None:
171
+ self._ref_hash_cache[branch] = new_hash
172
+ return
173
+
174
+ self._ref_hash_cache[branch] = new_hash
175
+
176
+ result = self.manager._run_git(["diff", "--name-only", old_hash, new_hash])
177
+ if result.returncode != 0 or not result.stdout.strip():
178
+ return
179
+
180
+ pushed = [f for f in result.stdout.strip().split("\n") if f]
181
+ lfs_files = [f for f in pushed if self.manager.is_file_lfs_tracked(f)]
182
+ if not lfs_files:
183
+ return
184
+
185
+ self.log(f"Push detected on {branch}: {len(lfs_files)} LFS file(s)")
186
+ for attempt in range(self.config.auto_unlock_retry_count):
187
+ results = self.manager.unlock_files(lfs_files)
188
+ for _f, ok in results.items():
189
+ if ok:
190
+ self.stats.files_unlocked += 1
191
+ failed = [f for f, ok in results.items() if not ok]
192
+ if not failed:
193
+ self.log(f" Unlocked: {', '.join(lfs_files)}")
194
+ return
195
+ if attempt < self.config.auto_unlock_retry_count - 1:
196
+ time.sleep(self.config.auto_unlock_retry_delay)
197
+ self.log(
198
+ f" Failed to unlock after {self.config.auto_unlock_retry_count} attempts"
199
+ )
200
+
201
+ def _on_modified(self, file_path: str) -> None:
202
+ """Handle a file modification event."""
203
+ if self._should_debounce(file_path):
204
+ return
205
+ if not self.manager.is_file_lfs_tracked(file_path):
206
+ return
207
+ self.log(f"Modified: {file_path}")
208
+ ok, reason = self.manager.lock(file_path)
209
+ if ok:
210
+ self.stats.files_locked += 1
211
+ self.log(" Auto-locked")
212
+ else:
213
+ self.stats.files_skipped += 1
214
+ self.log(f" Lock failed: {reason}")
215
+
216
+ def run(self) -> int:
217
+ """Start watching. Blocks until Ctrl-C."""
218
+ if self._is_already_running():
219
+ click.echo("Error: LFS watch is already running.", err=True)
220
+ return 1
221
+
222
+ if not (self.project_root / ".git").is_dir():
223
+ click.echo("Error: Not a git repository.", err=True)
224
+ return 1
225
+
226
+ click.echo("LFS Watch started. Ctrl-C to stop.")
227
+ self._write_pid_file()
228
+
229
+ if not _WATCHDOG_AVAILABLE:
230
+ click.echo(
231
+ "Error: watchdog is not installed. Run: pip install py_wlcli[lfs]",
232
+ err=True,
233
+ )
234
+ return 1
235
+
236
+ import watchdog.observers
237
+
238
+ self._startup_scan()
239
+
240
+ # Determine the branch to poll
241
+ branch = self._get_default_branch()
242
+ if branch:
243
+ self.log(f"Push monitoring active on: origin/{branch}")
244
+ self._ref_hash_cache[branch] = self._get_current_ref(branch) or ""
245
+
246
+ observer = watchdog.observers.Observer()
247
+ event_handler = _WatchdogHandler(self)
248
+ observer.schedule(event_handler, str(self.project_root), recursive=True)
249
+ observer.start()
250
+
251
+ try:
252
+ while self._running:
253
+ if branch:
254
+ self._poll_ref(branch)
255
+ time.sleep(self.REF_POLL_INTERVAL)
256
+ except KeyboardInterrupt:
257
+ pass
258
+ finally:
259
+ observer.stop()
260
+ observer.join()
261
+ self._cleanup_pid_file()
262
+
263
+ click.echo(
264
+ f"LFS Watch stopped. "
265
+ f"Session: {self.stats.files_locked} lock(s), "
266
+ f"{self.stats.files_unlocked} unlock(s), "
267
+ f"{self.stats.files_skipped} skipped."
268
+ )
269
+ return 0
270
+
271
+ def _get_default_branch(self) -> str | None:
272
+ """Determine the default remote branch."""
273
+ result = self.manager._run_git(["symbolic-ref", "refs/remotes/origin/HEAD"])
274
+ if result.returncode != 0:
275
+ return None
276
+ full = result.stdout.strip()
277
+ return cast("str", full.replace("refs/remotes/origin/", ""))
278
+
279
+
280
+ if _WATCHDOG_AVAILABLE:
281
+
282
+ class _WatchdogHandler(watchdog.events.FileSystemEventHandler):
283
+ """Watchdog event handler that dispatches to LfsWatcher."""
284
+
285
+ def __init__(self, watcher: LfsWatcher) -> None:
286
+ super().__init__()
287
+ self.watcher = watcher
288
+
289
+ def on_modified(self, event: watchdog.events.FileSystemEvent) -> None:
290
+ if not event.is_directory:
291
+ src = event.src_path
292
+ if isinstance(src, bytes):
293
+ src = src.decode("utf-8", errors="replace")
294
+ self.watcher._on_modified(src)
@@ -0,0 +1 @@
1
+ """Lint command package."""