multi-workspace 3.2.18__tar.gz → 3.2.20__tar.gz
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.
- {multi_workspace-3.2.18 → multi_workspace-3.2.20}/PKG-INFO +1 -1
- {multi_workspace-3.2.18 → multi_workspace-3.2.20}/multi/_version.py +2 -2
- {multi_workspace-3.2.18 → multi_workspace-3.2.20}/multi/cli.py +10 -0
- {multi_workspace-3.2.18 → multi_workspace-3.2.20}/multi/doctor.py +29 -0
- multi_workspace-3.2.20/multi/hooks.py +358 -0
- {multi_workspace-3.2.18 → multi_workspace-3.2.20}/multi/sync.py +2 -0
- {multi_workspace-3.2.18 → multi_workspace-3.2.20}/.gitignore +0 -0
- {multi_workspace-3.2.18 → multi_workspace-3.2.20}/LICENSE +0 -0
- {multi_workspace-3.2.18 → multi_workspace-3.2.20}/README.md +0 -0
- {multi_workspace-3.2.18 → multi_workspace-3.2.20}/multi/__init__.py +0 -0
- {multi_workspace-3.2.18 → multi_workspace-3.2.20}/multi/__main__.py +0 -0
- {multi_workspace-3.2.18 → multi_workspace-3.2.20}/multi/add.py +0 -0
- {multi_workspace-3.2.18 → multi_workspace-3.2.20}/multi/api.py +0 -0
- {multi_workspace-3.2.18 → multi_workspace-3.2.20}/multi/app_api.py +0 -0
- {multi_workspace-3.2.18 → multi_workspace-3.2.20}/multi/bootstrap.py +0 -0
- {multi_workspace-3.2.18 → multi_workspace-3.2.20}/multi/branch.py +0 -0
- {multi_workspace-3.2.18 → multi_workspace-3.2.20}/multi/cli_helpers.py +0 -0
- {multi_workspace-3.2.18 → multi_workspace-3.2.20}/multi/collaborator.py +0 -0
- {multi_workspace-3.2.18 → multi_workspace-3.2.20}/multi/convert_monorepo.py +0 -0
- {multi_workspace-3.2.18 → multi_workspace-3.2.20}/multi/errors.py +0 -0
- {multi_workspace-3.2.18 → multi_workspace-3.2.20}/multi/git_helpers.py +0 -0
- {multi_workspace-3.2.18 → multi_workspace-3.2.20}/multi/git_run.py +0 -0
- {multi_workspace-3.2.18 → multi_workspace-3.2.20}/multi/git_set_branch.py +0 -0
- {multi_workspace-3.2.18 → multi_workspace-3.2.20}/multi/ignore_files.py +0 -0
- {multi_workspace-3.2.18 → multi_workspace-3.2.20}/multi/init.py +0 -0
- {multi_workspace-3.2.18 → multi_workspace-3.2.20}/multi/logging.py +0 -0
- {multi_workspace-3.2.18 → multi_workspace-3.2.20}/multi/managed_blocks.py +0 -0
- {multi_workspace-3.2.18 → multi_workspace-3.2.20}/multi/open.py +0 -0
- {multi_workspace-3.2.18 → multi_workspace-3.2.20}/multi/paths.py +0 -0
- {multi_workspace-3.2.18 → multi_workspace-3.2.20}/multi/registry.py +0 -0
- {multi_workspace-3.2.18 → multi_workspace-3.2.20}/multi/remove.py +0 -0
- {multi_workspace-3.2.18 → multi_workspace-3.2.20}/multi/repo_urls.py +0 -0
- {multi_workspace-3.2.18 → multi_workspace-3.2.20}/multi/repos.py +0 -0
- {multi_workspace-3.2.18 → multi_workspace-3.2.20}/multi/resources/init_readme.md +0 -0
- {multi_workspace-3.2.18 → multi_workspace-3.2.20}/multi/service.py +0 -0
- {multi_workspace-3.2.18 → multi_workspace-3.2.20}/multi/settings.py +0 -0
- {multi_workspace-3.2.18 → multi_workspace-3.2.20}/multi/sync_agents.py +0 -0
- {multi_workspace-3.2.18 → multi_workspace-3.2.20}/multi/sync_github.py +0 -0
- {multi_workspace-3.2.18 → multi_workspace-3.2.20}/multi/sync_vscode.py +0 -0
- {multi_workspace-3.2.18 → multi_workspace-3.2.20}/multi/sync_vscode_devcontainer.py +0 -0
- {multi_workspace-3.2.18 → multi_workspace-3.2.20}/multi/sync_vscode_extensions.py +0 -0
- {multi_workspace-3.2.18 → multi_workspace-3.2.20}/multi/sync_vscode_helpers.py +0 -0
- {multi_workspace-3.2.18 → multi_workspace-3.2.20}/multi/sync_vscode_launch.py +0 -0
- {multi_workspace-3.2.18 → multi_workspace-3.2.20}/multi/sync_vscode_settings.py +0 -0
- {multi_workspace-3.2.18 → multi_workspace-3.2.20}/multi/sync_vscode_tasks.py +0 -0
- {multi_workspace-3.2.18 → multi_workspace-3.2.20}/multi/utils.py +0 -0
- {multi_workspace-3.2.18 → multi_workspace-3.2.20}/multi/worktree.py +0 -0
- {multi_workspace-3.2.18 → multi_workspace-3.2.20}/pyproject.toml +0 -0
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '3.2.
|
|
22
|
-
__version_tuple__ = version_tuple = (3, 2,
|
|
21
|
+
__version__ = version = '3.2.20'
|
|
22
|
+
__version_tuple__ = version_tuple = (3, 2, 20)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -9,6 +9,12 @@ from multi.convert_monorepo import convert_monorepo_cmd
|
|
|
9
9
|
from multi.doctor import doctor_cmd
|
|
10
10
|
from multi.git_run import git_cmd
|
|
11
11
|
from multi.git_set_branch import set_branch_cmd
|
|
12
|
+
from multi.hooks import (
|
|
13
|
+
hooks_cmd,
|
|
14
|
+
hooks_install_cmd,
|
|
15
|
+
hooks_status_cmd,
|
|
16
|
+
hooks_uninstall_cmd,
|
|
17
|
+
)
|
|
12
18
|
from multi.init import init_cmd
|
|
13
19
|
from multi.open import open_cmd
|
|
14
20
|
from multi.remove import remove_cmd
|
|
@@ -56,6 +62,10 @@ main.add_command(common_command_wrapper(doctor_cmd))
|
|
|
56
62
|
main.add_command(common_command_wrapper(convert_monorepo_cmd))
|
|
57
63
|
main.add_command(common_command_wrapper(remove_cmd))
|
|
58
64
|
main.add_command(worktree_cmd)
|
|
65
|
+
hooks_cmd.add_command(common_command_wrapper(hooks_install_cmd))
|
|
66
|
+
hooks_cmd.add_command(common_command_wrapper(hooks_status_cmd))
|
|
67
|
+
hooks_cmd.add_command(common_command_wrapper(hooks_uninstall_cmd))
|
|
68
|
+
main.add_command(hooks_cmd)
|
|
59
69
|
main.add_command(open_cmd)
|
|
60
70
|
main.add_command(service_cmd)
|
|
61
71
|
|
|
@@ -10,6 +10,7 @@ import git as gitmodule
|
|
|
10
10
|
|
|
11
11
|
from multi.errors import NoRepositoriesError
|
|
12
12
|
from multi.git_helpers import is_git_repo_root
|
|
13
|
+
from multi.hooks import PROBLEM_STATES, configured_hooks_path, hooks_status
|
|
13
14
|
from multi.paths import Paths
|
|
14
15
|
from multi.repos import Repository, load_repos
|
|
15
16
|
|
|
@@ -258,6 +259,33 @@ def warn_on_repo_disk_mismatches(
|
|
|
258
259
|
)
|
|
259
260
|
|
|
260
261
|
|
|
262
|
+
def warn_on_uninstalled_hooks(report: DoctorReport, paths: Paths) -> None:
|
|
263
|
+
try:
|
|
264
|
+
if configured_hooks_path(paths) is None:
|
|
265
|
+
return
|
|
266
|
+
except ValueError as exc:
|
|
267
|
+
report.errors.append(str(exc))
|
|
268
|
+
return
|
|
269
|
+
|
|
270
|
+
status = hooks_status(paths)
|
|
271
|
+
if not status.hooks_dir_exists:
|
|
272
|
+
report.warnings.append(
|
|
273
|
+
f"Workspace hooks directory {status.hooks_dir} does not exist; "
|
|
274
|
+
"git runs no hooks."
|
|
275
|
+
)
|
|
276
|
+
if status.non_executable:
|
|
277
|
+
report.warnings.append(
|
|
278
|
+
"Workspace hooks are not executable (git skips them): "
|
|
279
|
+
f"{', '.join(status.non_executable)}. Run `chmod +x` on them."
|
|
280
|
+
)
|
|
281
|
+
uninstalled = [repo.path for repo in status.repos if repo.state in PROBLEM_STATES]
|
|
282
|
+
if uninstalled:
|
|
283
|
+
report.warnings.append(
|
|
284
|
+
"Workspace hooks are not installed in: "
|
|
285
|
+
f"{', '.join(uninstalled)}. Run `multi hooks install`."
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
261
289
|
def run_doctor_checks(target_dir: Path) -> DoctorReport:
|
|
262
290
|
report = DoctorReport()
|
|
263
291
|
|
|
@@ -292,6 +320,7 @@ def run_doctor_checks(target_dir: Path) -> DoctorReport:
|
|
|
292
320
|
warn_on_tracked_subrepos(report, paths, repos)
|
|
293
321
|
warn_on_subrepo_submodules(report, paths, repos)
|
|
294
322
|
warn_on_repo_disk_mismatches(report, paths, repos)
|
|
323
|
+
warn_on_uninstalled_hooks(report, paths)
|
|
295
324
|
|
|
296
325
|
return report
|
|
297
326
|
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"""Workspace-level git hooks.
|
|
2
|
+
|
|
3
|
+
A workspace opts in with ``"hooks": {"path": ".githooks"}`` in multi.json.
|
|
4
|
+
Multi then points ``core.hooksPath`` of the root repo and of every sub-repo at
|
|
5
|
+
that one directory, so a single workspace-level definition governs every
|
|
6
|
+
commit and push in the workspace, its worktrees and fresh clones.
|
|
7
|
+
|
|
8
|
+
- The root repo gets the *relative* path. Git resolves a relative
|
|
9
|
+
``core.hooksPath`` against the working tree that runs the hook, and git
|
|
10
|
+
worktrees share the root repo's config, so every worktree of the root
|
|
11
|
+
(including ``multi worktree add`` worktrees) uses its own checkout's hooks.
|
|
12
|
+
- Sub-repos get the *absolute* path of the workspace's hooks directory. Each
|
|
13
|
+
workspace (and each multi worktree) clones its own sub-repos, so the value
|
|
14
|
+
is per workspace, and ad-hoc ``git worktree add`` checkouts of a sub-repo
|
|
15
|
+
outside the workspace still run the workspace hooks.
|
|
16
|
+
- Sub-repos that are symlinks to a clone owned by another workspace are left
|
|
17
|
+
alone: their owning workspace manages their hooks.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import logging
|
|
22
|
+
import os
|
|
23
|
+
import subprocess
|
|
24
|
+
from dataclasses import asdict, dataclass
|
|
25
|
+
from pathlib import Path, PurePosixPath
|
|
26
|
+
|
|
27
|
+
import click
|
|
28
|
+
|
|
29
|
+
from multi.errors import GitError
|
|
30
|
+
from multi.git_helpers import is_git_repo_root
|
|
31
|
+
from multi.paths import Paths
|
|
32
|
+
from multi.repos import load_repos
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
ROOT_NAME = "(root)"
|
|
37
|
+
|
|
38
|
+
# Hook names git runs from core.hooksPath; used to flag non-executable files,
|
|
39
|
+
# which git silently skips.
|
|
40
|
+
GIT_HOOK_NAMES = frozenset(
|
|
41
|
+
{
|
|
42
|
+
"applypatch-msg",
|
|
43
|
+
"pre-applypatch",
|
|
44
|
+
"post-applypatch",
|
|
45
|
+
"pre-commit",
|
|
46
|
+
"pre-merge-commit",
|
|
47
|
+
"prepare-commit-msg",
|
|
48
|
+
"commit-msg",
|
|
49
|
+
"post-commit",
|
|
50
|
+
"pre-rebase",
|
|
51
|
+
"post-checkout",
|
|
52
|
+
"post-merge",
|
|
53
|
+
"pre-push",
|
|
54
|
+
"pre-auto-gc",
|
|
55
|
+
"post-rewrite",
|
|
56
|
+
"reference-transaction",
|
|
57
|
+
"push-to-checkout",
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
STATE_OK = "installed"
|
|
62
|
+
STATE_MISSING = "missing"
|
|
63
|
+
STATE_MISMATCH = "mismatch"
|
|
64
|
+
STATE_NOT_CLONED = "not-cloned"
|
|
65
|
+
STATE_SYMLINKED = "symlinked"
|
|
66
|
+
PROBLEM_STATES = {STATE_MISSING, STATE_MISMATCH}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class RepoHookStatus:
|
|
71
|
+
name: str
|
|
72
|
+
path: str
|
|
73
|
+
state: str
|
|
74
|
+
expected: str | None
|
|
75
|
+
actual: str | None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class HooksStatus:
|
|
80
|
+
hooks_dir: str
|
|
81
|
+
hooks_dir_exists: bool
|
|
82
|
+
hooks: list[str]
|
|
83
|
+
non_executable: list[str]
|
|
84
|
+
repos: list[RepoHookStatus]
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def ok(self) -> bool:
|
|
88
|
+
return (
|
|
89
|
+
self.hooks_dir_exists
|
|
90
|
+
and not self.non_executable
|
|
91
|
+
and not any(repo.state in PROBLEM_STATES for repo in self.repos)
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def to_dict(self) -> dict:
|
|
95
|
+
result = asdict(self)
|
|
96
|
+
result["ok"] = self.ok
|
|
97
|
+
return result
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def configured_hooks_path(paths: Paths) -> str | None:
|
|
101
|
+
"""Return the workspace-relative hooks directory from multi.json, if any."""
|
|
102
|
+
hooks_settings = paths.settings.get("hooks")
|
|
103
|
+
if hooks_settings is None:
|
|
104
|
+
return None
|
|
105
|
+
if not isinstance(hooks_settings, dict):
|
|
106
|
+
raise ValueError('multi.json field "hooks" must be an object.')
|
|
107
|
+
|
|
108
|
+
raw = hooks_settings.get("path")
|
|
109
|
+
if raw is None:
|
|
110
|
+
return None
|
|
111
|
+
if not isinstance(raw, str) or not raw.strip():
|
|
112
|
+
raise ValueError('multi.json field "hooks.path" must be a non-empty string.')
|
|
113
|
+
posix = PurePosixPath(raw.strip())
|
|
114
|
+
if posix.is_absolute() or ".." in posix.parts:
|
|
115
|
+
raise ValueError(
|
|
116
|
+
'multi.json field "hooks.path" must be a relative path inside the '
|
|
117
|
+
"workspace (no leading '/' and no '..')."
|
|
118
|
+
)
|
|
119
|
+
return posix.as_posix()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _git_config_get(repo_path: Path, key: str) -> str | None:
|
|
123
|
+
result = subprocess.run(
|
|
124
|
+
["git", "config", "--get", key],
|
|
125
|
+
cwd=repo_path,
|
|
126
|
+
capture_output=True,
|
|
127
|
+
text=True,
|
|
128
|
+
check=False,
|
|
129
|
+
)
|
|
130
|
+
if result.returncode == 0:
|
|
131
|
+
return result.stdout.strip()
|
|
132
|
+
if result.returncode == 1:
|
|
133
|
+
return None
|
|
134
|
+
raise GitError(f"Could not read {key} in {repo_path}: {result.stderr.strip()}")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _git_config(repo_path: Path, *args: str) -> None:
|
|
138
|
+
result = subprocess.run(
|
|
139
|
+
["git", "config", *args],
|
|
140
|
+
cwd=repo_path,
|
|
141
|
+
capture_output=True,
|
|
142
|
+
text=True,
|
|
143
|
+
check=False,
|
|
144
|
+
)
|
|
145
|
+
if result.returncode != 0:
|
|
146
|
+
raise GitError(
|
|
147
|
+
f"Failed to run git config {' '.join(args)} in {repo_path}: "
|
|
148
|
+
f"{result.stderr.strip()}"
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _targets(paths: Paths) -> list[tuple[str, Path, bool]]:
|
|
153
|
+
"""Return (name, path, is_root) for the root and every declared sub-repo."""
|
|
154
|
+
targets = [(ROOT_NAME, paths.root_dir, True)]
|
|
155
|
+
if not paths.settings.is_monorepo():
|
|
156
|
+
targets.extend((repo.name, repo.path, False) for repo in load_repos(paths))
|
|
157
|
+
return targets
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _expected_value(paths: Paths, relative_hooks_path: str, is_root: bool) -> str:
|
|
161
|
+
if is_root:
|
|
162
|
+
return relative_hooks_path
|
|
163
|
+
return str((paths.root_dir / relative_hooks_path).resolve())
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _require_hooks_path(paths: Paths) -> str:
|
|
167
|
+
relative_hooks_path = configured_hooks_path(paths)
|
|
168
|
+
if relative_hooks_path is None:
|
|
169
|
+
raise click.UsageError(
|
|
170
|
+
'No workspace hooks configured. Add "hooks": {"path": ".githooks"} '
|
|
171
|
+
"to multi.json."
|
|
172
|
+
)
|
|
173
|
+
return relative_hooks_path
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _list_hooks(hooks_dir: Path) -> tuple[list[str], list[str]]:
|
|
177
|
+
if not hooks_dir.is_dir():
|
|
178
|
+
return [], []
|
|
179
|
+
hooks = sorted(
|
|
180
|
+
entry.name
|
|
181
|
+
for entry in hooks_dir.iterdir()
|
|
182
|
+
if entry.is_file() and entry.name in GIT_HOOK_NAMES
|
|
183
|
+
)
|
|
184
|
+
non_executable = [
|
|
185
|
+
name for name in hooks if not os.access(hooks_dir / name, os.X_OK)
|
|
186
|
+
]
|
|
187
|
+
return hooks, non_executable
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def hooks_status(paths: Paths) -> HooksStatus:
|
|
191
|
+
relative_hooks_path = _require_hooks_path(paths)
|
|
192
|
+
hooks_dir = paths.root_dir / relative_hooks_path
|
|
193
|
+
hooks, non_executable = _list_hooks(hooks_dir)
|
|
194
|
+
|
|
195
|
+
repos: list[RepoHookStatus] = []
|
|
196
|
+
for name, repo_path, is_root in _targets(paths):
|
|
197
|
+
expected = _expected_value(paths, relative_hooks_path, is_root)
|
|
198
|
+
display_path = "." if is_root else str(repo_path.relative_to(paths.root_dir))
|
|
199
|
+
if not is_root and repo_path.is_symlink():
|
|
200
|
+
state, expected, actual = STATE_SYMLINKED, None, None
|
|
201
|
+
elif not is_git_repo_root(repo_path):
|
|
202
|
+
state, expected, actual = STATE_NOT_CLONED, None, None
|
|
203
|
+
else:
|
|
204
|
+
actual = _git_config_get(repo_path, "core.hooksPath")
|
|
205
|
+
if actual is None:
|
|
206
|
+
state = STATE_MISSING
|
|
207
|
+
elif actual == expected:
|
|
208
|
+
state = STATE_OK
|
|
209
|
+
else:
|
|
210
|
+
state = STATE_MISMATCH
|
|
211
|
+
repos.append(
|
|
212
|
+
RepoHookStatus(
|
|
213
|
+
name=name,
|
|
214
|
+
path=display_path,
|
|
215
|
+
state=state,
|
|
216
|
+
expected=expected,
|
|
217
|
+
actual=actual,
|
|
218
|
+
)
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
return HooksStatus(
|
|
222
|
+
hooks_dir=relative_hooks_path,
|
|
223
|
+
hooks_dir_exists=hooks_dir.is_dir(),
|
|
224
|
+
hooks=hooks,
|
|
225
|
+
non_executable=non_executable,
|
|
226
|
+
repos=repos,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def install_hooks(paths: Paths) -> list[str]:
|
|
231
|
+
"""Point core.hooksPath of the root and every cloned sub-repo at the
|
|
232
|
+
workspace hooks directory. Returns the names of repos that changed.
|
|
233
|
+
|
|
234
|
+
A no-op when multi.json does not configure ``hooks.path``.
|
|
235
|
+
"""
|
|
236
|
+
relative_hooks_path = configured_hooks_path(paths)
|
|
237
|
+
if relative_hooks_path is None:
|
|
238
|
+
return []
|
|
239
|
+
|
|
240
|
+
hooks_dir = paths.root_dir / relative_hooks_path
|
|
241
|
+
if not hooks_dir.is_dir():
|
|
242
|
+
logger.warning(
|
|
243
|
+
f"Workspace hooks directory {relative_hooks_path} does not exist yet; "
|
|
244
|
+
"git runs no hooks until it does."
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
changed: list[str] = []
|
|
248
|
+
for name, repo_path, is_root in _targets(paths):
|
|
249
|
+
if not is_root and repo_path.is_symlink():
|
|
250
|
+
logger.debug(f"Skipping hooks for symlinked repo {name}")
|
|
251
|
+
continue
|
|
252
|
+
if not is_git_repo_root(repo_path):
|
|
253
|
+
continue
|
|
254
|
+
expected = _expected_value(paths, relative_hooks_path, is_root)
|
|
255
|
+
actual = _git_config_get(repo_path, "core.hooksPath")
|
|
256
|
+
if actual == expected:
|
|
257
|
+
continue
|
|
258
|
+
if actual is not None:
|
|
259
|
+
logger.warning(
|
|
260
|
+
f"{name}: replacing core.hooksPath '{actual}' with the workspace "
|
|
261
|
+
f"hooks. Chain repo-local hooks from the workspace hooks instead."
|
|
262
|
+
)
|
|
263
|
+
_git_config(repo_path, "core.hooksPath", expected)
|
|
264
|
+
changed.append(name)
|
|
265
|
+
|
|
266
|
+
if changed:
|
|
267
|
+
logger.info(
|
|
268
|
+
f"🪝 Installed workspace hooks ({relative_hooks_path}) in: "
|
|
269
|
+
f"{', '.join(changed)}"
|
|
270
|
+
)
|
|
271
|
+
return changed
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def uninstall_hooks(paths: Paths) -> list[str]:
|
|
275
|
+
"""Unset core.hooksPath wherever it points at the workspace hooks."""
|
|
276
|
+
relative_hooks_path = _require_hooks_path(paths)
|
|
277
|
+
removed: list[str] = []
|
|
278
|
+
for name, repo_path, is_root in _targets(paths):
|
|
279
|
+
if not is_root and repo_path.is_symlink():
|
|
280
|
+
continue
|
|
281
|
+
if not is_git_repo_root(repo_path):
|
|
282
|
+
continue
|
|
283
|
+
expected = _expected_value(paths, relative_hooks_path, is_root)
|
|
284
|
+
if _git_config_get(repo_path, "core.hooksPath") == expected:
|
|
285
|
+
_git_config(repo_path, "--unset", "core.hooksPath")
|
|
286
|
+
removed.append(name)
|
|
287
|
+
return removed
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _print_status(status: HooksStatus) -> None:
|
|
291
|
+
dir_note = "" if status.hooks_dir_exists else " (MISSING)"
|
|
292
|
+
click.echo(f"Hooks directory: {status.hooks_dir}{dir_note}")
|
|
293
|
+
click.echo(f"Hooks: {', '.join(status.hooks) or '(none)'}")
|
|
294
|
+
if status.non_executable:
|
|
295
|
+
click.secho(
|
|
296
|
+
f"Not executable (git skips them): {', '.join(status.non_executable)}",
|
|
297
|
+
fg="red",
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
width = max(len(repo.path) for repo in status.repos)
|
|
301
|
+
colors = {STATE_OK: "green", STATE_MISSING: "red", STATE_MISMATCH: "red"}
|
|
302
|
+
for repo in status.repos:
|
|
303
|
+
line = f" {repo.path:<{width}} {repo.state}"
|
|
304
|
+
if repo.state == STATE_MISMATCH:
|
|
305
|
+
line += f" (core.hooksPath={repo.actual}, expected {repo.expected})"
|
|
306
|
+
click.secho(line, fg=colors.get(repo.state))
|
|
307
|
+
|
|
308
|
+
if status.ok:
|
|
309
|
+
click.secho(
|
|
310
|
+
"✅ Workspace hooks are installed in every cloned repo.", fg="green"
|
|
311
|
+
)
|
|
312
|
+
else:
|
|
313
|
+
click.secho(
|
|
314
|
+
"❌ Workspace hooks are not fully installed. Run `multi hooks install`.",
|
|
315
|
+
fg="red",
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
@click.group(name="hooks")
|
|
320
|
+
def hooks_cmd() -> None:
|
|
321
|
+
"""Manage workspace-level git hooks (multi.json "hooks.path").
|
|
322
|
+
|
|
323
|
+
One hooks directory at the workspace root governs the root repo and every
|
|
324
|
+
sub-repo. `multi sync`, `multi init` and `multi worktree add` install it
|
|
325
|
+
automatically.
|
|
326
|
+
"""
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
@click.command(name="install")
|
|
330
|
+
def hooks_install_cmd() -> None:
|
|
331
|
+
"""Point core.hooksPath of every repo at the workspace hooks directory."""
|
|
332
|
+
paths = Paths(Path.cwd())
|
|
333
|
+
_require_hooks_path(paths)
|
|
334
|
+
install_hooks(paths)
|
|
335
|
+
status = hooks_status(paths)
|
|
336
|
+
_print_status(status)
|
|
337
|
+
if not status.ok:
|
|
338
|
+
raise click.ClickException("Workspace hooks are not fully installed.")
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
@click.command(name="status")
|
|
342
|
+
@click.option("--json", "output_json", is_flag=True, help="Output JSON.")
|
|
343
|
+
def hooks_status_cmd(output_json: bool) -> None:
|
|
344
|
+
"""Report whether every repo uses the workspace hooks. Exits 1 if not."""
|
|
345
|
+
status = hooks_status(Paths(Path.cwd()))
|
|
346
|
+
if output_json:
|
|
347
|
+
click.echo(json.dumps(status.to_dict(), indent=2))
|
|
348
|
+
else:
|
|
349
|
+
_print_status(status)
|
|
350
|
+
if not status.ok:
|
|
351
|
+
raise SystemExit(1)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
@click.command(name="uninstall")
|
|
355
|
+
def hooks_uninstall_cmd() -> None:
|
|
356
|
+
"""Unset core.hooksPath wherever it points at the workspace hooks."""
|
|
357
|
+
removed = uninstall_hooks(Paths(Path.cwd()))
|
|
358
|
+
click.echo(f"Removed workspace hooks from: {', '.join(removed) or '(none)'}")
|
|
@@ -12,6 +12,7 @@ from multi.bootstrap import ensure_root_git_repo, ensure_workspace_readme
|
|
|
12
12
|
from multi.cli_helpers import common_command_wrapper
|
|
13
13
|
from multi.doctor import find_nested_git_repos_in_monorepo
|
|
14
14
|
from multi.git_helpers import expected_branch_for_repo, get_current_branch
|
|
15
|
+
from multi.hooks import install_hooks
|
|
15
16
|
from multi.ignore_files import (
|
|
16
17
|
update_gitignore_with_generated_files,
|
|
17
18
|
update_gitignore_with_repos,
|
|
@@ -214,6 +215,7 @@ def sync(
|
|
|
214
215
|
if not paths.settings.is_monorepo():
|
|
215
216
|
clone_repos(paths=paths, ensure_on_same_branch=ensure_on_same_branch)
|
|
216
217
|
|
|
218
|
+
install_hooks(paths=paths)
|
|
217
219
|
update_gitignore_with_generated_files(paths=paths)
|
|
218
220
|
merge_vscode_configs(root_dir=root_dir, install_set=install_set)
|
|
219
221
|
sync_all_agents(root_dir=root_dir, install_set=install_set)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|