multi-workspace 3.2.3__tar.gz → 3.2.5__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 (46) hide show
  1. {multi_workspace-3.2.3 → multi_workspace-3.2.5}/PKG-INFO +4 -1
  2. {multi_workspace-3.2.3 → multi_workspace-3.2.5}/README.md +3 -0
  3. {multi_workspace-3.2.3 → multi_workspace-3.2.5}/multi/_version.py +2 -2
  4. {multi_workspace-3.2.3 → multi_workspace-3.2.5}/multi/collaborator.py +133 -0
  5. {multi_workspace-3.2.3 → multi_workspace-3.2.5}/multi/worktree.py +78 -11
  6. {multi_workspace-3.2.3 → multi_workspace-3.2.5}/.gitignore +0 -0
  7. {multi_workspace-3.2.3 → multi_workspace-3.2.5}/LICENSE +0 -0
  8. {multi_workspace-3.2.3 → multi_workspace-3.2.5}/multi/__init__.py +0 -0
  9. {multi_workspace-3.2.3 → multi_workspace-3.2.5}/multi/__main__.py +0 -0
  10. {multi_workspace-3.2.3 → multi_workspace-3.2.5}/multi/add.py +0 -0
  11. {multi_workspace-3.2.3 → multi_workspace-3.2.5}/multi/api.py +0 -0
  12. {multi_workspace-3.2.3 → multi_workspace-3.2.5}/multi/app_api.py +0 -0
  13. {multi_workspace-3.2.3 → multi_workspace-3.2.5}/multi/bootstrap.py +0 -0
  14. {multi_workspace-3.2.3 → multi_workspace-3.2.5}/multi/cli.py +0 -0
  15. {multi_workspace-3.2.3 → multi_workspace-3.2.5}/multi/cli_helpers.py +0 -0
  16. {multi_workspace-3.2.3 → multi_workspace-3.2.5}/multi/convert_monorepo.py +0 -0
  17. {multi_workspace-3.2.3 → multi_workspace-3.2.5}/multi/doctor.py +0 -0
  18. {multi_workspace-3.2.3 → multi_workspace-3.2.5}/multi/errors.py +0 -0
  19. {multi_workspace-3.2.3 → multi_workspace-3.2.5}/multi/git_helpers.py +0 -0
  20. {multi_workspace-3.2.3 → multi_workspace-3.2.5}/multi/git_run.py +0 -0
  21. {multi_workspace-3.2.3 → multi_workspace-3.2.5}/multi/git_set_branch.py +0 -0
  22. {multi_workspace-3.2.3 → multi_workspace-3.2.5}/multi/ignore_files.py +0 -0
  23. {multi_workspace-3.2.3 → multi_workspace-3.2.5}/multi/init.py +0 -0
  24. {multi_workspace-3.2.3 → multi_workspace-3.2.5}/multi/logging.py +0 -0
  25. {multi_workspace-3.2.3 → multi_workspace-3.2.5}/multi/managed_blocks.py +0 -0
  26. {multi_workspace-3.2.3 → multi_workspace-3.2.5}/multi/open.py +0 -0
  27. {multi_workspace-3.2.3 → multi_workspace-3.2.5}/multi/paths.py +0 -0
  28. {multi_workspace-3.2.3 → multi_workspace-3.2.5}/multi/registry.py +0 -0
  29. {multi_workspace-3.2.3 → multi_workspace-3.2.5}/multi/remove.py +0 -0
  30. {multi_workspace-3.2.3 → multi_workspace-3.2.5}/multi/repo_urls.py +0 -0
  31. {multi_workspace-3.2.3 → multi_workspace-3.2.5}/multi/repos.py +0 -0
  32. {multi_workspace-3.2.3 → multi_workspace-3.2.5}/multi/resources/init_readme.md +0 -0
  33. {multi_workspace-3.2.3 → multi_workspace-3.2.5}/multi/service.py +0 -0
  34. {multi_workspace-3.2.3 → multi_workspace-3.2.5}/multi/settings.py +0 -0
  35. {multi_workspace-3.2.3 → multi_workspace-3.2.5}/multi/sync.py +0 -0
  36. {multi_workspace-3.2.3 → multi_workspace-3.2.5}/multi/sync_agents.py +0 -0
  37. {multi_workspace-3.2.3 → multi_workspace-3.2.5}/multi/sync_github.py +0 -0
  38. {multi_workspace-3.2.3 → multi_workspace-3.2.5}/multi/sync_vscode.py +0 -0
  39. {multi_workspace-3.2.3 → multi_workspace-3.2.5}/multi/sync_vscode_devcontainer.py +0 -0
  40. {multi_workspace-3.2.3 → multi_workspace-3.2.5}/multi/sync_vscode_extensions.py +0 -0
  41. {multi_workspace-3.2.3 → multi_workspace-3.2.5}/multi/sync_vscode_helpers.py +0 -0
  42. {multi_workspace-3.2.3 → multi_workspace-3.2.5}/multi/sync_vscode_launch.py +0 -0
  43. {multi_workspace-3.2.3 → multi_workspace-3.2.5}/multi/sync_vscode_settings.py +0 -0
  44. {multi_workspace-3.2.3 → multi_workspace-3.2.5}/multi/sync_vscode_tasks.py +0 -0
  45. {multi_workspace-3.2.3 → multi_workspace-3.2.5}/multi/utils.py +0 -0
  46. {multi_workspace-3.2.3 → multi_workspace-3.2.5}/pyproject.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: multi-workspace
