py-wlcli 0.8.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- py_wlcli-0.8.0.dist-info/METADATA +139 -0
- py_wlcli-0.8.0.dist-info/RECORD +320 -0
- py_wlcli-0.8.0.dist-info/WHEEL +4 -0
- py_wlcli-0.8.0.dist-info/entry_points.txt +2 -0
- py_wlcli-0.8.0.dist-info/licenses/LICENSE +21 -0
- wl_cli/__init__.py +27 -0
- wl_cli/__main__.py +130 -0
- wl_cli/capabilities.py +79 -0
- wl_cli/cc/__init__.py +5 -0
- wl_cli/cc/checks.py +1019 -0
- wl_cli/cc/cli.py +370 -0
- wl_cli/cc/mutate.py +1350 -0
- wl_cli/cli_click.py +156 -0
- wl_cli/commands/__init__.py +84 -0
- wl_cli/commands/base.py +80 -0
- wl_cli/commands/buildcommands/__init__.py +1 -0
- wl_cli/commands/buildcommands/build.py +71 -0
- wl_cli/commands/buildcommands/build_utils.py +47 -0
- wl_cli/commands/buildcommands/builders/__init__.py +18 -0
- wl_cli/commands/buildcommands/builders/cleanup.py +35 -0
- wl_cli/commands/buildcommands/builders/coordinator.py +122 -0
- wl_cli/commands/buildcommands/builders/environment.py +81 -0
- wl_cli/commands/buildcommands/builders/rust_builder.py +39 -0
- wl_cli/commands/buildcommands/builders/stubs.py +58 -0
- wl_cli/commands/buildcommands/dist.py +326 -0
- wl_cli/commands/buildcommands/test.py +89 -0
- wl_cli/commands/clean/__init__.py +1 -0
- wl_cli/commands/clean/build.py +151 -0
- wl_cli/commands/clean/clean.py +294 -0
- wl_cli/commands/clean/clean_utils.py +77 -0
- wl_cli/commands/clean/core.py +236 -0
- wl_cli/commands/clean/dead_code/__init__.py +0 -0
- wl_cli/commands/clean/dead_code/dead_code_command.py +70 -0
- wl_cli/commands/clean/dead_code/detector.py +161 -0
- wl_cli/commands/clean/dead_code/fixer.py +176 -0
- wl_cli/commands/clean/dead_code/reporter.py +295 -0
- wl_cli/commands/clean/dead_code/utils.py +0 -0
- wl_cli/commands/clean/lfs.py +215 -0
- wl_cli/commands/clean/python.py +81 -0
- wl_cli/commands/clean/rust.py +91 -0
- wl_cli/commands/clean/utils/__init__.py +34 -0
- wl_cli/commands/clean/venv.py +91 -0
- wl_cli/commands/cleanup.py +73 -0
- wl_cli/commands/click_base.py +79 -0
- wl_cli/commands/config/__init__.py +1 -0
- wl_cli/commands/config/config.py +291 -0
- wl_cli/commands/debug/__init__.py +6 -0
- wl_cli/commands/debug/debug.py +309 -0
- wl_cli/commands/debug/debug_cache.py +86 -0
- wl_cli/commands/debug/debug_subagent.py +156 -0
- wl_cli/commands/debug/output_formatter.py +165 -0
- wl_cli/commands/debug/source_hasher.py +92 -0
- wl_cli/commands/format/__init__.py +13 -0
- wl_cli/commands/format/format_cache_adapter.py +125 -0
- wl_cli/commands/format/format_command.py +197 -0
- wl_cli/commands/format/format_coordinator.py +191 -0
- wl_cli/commands/format/format_executor.py +470 -0
- wl_cli/commands/format/format_outcome.py +44 -0
- wl_cli/commands/format/format_path_handler.py +1016 -0
- wl_cli/commands/format/format_progress.py +62 -0
- wl_cli/commands/format/format_reporter.py +31 -0
- wl_cli/commands/format/python_formatter.py +506 -0
- wl_cli/commands/format/rust_formatter.py +106 -0
- wl_cli/commands/gate/__init__.py +1 -0
- wl_cli/commands/gate/gate_command.py +862 -0
- wl_cli/commands/gate/preflight.py +445 -0
- wl_cli/commands/git/__init__.py +1 -0
- wl_cli/commands/git/config.py +169 -0
- wl_cli/commands/git/git_facts.py +60 -0
- wl_cli/commands/git/git_flow.py +86 -0
- wl_cli/commands/git/hook.py +112 -0
- wl_cli/commands/git/policy.py +216 -0
- wl_cli/commands/git/push_update.py +82 -0
- wl_cli/commands/initenv/__init__.py +1 -0
- wl_cli/commands/initenv/common/__init__.py +23 -0
- wl_cli/commands/initenv/common/check_git_bash.py +78 -0
- wl_cli/commands/initenv/common/error_handler.py +68 -0
- wl_cli/commands/initenv/common/exceptions.py +52 -0
- wl_cli/commands/initenv/components/__init__.py +4 -0
- wl_cli/commands/initenv/components/cargo_sync.py +240 -0
- wl_cli/commands/initenv/components/config_manager.py +106 -0
- wl_cli/commands/initenv/components/directory_utils.py +71 -0
- wl_cli/commands/initenv/components/git/__init__.py +9 -0
- wl_cli/commands/initenv/components/git/branch_creator.py +143 -0
- wl_cli/commands/initenv/components/git/git_flow_interface.py +24 -0
- wl_cli/commands/initenv/components/git/git_flow_manager.py +159 -0
- wl_cli/commands/initenv/components/git/git_lfs_manager.py +272 -0
- wl_cli/commands/initenv/components/git/git_utility.py +254 -0
- wl_cli/commands/initenv/components/git/gitignore_manager.py +53 -0
- wl_cli/commands/initenv/components/git_initializer.py +212 -0
- wl_cli/commands/initenv/components/i18n_manager.py +104 -0
- wl_cli/commands/initenv/components/log_manager.py +241 -0
- wl_cli/commands/initenv/components/project_structure/__init__.py +23 -0
- wl_cli/commands/initenv/components/project_structure/config_generator.py +360 -0
- wl_cli/commands/initenv/components/project_structure/directory_creator.py +184 -0
- wl_cli/commands/initenv/components/project_structure/hook_source.py +110 -0
- wl_cli/commands/initenv/components/project_structure/hooks_manager.py +995 -0
- wl_cli/commands/initenv/components/project_structure/readme_handler.py +56 -0
- wl_cli/commands/initenv/components/project_structure/rust_files_handler.py +30 -0
- wl_cli/commands/initenv/components/project_structure/setup_handler.py +103 -0
- wl_cli/commands/initenv/components/project_structure/sop_generator.py +73 -0
- wl_cli/commands/initenv/components/project_structure/template_copier.py +104 -0
- wl_cli/commands/initenv/components/project_structure/template_manager.py +195 -0
- wl_cli/commands/initenv/components/pyproject_generator.py +194 -0
- wl_cli/commands/initenv/components/rust_initializer.py +145 -0
- wl_cli/commands/initenv/components/symlink_cleaner.py +99 -0
- wl_cli/commands/initenv/components/symlink_manager.py +52 -0
- wl_cli/commands/initenv/components/venv_manager.py +118 -0
- wl_cli/commands/initenv/initenv.py +169 -0
- wl_cli/commands/initenv/services/__init__.py +11 -0
- wl_cli/commands/initenv/services/environment_initializer.py +288 -0
- wl_cli/commands/initenv/services/git_lfs_initializer.py +26 -0
- wl_cli/commands/initenv/services/project_structure_initializer.py +52 -0
- wl_cli/commands/lfs/README.md +221 -0
- wl_cli/commands/lfs/__init__.py +5 -0
- wl_cli/commands/lfs/cli.py +106 -0
- wl_cli/commands/lfs/config.py +164 -0
- wl_cli/commands/lfs/hook.py +61 -0
- wl_cli/commands/lfs/initializer.py +531 -0
- wl_cli/commands/lfs/manager.py +95 -0
- wl_cli/commands/lfs/policy.py +165 -0
- wl_cli/commands/lfs/watcher.py +294 -0
- wl_cli/commands/lint/__init__.py +1 -0
- wl_cli/commands/lint/lint_command.py +297 -0
- wl_cli/commands/lint/lint_executor.py +409 -0
- wl_cli/commands/lint/lint_reporter.py +579 -0
- wl_cli/commands/lint/lint_run.py +258 -0
- wl_cli/commands/lint/mypy_config.py +90 -0
- wl_cli/commands/registry.py +103 -0
- wl_cli/commands/self/__init__.py +7 -0
- wl_cli/commands/self/common/__init__.py +8 -0
- wl_cli/commands/self/common/capabilities.py +30 -0
- wl_cli/commands/self/common/config.py +27 -0
- wl_cli/commands/self/common/version_cache.py +85 -0
- wl_cli/commands/self/handlers/__init__.py +19 -0
- wl_cli/commands/self/handlers/base_handler.py +28 -0
- wl_cli/commands/self/handlers/install_handler.py +85 -0
- wl_cli/commands/self/handlers/status_handler.py +66 -0
- wl_cli/commands/self/handlers/uninstall_handler.py +58 -0
- wl_cli/commands/self/handlers/update_handler.py +102 -0
- wl_cli/commands/self/handlers/version_handler.py +16 -0
- wl_cli/commands/self/self_command.py +237 -0
- wl_cli/commands/self/services/__init__.py +15 -0
- wl_cli/commands/self/services/installation_manager.py +187 -0
- wl_cli/commands/self/services/local_installer.py +165 -0
- wl_cli/commands/self/services/pypi_updater.py +278 -0
- wl_cli/commands/self/services/windows_update.py +174 -0
- wl_cli/commands/testcommands/__init__.py +8 -0
- wl_cli/commands/testcommands/base_dependency_analyzer.py +198 -0
- wl_cli/commands/testcommands/batch_processor.py +278 -0
- wl_cli/commands/testcommands/cache_manager.py +1156 -0
- wl_cli/commands/testcommands/coverage_analyzer.py +955 -0
- wl_cli/commands/testcommands/coverage_cache_manager.py +183 -0
- wl_cli/commands/testcommands/progress_manager.py +79 -0
- wl_cli/commands/testcommands/python_env_detector.py +207 -0
- wl_cli/commands/testcommands/report_generator.py +101 -0
- wl_cli/commands/testcommands/single_test_executor.py +250 -0
- wl_cli/commands/testcommands/speed_checker.py +118 -0
- wl_cli/commands/testcommands/test_command_builder.py +165 -0
- wl_cli/commands/testcommands/test_commands.py +786 -0
- wl_cli/commands/testcommands/test_reporter.py +181 -0
- wl_cli/commands/testcommands/test_result_handler.py +96 -0
- wl_cli/commands/testcommands/test_runner.py +1343 -0
- wl_cli/commands/testcommands/test_utils.py +197 -0
- wl_cli/commands/testcommands/utils/__init__.py +17 -0
- wl_cli/commands/testcommands/utils/coverage_parser.py +98 -0
- wl_cli/commands/testcommands/utils/env_info.py +36 -0
- wl_cli/commands/testcommands/utils/junit_parser.py +75 -0
- wl_cli/commands/testcommands/utils/markdown_utils.py +20 -0
- wl_cli/commands/testcommands/utils/project_root.py +61 -0
- wl_cli/commands/update/__init__.py +9 -0
- wl_cli/commands/update/services/__init__.py +21 -0
- wl_cli/commands/update/services/backup_manager.py +207 -0
- wl_cli/commands/update/services/component_manager.py +83 -0
- wl_cli/commands/update/services/conflict_resolver.py +189 -0
- wl_cli/commands/update/services/vendors_updater.py +85 -0
- wl_cli/commands/update/services/version_tracker.py +127 -0
- wl_cli/commands/update/update_command.py +270 -0
- wl_cli/commands/update/utils/__init__.py +3 -0
- wl_cli/commands/version/__init__.py +9 -0
- wl_cli/commands/version/version_command.py +155 -0
- wl_cli/commands/workflow.py +477 -0
- wl_cli/env/__init__.py +5 -0
- wl_cli/env/cli.py +167 -0
- wl_cli/env_status.py +157 -0
- wl_cli/envelope.py +91 -0
- wl_cli/exceptions.py +85 -0
- wl_cli/lib/__init__.py +3 -0
- wl_cli/lib/rust_utils.py +35 -0
- wl_cli/mcp/__init__.py +6 -0
- wl_cli/mcp/capabilities.py +19 -0
- wl_cli/mcp/cli.py +22 -0
- wl_cli/mcp/gitlab.py +553 -0
- wl_cli/mcp/k8s.py +344 -0
- wl_cli/mcp/protocol.py +159 -0
- wl_cli/platform.py +139 -0
- wl_cli/quality/__init__.py +18 -0
- wl_cli/utils/__init__.py +7 -0
- wl_cli/utils/cache.py +491 -0
- wl_cli/utils/cache_ledger.py +355 -0
- wl_cli/utils/cache_store.py +546 -0
- wl_cli/utils/config/__init__.py +27 -0
- wl_cli/utils/config/config_loader.py +163 -0
- wl_cli/utils/config/core.py +862 -0
- wl_cli/utils/config/defaults.py +142 -0
- wl_cli/utils/config/env_exporter.py +86 -0
- wl_cli/utils/config/env_parser.py +108 -0
- wl_cli/utils/config/validation.py +280 -0
- wl_cli/utils/error_handler.py +165 -0
- wl_cli/utils/file_copier.py +56 -0
- wl_cli/utils/file_operations.py +506 -0
- wl_cli/utils/language_utils.py +122 -0
- wl_cli/utils/lazy_import.py +240 -0
- wl_cli/utils/log_levels.py +35 -0
- wl_cli/utils/log_rotators.py +74 -0
- wl_cli/utils/logging.py +458 -0
- wl_cli/utils/output_context.py +142 -0
- wl_cli/utils/performance/__init__.py +1 -0
- wl_cli/utils/performance/decorators.py +111 -0
- wl_cli/utils/performance/exporter.py +38 -0
- wl_cli/utils/performance/models.py +16 -0
- wl_cli/utils/performance/monitor.py +161 -0
- wl_cli/utils/performance_monitor.py +32 -0
- wl_cli/utils/platform_adapter.py +131 -0
- wl_cli/utils/pre_commit.py +266 -0
- wl_cli/utils/pre_commit_config.py +72 -0
- wl_cli/utils/root.py +521 -0
- wl_cli/utils/root_fallback.py +256 -0
- wl_cli/utils/run_evidence.py +236 -0
- wl_cli/utils/structured_logger.py +18 -0
- wl_cli/utils/structured_logging/__init__.py +19 -0
- wl_cli/utils/structured_logging/config.py +337 -0
- wl_cli/utils/structured_logging/core.py +177 -0
- wl_cli/utils/structured_logging/env.py +56 -0
- wl_cli/utils/structured_logging/filtering.py +75 -0
- wl_cli/utils/structured_logging/filters.py +55 -0
- wl_cli/utils/structured_logging/formatters.py +251 -0
- wl_cli/utils/structured_logging/handlers.py +308 -0
- wl_cli/utils/structured_logging/logger.py +354 -0
- wl_cli/utils/subprocess.py +71 -0
- wl_cli/utils/subprocess_utils.py +260 -0
- wl_cli/utils/test_utils/__init__.py +5 -0
- wl_cli/utils/test_utils/test_filter.py +36 -0
- wl_cli/utils/uv_run.py +79 -0
- wl_cli/utils/uv_tool.py +117 -0
- wl_cli/utils/uv_tree.py +71 -0
- wl_cli/utils/uv_workspace.py +174 -0
- wl_cli/utils/version/__init__.py +15 -0
- wl_cli/utils/version/pypi_version_checker.py +95 -0
- wl_cli/utils/version/version_comparator.py +133 -0
- wl_cli/utils/version/version_detectors.py +312 -0
- wl_cli/utils/version/version_service.py +131 -0
- wl_cli/utils/version/version_updater.py +198 -0
- wl_cli/utils/wl_dir_updater.py +1440 -0
- wl_cli/utils/workspace_detector/__init__.py +28 -0
- wl_cli/utils/workspace_detector/core.py +161 -0
- wl_cli/utils/workspace_detector/detection_strategies.py +107 -0
- wl_cli/utils/workspace_detector/exceptions.py +5 -0
- wl_cli/utils/workspace_detector/types.py +17 -0
- wl_cli/utils/workspace_detector/venv_resolver.py +84 -0
- wl_cli/vendors/config/.codespellrc +2 -0
- wl_cli/vendors/config/dependencies.toml +10 -0
- wl_cli/vendors/config/mypy.ini +62 -0
- wl_cli/vendors/config/pyproject_defaults.toml +28 -0
- wl_cli/vendors/config/template_config.toml +24 -0
- wl_cli/vendors/config/tool_configs.toml +76 -0
- wl_cli/vendors/git/.gitignore +122 -0
- wl_cli/vendors/git/.pre-commit-config.yaml +37 -0
- wl_cli/vendors/hooks/applypatch-msg.sample +15 -0
- wl_cli/vendors/hooks/commit-msg.sample +24 -0
- wl_cli/vendors/hooks/fsmonitor-watchman.sample +174 -0
- wl_cli/vendors/hooks/post-update.sample +8 -0
- wl_cli/vendors/hooks/pre-applypatch.sample +14 -0
- wl_cli/vendors/hooks/pre-commit +45 -0
- wl_cli/vendors/hooks/pre-commit.legacy +31 -0
- wl_cli/vendors/hooks/pre-commit.sample +49 -0
- wl_cli/vendors/hooks/pre-merge-commit.sample +13 -0
- wl_cli/vendors/hooks/pre-push.sample +60 -0
- wl_cli/vendors/hooks/pre-rebase.sample +169 -0
- wl_cli/vendors/hooks/pre-receive.sample +24 -0
- wl_cli/vendors/hooks/prepare-commit-msg.sample +42 -0
- wl_cli/vendors/hooks/push-to-checkout.sample +78 -0
- wl_cli/vendors/hooks/sendemail-validate.sample +77 -0
- wl_cli/vendors/hooks/unix/applypatch-msg.sample +15 -0
- wl_cli/vendors/hooks/unix/check_lockfile.py +58 -0
- wl_cli/vendors/hooks/unix/check_lockfile.sh +36 -0
- wl_cli/vendors/hooks/unix/commit-msg.sample +24 -0
- wl_cli/vendors/hooks/unix/fsmonitor-watchman.sample +174 -0
- wl_cli/vendors/hooks/unix/post-update.sample +8 -0
- wl_cli/vendors/hooks/unix/pre-applypatch.sample +14 -0
- wl_cli/vendors/hooks/unix/pre-commit +190 -0
- wl_cli/vendors/hooks/unix/pre-commit.legacy +31 -0
- wl_cli/vendors/hooks/unix/pre-commit.py +81 -0
- wl_cli/vendors/hooks/unix/pre-commit.sample +49 -0
- wl_cli/vendors/hooks/unix/pre-merge-commit.sample +13 -0
- wl_cli/vendors/hooks/unix/pre-push +417 -0
- wl_cli/vendors/hooks/unix/pre-push.sample +53 -0
- wl_cli/vendors/hooks/unix/pre-rebase.sample +169 -0
- wl_cli/vendors/hooks/unix/pre-receive.sample +24 -0
- wl_cli/vendors/hooks/unix/prepare-commit-msg.sample +42 -0
- wl_cli/vendors/hooks/update.sample +128 -0
- wl_cli/vendors/hooks/win/work.bat +63 -0
- wl_cli/vendors/project/.gitignore +90 -0
- wl_cli/vendors/project/.python-version +1 -0
- wl_cli/vendors/project/LICENSE +21 -0
- wl_cli/vendors/project/README.md +0 -0
- wl_cli/vendors/project/pyproject.toml +27 -0
- wl_cli/vendors/project/pyproject_no_workspace.toml +16 -0
- wl_cli/vendors/project/pyproject_workspace.toml +14 -0
- wl_cli/vendors/readme/README.md +120 -0
- wl_cli/vendors/rust/Cargo.toml +14 -0
- wl_cli/vendors/rust/lib/__init__.py +3 -0
- wl_cli/vendors/rust/lib/rust_utils.py +31 -0
- wl_cli/vendors/rust/src/lib.rs +13 -0
- wl_cli/vendors/rust/src/main.rs +3 -0
- wl_cli/vendors/rust/tests/test_rust_fallback.py +40 -0
- wl_cli/vendors/sop_doc/git-dev-sop.md +220 -0
- wl_cli/vendors/sop_doc/industrial-standard-sop.md +145 -0
- wl_cli/vendors/sop_doc/python-sop.md +342 -0
- wl_cli/vendors/sop_doc/rust-readme.md +219 -0
|
@@ -0,0 +1,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."""
|