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,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
|