3
- Version: 3.2.3
3
+ Version: 3.2.5
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
@@ -108,12 +108,15 @@ To grant or remove GitHub collaborator access across every GitHub repo in a work
108
108
  ```bash
109
109
  multi collaborator add octocat --permission maintain --yes
110
110
  multi collaborator add --yes
111
+ multi collaborator accept --yes
111
112
  multi collaborator remove octocat --yes
112
113
  multi collaborator recent-users
113
114
  ```
114
115
 
115
116
  When the workspace root repository has a GitHub `origin`, `multi collaborator` applies the change there too.
116
117
 
118
+ The invited collaborator can run `multi collaborator accept --yes` with their own `gh` authentication to accept pending invitations for the workspace repos.
119
+
117
120
  Recent collaborator usernames are saved under `~/.multi`, and `multi collaborator add` can prompt from that list when no username is supplied.
118
121
 
119
122
  If one repo fails, `multi collaborator` continues through the rest of the workspace and reports the failures at the end.
@@ -81,12 +81,15 @@ To grant or remove GitHub collaborator access across every GitHub repo in a work
81
81
  ```bash
82
82
  multi collaborator add octocat --permission maintain --yes
83
83
  multi collaborator add --yes
84
+ multi collaborator accept --yes
84
85
  multi collaborator remove octocat --yes
85
86
  multi collaborator recent-users
86
87
  ```
87
88
 
88
89
  When the workspace root repository has a GitHub `origin`, `multi collaborator` applies the change there too.
89
90
 
91
+ The invited collaborator can run `multi collaborator accept --yes` with their own `gh` authentication to accept pending invitations for the workspace repos.
92
+
90
93
  Recent collaborator usernames are saved under `~/.multi`, and `multi collaborator add` can prompt from that list when no username is supplied.
91
94
 
92
95
  If one repo fails, `multi collaborator` continues through the rest of the workspace and reports the failures at the end.
@@ -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.3'
22
- __version_tuple__ = version_tuple = (3, 2, 3)
21
+ __version__ = version = '3.2.5'
22
+ __version_tuple__ = version_tuple = (3, 2, 5)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -135,11 +135,17 @@ def _run_gh_api(
135
135
  method: str,
136
136
  endpoint: str,
137
137
  fields: dict[str, str] | None = None,
138
+ paginate: bool = False,
139
+ slurp: bool = False,
138
140
  ) -> subprocess.CompletedProcess[str]:
139
141
  cmd = [gh_path, "api", "--method", method, endpoint]
140
142
  if fields:
141
143
  for key, value in fields.items():
142
144
  cmd.extend(["-f", f"{key}={value}"])
