multi-workspace 3.2.18__tar.gz → 3.2.19__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.
Files changed (48) hide show
  1. {multi_workspace-3.2.18 → multi_workspace-3.2.19}/PKG-INFO +1 -1
  2. {multi_workspace-3.2.18 → multi_workspace-3.2.19}/multi/_version.py +2 -2
  3. {multi_workspace-3.2.18 → multi_workspace-3.2.19}/multi/cli.py +10 -0
  4. multi_workspace-3.2.19/multi/hooks.py +358 -0
  5. {multi_workspace-3.2.18 → multi_workspace-3.2.19}/multi/sync.py +2 -0
  6. {multi_workspace-3.2.18 → multi_workspace-3.2.19}/.gitignore +0 -0
  7. {multi_workspace-3.2.18 → multi_workspace-3.2.19}/LICENSE +0 -0
  8. {multi_workspace-3.2.18 → multi_workspace-3.2.19}/README.md +0 -0
  9. {multi_workspace-3.2.18 → multi_workspace-3.2.19}/multi/__init__.py +0 -0
  10. {multi_workspace-3.2.18 → multi_workspace-3.2.19}/multi/__main__.py +0 -0
  11. {multi_workspace-3.2.18 → multi_workspace-3.2.19}/multi/add.py +0 -0
  12. {multi_workspace-3.2.18 → multi_workspace-3.2.19}/multi/api.py +0 -0
  13. {multi_workspace-3.2.18 → multi_workspace-3.2.19}/multi/app_api.py +0 -0
  14. {multi_workspace-3.2.18 → multi_workspace-3.2.19}/multi/bootstrap.py +0 -0
  15. {multi_workspace-3.2.18 → multi_workspace-3.2.19}/multi/branch.py +0 -0
  16. {multi_workspace-3.2.18 → multi_workspace-3.2.19}/multi/cli_helpers.py +0 -0
  17. {multi_workspace-3.2.18 → multi_workspace-3.2.19}/multi/collaborator.py +0 -0
  18. {multi_workspace-3.2.18 → multi_workspace-3.2.19}/multi/convert_monorepo.py +0 -0
  19. {multi_workspace-3.2.18 → multi_workspace-3.2.19}/multi/doctor.py +0 -0
  20. {multi_workspace-3.2.18 → multi_workspace-3.2.19}/multi/errors.py +0 -0
  21. {multi_workspace-3.2.18 → multi_workspace-3.2.19}/multi/git_helpers.py +0 -0
  22. {multi_workspace-3.2.18 → multi_workspace-3.2.19}/multi/git_run.py +0 -0
  23. {multi_workspace-3.2.18 → multi_workspace-3.2.19}/multi/git_set_branch.py +0 -0
  24. {multi_workspace-3.2.18 → multi_workspace-3.2.19}/multi/ignore_files.py +0 -0
  25. {multi_workspace-3.2.18 → multi_workspace-3.2.19}/multi/init.py +0 -0
  26. {multi_workspace-3.2.18 → multi_workspace-3.2.19}/multi/logging.py +0 -0
  27. {multi_workspace-3.2.18 → multi_workspace-3.2.19}/multi/managed_blocks.py +0 -0
  28. {multi_workspace-3.2.18 → multi_workspace-3.2.19}/multi/open.py +0 -0
  29. {multi_workspace-3.2.18 → multi_workspace-3.2.19}/multi/paths.py +0 -0
  30. {multi_workspace-3.2.18 → multi_workspace-3.2.19}/multi/registry.py +0 -0
  31. {multi_workspace-3.2.18 → multi_workspace-3.2.19}/multi/remove.py +0 -0
  32. {multi_workspace-3.2.18 → multi_workspace-3.2.19}/multi/repo_urls.py +0 -0
  33. {multi_workspace-3.2.18 → multi_workspace-3.2.19}/multi/repos.py +0 -0
  34. {multi_workspace-3.2.18 → multi_workspace-3.2.19}/multi/resources/init_readme.md +0 -0
  35. {multi_workspace-3.2.18 → multi_workspace-3.2.19}/multi/service.py +0 -0
  36. {multi_workspace-3.2.18 → multi_workspace-3.2.19}/multi/settings.py +0 -0
  37. {multi_workspace-3.2.18 → multi_workspace-3.2.19}/multi/sync_agents.py +0 -0
  38. {multi_workspace-3.2.18 → multi_workspace-3.2.19}/multi/sync_github.py +0 -0
  39. {multi_workspace-3.2.18 → multi_workspace-3.2.19}/multi/sync_vscode.py +0 -0
  40. {multi_workspace-3.2.18 → multi_workspace-3.2.19}/multi/sync_vscode_devcontainer.py +0 -0
  41. {multi_workspace-3.2.18 → multi_workspace-3.2.19}/multi/sync_vscode_extensions.py +0 -0
  42. {multi_workspace-3.2.18 → multi_workspace-3.2.19}/multi/sync_vscode_helpers.py +0 -0
  43. {multi_workspace-3.2.18 → multi_workspace-3.2.19}/multi/sync_vscode_launch.py +0 -0
  44. {multi_workspace-3.2.18 → multi_workspace-3.2.19}/multi/sync_vscode_settings.py +0 -0
  45. {multi_workspace-3.2.18 → multi_workspace-3.2.19}/multi/sync_vscode_tasks.py +0 -0
  46. {multi_workspace-3.2.18 → multi_workspace-3.2.19}/multi/utils.py +0 -0
  47. {multi_workspace-3.2.18 → multi_workspace-3.2.19}/multi/worktree.py +0 -0
  48. {multi_workspace-3.2.18 → multi_workspace-3.2.19}/pyproject.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: multi-workspace
3
- Version: 3.2.18
3
+ Version: 3.2.19
4
4
  Summary: Multi
5
5
  Project-URL: Repository, https://github.com/gabemontague/multi
6
6
  Project-URL: Issues, https://github.com/gabemontague/multi/issues
@@ -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.18'
22
- __version_tuple__ = version_tuple = (3, 2, 18)
21
+ __version__ = version = '3.2.19'
22
+ __version_tuple__ = version_tuple = (3, 2, 19)
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
 
@@ -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)