145
+ if paginate:
146
+ cmd.append("--paginate")
147
+ if slurp:
148
+ cmd.append("--slurp")
143
149
 
144
150
  try:
145
151
  return subprocess.run(
@@ -155,6 +161,57 @@ def _run_gh_api(
155
161
  raise click.ClickException(message) from exc
156
162
 
157
163
 
164
+ def _load_pending_repository_invitations(gh_path: str) -> list[dict[str, object]]:
165
+ result = _run_gh_api(
166
+ gh_path,
167
+ method="GET",
168
+ endpoint="user/repository_invitations?per_page=100",
169
+ paginate=True,
170
+ slurp=True,
171
+ )
172
+
173
+ try:
174
+ invitations = json.loads(result.stdout or "[]")
175
+ except json.JSONDecodeError as exc:
176
+ raise click.ClickException(
177
+ "GitHub returned invalid repository invitation data."
178
+ ) from exc
179
+
180
+ if not isinstance(invitations, list):
181
+ raise click.ClickException(
182
+ "GitHub returned invalid repository invitation data."
183
+ )
184
+
185
+ if all(isinstance(page, list) for page in invitations):
186
+ return [
187
+ invitation
188
+ for page in invitations
189
+ for invitation in page
190
+ if isinstance(invitation, dict)
191
+ ]
192
+
193
+ return [invitation for invitation in invitations if isinstance(invitation, dict)]
194
+
195
+
196
+ def _get_invitation_id(invitation: dict[str, object]) -> int | None:
197
+ invitation_id = invitation.get("id")
198
+ if isinstance(invitation_id, int):
199
+ return invitation_id
200
+ return None
201
+
202
+
203
+ def _get_invitation_repo_slug(invitation: dict[str, object]) -> str | None:
204
+ repository = invitation.get("repository")
205
+ if not isinstance(repository, dict):
206
+ return None
207
+
208
+ full_name = repository.get("full_name")
209
+ if isinstance(full_name, str) and full_name:
210
+ return full_name
211
+
212
+ return None
213
+
214
+
158
215
  def _load_workspace_github_repo_slug(paths: Paths) -> str | None:
159
216
  try:
160
217
  repo = git.Repo(paths.root_dir)
@@ -246,6 +303,70 @@ def _confirm_across_repos(
246
303
  raise click.ClickException("Operation cancelled.")
247
304
 
248
305
 
306
+ def _confirm_accept_invitations(
307
+ *,
308
+ invitations: list[tuple[str, int]],
309
+ yes: bool,
310
+ ) -> None:
311
+ if yes:
312
+ return
313
+
314
+ repo_list = "\n".join(f"- {slug}" for slug, _ in invitations)
315
+ confirmed = click.confirm(
316
+ f"Accept repository invitations for these repos?\n{repo_list}",
317
+ default=False,
318
+ )
319
+ if not confirmed:
320
+ raise click.ClickException("Operation cancelled.")
321
+
322
+
323
+ def _accept_collaborator_invitations(*, yes: bool) -> None:
324
+ paths = Paths(Path.cwd())
325
+ gh_path = _ensure_gh_available()
326
+ repo_targets = _load_github_repo_targets(paths)
327
+ target_slugs = {slug.lower() for _, slug in repo_targets}
328
+ invitations: list[tuple[str, int]] = []
329
+ failures: list[tuple[str, str]] = []
330
+
331
+ for invitation in _load_pending_repository_invitations(gh_path):
332
+ slug = _get_invitation_repo_slug(invitation)
333
+ invitation_id = _get_invitation_id(invitation)
334
+ if slug is None or invitation_id is None:
335
+ continue
336
+ if slug.lower() not in target_slugs:
337
+ continue
338
+ invitations.append((slug, invitation_id))
339
+
340
+ if not invitations:
341
+ click.echo("No pending repository invitations found for this workspace.")
342
+ return
343
+
344
+ _confirm_accept_invitations(invitations=invitations, yes=yes)
345
+
346
+ for slug, invitation_id in invitations:
347
+ try:
348
+ logger.info(f"Accepting repository invitation for {slug}")
349
+ _run_gh_api(
350
+ gh_path,
351
+ method="PATCH",
352
+ endpoint=f"user/repository_invitations/{invitation_id}",
353
+ )
354
+ except click.ClickException as exc:
355
+ message = exc.message or str(exc)
356
+ logger.error(
357
+ f"Failed accepting repository invitation for {slug}: {message}"
358
+ )
359
+ failures.append((slug, message))
360
+
361
+ if failures:
362
+ failure_lines = "\n".join(f"- {slug}: {message}" for slug, message in failures)
363
+ raise click.ClickException(
364
+ f"Finished accepting repository invitations with failures:\n{failure_lines}"
365
+ )
366
+
367
+ logger.info("✅ Accepted repository invitations for all workspace repos")
368
+
369
+
249
370
  def _manage_collaborator(
250
371
  *,
251
372
  action: str,
@@ -335,6 +456,17 @@ def collaborator_add_cmd(username: str | None, permission: str, yes: bool) -> No
335
456
  )
336
457
 
337
458
 
459
+ @click.command(name="accept")
460
+ @click.option(
461
+ "--yes",
462
+ is_flag=True,
463
+ help="Skip the confirmation prompt.",
464
+ )
465
+ def collaborator_accept_cmd(yes: bool) -> None:
466
+ """Accept pending collaborator invitations for this workspace."""
467
+ _accept_collaborator_invitations(yes=yes)
468
+
469
+
338
470
  @click.command(name="remove")
339
471
  @click.argument("username")
340
472
  @click.option(
@@ -365,5 +497,6 @@ def collaborator_recent_users_cmd() -> None:
365
497
 
366
498
 
367
499
  collaborator_cmd.add_command(common_command_wrapper(collaborator_add_cmd))
500
+ collaborator_cmd.add_command(common_command_wrapper(collaborator_accept_cmd))
368
501
  collaborator_cmd.add_command(common_command_wrapper(collaborator_remove_cmd))
369
502
  collaborator_cmd.add_command(common_command_wrapper(collaborator_recent_users_cmd))
@@ -39,7 +39,12 @@ def _run_git(repo_path: Path, args: list[str]) -> None:
39
39
  ) from e
40
40
 
41
41
 
42
- def _create_root_worktree(root_dir: Path, destination: Path, branch_name: str) -> None:
42
+ def _create_root_worktree(
43
+ root_dir: Path,
44
+ destination: Path,
45
+ branch_name: str,
46
+ base_ref: str,
47
+ ) -> None:
43
48
  exists_locally, exists_remotely = check_branch_existence(root_dir, branch_name)
44
49
 
45
50
  if exists_locally:
@@ -54,7 +59,7 @@ def _create_root_worktree(root_dir: Path, destination: Path, branch_name: str) -
54
59
  f"origin/{branch_name}",
55
60
  ]
56
61
  else:
57
- args = ["worktree", "add", "-b", branch_name, str(destination), "HEAD"]
62
+ args = ["worktree", "add", "-b", branch_name, str(destination), base_ref]
58
63
 
59
64
  _run_git(root_dir, args)
60
65
  logger.info(f"Created worktree at {destination}")
@@ -164,12 +169,42 @@ def _transfer_configured_paths(source_paths: Paths, destination_paths: Paths) ->
164
169
 
165
170
 
166
171
  def _checkout_subrepos(
167
- repos: Iterable[Repository],
172
+ source_repos: Iterable[Repository],
173
+ destination_repos: Iterable[Repository],
168
174
  branch_name: str,
169
175
  allow_create: bool,
176
+ base_ref: str,
170
177
  ) -> None:
171
- for repo in repos:
178
+ source_by_name = {repo.name: repo for repo in source_repos}
179
+ for repo in destination_repos:
172
180
  repo_branch_name = repo.fixed_branch or branch_name
181
+ if repo.fixed_branch:
182
+ create_and_switch_branch(
183
+ repo.path,
184
+ repo_branch_name,
185
+ allow_create=allow_create,
186
+ )
187
+ continue
188
+ source_repo = source_by_name.get(repo.name)
189
+ if (
190
+ source_repo is not None
191
+ and base_ref != "HEAD"
192
+ and not check_branch_existence(repo.path, repo_branch_name)[0]
193
+ ):
194
+ try:
195
+ _run_git(repo.path, ["fetch", str(source_repo.path), base_ref])
196
+ _run_git(repo.path, ["checkout", "-b", repo_branch_name, "FETCH_HEAD"])
197
+ logger.info(f"✅ Switched to branch '{repo_branch_name}' in {repo.path}")
198
+ continue
199
+ except GitError as exc:
200
+ logger.warning(
201
+ "⚠️ Base ref %s was not available for %s; creating %s from "
202
+ "the current checkout instead. %s",
203
+ base_ref,
204
+ repo.name,
205
+ repo_branch_name,
206
+ exc,
207
+ )
173
208
  create_and_switch_branch(
174
209
  repo.path,
175
210
  repo_branch_name,
@@ -177,8 +212,14 @@ def _checkout_subrepos(
177
212
  )
178
213
 
179
214
 
180
- def add_worktree(root_dir: Path, name: str, branch_name: str | None = None) -> Path:
181
- paths = Paths(root_dir)
215
+ def add_worktree(
216
+ root_dir: Path,
217
+ name: str,
218
+ branch_name: str | None = None,
219
+ install_set: str | None = None,
220
+ base_ref: str = "HEAD",
221
+ ) -> Path:
222
+ paths = Paths(root_dir, install_set=install_set)
182
223
  if paths.settings.is_monorepo():
183
224
  raise click.UsageError(
184
225
  "The 'multi worktree add' command is not available in monorepo mode. "
@@ -198,14 +239,16 @@ def add_worktree(root_dir: Path, name: str, branch_name: str | None = None) -> P
198
239
  "already exist for all repos, this command will fix the situation."
199
240
  )
200
241
 
201
- _create_root_worktree(paths.root_dir, destination, branch_name)
202
- sync(root_dir=destination)
242
+ _create_root_worktree(paths.root_dir, destination, branch_name, base_ref)
243
+ sync(root_dir=destination, install_set=install_set)
203
244
 
204
- destination_paths = Paths(destination)
245
+ destination_paths = Paths(destination, install_set=install_set)
205
246
  _checkout_subrepos(
247
+ load_repos(paths),
206
248
  load_repos(destination_paths),
207
249
  branch_name,
208
250
  allow_create=all_on_same_branch,
251
+ base_ref=base_ref,
209
252
  )
210
253
  _transfer_configured_paths(paths, destination_paths)
211
254
  return destination
@@ -223,13 +266,37 @@ def worktree_cmd() -> None:
223
266
  "branch_name",
224
267
  help="Branch name for the worktree. Defaults to NAME.",
225
268
  )
226
- def worktree_add_cmd(name: str, branch_name: str | None = None) -> None:
269
+ @click.option(
270
+ "--install-set",
271
+ "--set",
272
+ "install_set",
273
+ metavar="NAME",
274
+ help="Only sync repositories included in the named install set.",
275
+ )
276
+ @click.option(
277
+ "--base-ref",
278
+ default="HEAD",
279
+ show_default=True,
280
+ help="Base ref for newly created root and sub-repo branches.",
281
+ )
282
+ def worktree_add_cmd(
283
+ name: str,
284
+ branch_name: str | None = None,
285
+ install_set: str | None = None,
286
+ base_ref: str = "HEAD",
287
+ ) -> None:
227
288
  """Create a sibling worktree.
228
289
 
229
290
  NAME: Directory name for the sibling worktree. Used as the branch name when
230
291
  --branch is omitted.
231
292
  """
232
- add_worktree(Path.cwd(), name=name, branch_name=branch_name)
293
+ add_worktree(
294
+ Path.cwd(),
295
+ name=name,
296
+ branch_name=branch_name,
297
+ install_set=install_set,
298
+ base_ref=base_ref,
299
+ )
233
300
 
234
301
 
235
302
  worktree_cmd.add_command(common_command_wrapper(worktree_add_cmd))
File without changes