multi-workspace 3.2.2__tar.gz → 3.2.4__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.2 → multi_workspace-3.2.4}/PKG-INFO +4 -1
  2. {multi_workspace-3.2.2 → multi_workspace-3.2.4}/README.md +3 -0
  3. {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/_version.py +2 -2
  4. {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/app_api.py +160 -15
  5. {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/collaborator.py +133 -0
  6. {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/ignore_files.py +9 -4
  7. {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/service.py +13 -0
  8. {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/worktree.py +78 -11
  9. {multi_workspace-3.2.2 → multi_workspace-3.2.4}/.gitignore +0 -0
  10. {multi_workspace-3.2.2 → multi_workspace-3.2.4}/LICENSE +0 -0
  11. {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/__init__.py +0 -0
  12. {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/__main__.py +0 -0
  13. {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/add.py +0 -0
  14. {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/api.py +0 -0
  15. {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/bootstrap.py +0 -0
  16. {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/cli.py +0 -0
  17. {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/cli_helpers.py +0 -0
  18. {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/convert_monorepo.py +0 -0
  19. {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/doctor.py +0 -0
  20. {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/errors.py +0 -0
  21. {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/git_helpers.py +0 -0
  22. {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/git_run.py +0 -0
  23. {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/git_set_branch.py +0 -0
  24. {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/init.py +0 -0
  25. {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/logging.py +0 -0
  26. {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/managed_blocks.py +0 -0
  27. {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/open.py +0 -0
  28. {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/paths.py +0 -0
  29. {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/registry.py +0 -0
  30. {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/remove.py +0 -0
  31. {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/repo_urls.py +0 -0
  32. {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/repos.py +0 -0
  33. {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/resources/init_readme.md +0 -0
  34. {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/settings.py +0 -0
  35. {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/sync.py +0 -0
  36. {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/sync_agents.py +0 -0
  37. {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/sync_github.py +0 -0
  38. {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/sync_vscode.py +0 -0
  39. {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/sync_vscode_devcontainer.py +0 -0
  40. {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/sync_vscode_extensions.py +0 -0
  41. {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/sync_vscode_helpers.py +0 -0
  42. {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/sync_vscode_launch.py +0 -0
  43. {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/sync_vscode_settings.py +0 -0
  44. {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/sync_vscode_tasks.py +0 -0
  45. {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/utils.py +0 -0
  46. {multi_workspace-3.2.2 → multi_workspace-3.2.4}/pyproject.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: multi-workspace
3
- Version: 3.2.2
3
+ Version: 3.2.4
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.2'
22
- __version_tuple__ = version_tuple = (3, 2, 2)
21
+ __version__ = version = '3.2.4'
22
+ __version_tuple__ = version_tuple = (3, 2, 4)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -2,6 +2,9 @@ from __future__ import annotations
2
2
 
3
3
  import dataclasses
4
4
  import subprocess
5
+ import threading
6
+ import time
7
+ from concurrent.futures import ThreadPoolExecutor, TimeoutError, as_completed
5
8
  from pathlib import Path
6
9
  from typing import Any, Literal, TypedDict
7
10
 
@@ -10,11 +13,29 @@ from multi.errors import NoRepositoriesError
10
13
  from multi.paths import Paths
11
14
  from multi.repos import Repository, load_repos
12
15
 
13
- RepoStatus = Literal["clean", "dirty", "out-of-sync", "not-a-repo", "missing"]
16
+ RepoStatus = Literal[
17
+ "clean",
18
+ "dirty",
19
+ "out-of-sync",
20
+ "no_git",
21
+ "missing",
22
+ "unknown",
23
+ ]
14
24
 
15
25
  HISTORY_GROUP_WINDOW_MS = 60_000
16
26
  HISTORY_MAX_COMMITS_PER_REPO = 250
17
27
  GIT_HISTORY_FORMAT = "%H%x1f%h%x1f%an%x1f%ae%x1f%at%x1f%s%x1f%b%x1e"
28
+ PROJECT_SUMMARY_METADATA_CACHE_TTL_SECONDS = 10.0
29
+ PROJECT_SUMMARY_WORKERS = 8
30
+ PROJECT_SUMMARY_TIMEOUT_SECONDS = 12.0
31
+
32
+ _project_summary_cache_lock = threading.Lock()
33
+ _project_summary_executor = ThreadPoolExecutor(
34
+ max_workers=PROJECT_SUMMARY_WORKERS,
35
+ thread_name_prefix="multi-project-summary",
36
+ )
37
+ _cached_project_summary_metadata: dict[str, tuple[float, dict[str, Any]]] = {}
38
+ _refreshing_project_summary_paths: set[str] = set()
18
39
 
19
40
 
20
41
  class RepoSyncState(TypedDict):
@@ -104,7 +125,7 @@ def _get_git_status_internal(repo_path: Path, ignore_untracked: bool) -> RepoSta
104
125
  return "missing"
105
126
 
106
127
  if not (repo_path / ".git").exists():
107
- return "not-a-repo"
128
+ return "no_git"
108
129
 
109
130
  status_args = ["status", "--porcelain"]
110
131
  if ignore_untracked:
@@ -184,7 +205,9 @@ def _get_latest_commit_ms_for_repo(repo_path: Path) -> int | None:
184
205
  return None
185
206
 
186
207
  try:
187
- stdout = _run_git(["log", "-1", "--format=%at"], cwd=repo_path, timeout=10).stdout
208
+ stdout = _run_git(
209
+ ["log", "-1", "--format=%at"], cwd=repo_path, timeout=10
210
+ ).stdout
188
211
  authored_at_seconds = int(stdout.strip())
189
212
  return authored_at_seconds * 1000
190
213
  except (OSError, ValueError, subprocess.SubprocessError):
@@ -194,10 +217,11 @@ def _get_latest_commit_ms_for_repo(repo_path: Path) -> int | None:
194
217
  def _worst_status(a: RepoStatus, b: RepoStatus) -> RepoStatus:
195
218
  severity = {
196
219
  "clean": 0,
197
- "not-a-repo": 1,
198
- "out-of-sync": 2,
199
- "dirty": 3,
200
- "missing": 4,
220
+ "unknown": 1,
221
+ "no_git": 2,
222
+ "out-of-sync": 3,
223
+ "dirty": 4,
224
+ "missing": 5,
201
225
  }
202
226
  return a if severity[a] >= severity[b] else b
203
227
 
@@ -214,10 +238,10 @@ def get_project_status(repo_path: str | Path) -> RepoStatus:
214
238
  ],
215
239
  ]
216
240
  worst = statuses[0]
217
- if worst in {"missing", "not-a-repo"}:
241
+ if worst in {"missing", "no_git"}:
218
242
  worst = "clean"
219
243
  for status in statuses[1:]:
220
- if status in {"missing", "not-a-repo"}:
244
+ if status in {"missing", "no_git"}:
221
245
  continue
222
246
  worst = _worst_status(worst, status)
223
247
  return worst
@@ -311,10 +335,13 @@ def _history_commit_from_record(
311
335
  "authorName": author_name or "",
312
336
  "authorEmail": author_email or "",
313
337
  "authoredAtMs": authored_at_ms,
314
- "authoredAtIso": __import__("datetime").datetime.fromtimestamp(
338
+ "authoredAtIso": __import__("datetime")
339
+ .datetime.fromtimestamp(
315
340
  authored_at_ms / 1000,
316
341
  tz=__import__("datetime").timezone.utc,
317
- ).isoformat().replace("+00:00", "Z"),
342
+ )
343
+ .isoformat()
344
+ .replace("+00:00", "Z"),
318
345
  "repoPath": str(repo_path),
319
346
  "repoName": repo_name,
320
347
  }
@@ -365,7 +392,9 @@ def group_history_commits(commits: list[dict[str, Any]]) -> list[dict[str, Any]]
365
392
  for commit in sorted_commits:
366
393
  current_group = groups[-1] if groups else None
367
394
  previous_commit = (
368
- current_group["commits"][-1] if current_group and current_group["commits"] else None
395
+ current_group["commits"][-1]
396
+ if current_group and current_group["commits"]
397
+ else None
369
398
  )
370
399
  should_merge = (
371
400
  previous_commit is not None
@@ -483,7 +512,7 @@ def _doctor_result(repo_path: Path) -> dict[str, Any] | None:
483
512
  }
484
513
 
485
514
 
486
- def get_project_summary(repo_path: str | Path) -> dict[str, Any]:
515
+ def _project_summary_static_payload(repo_path: str | Path) -> dict[str, Any]:
487
516
  project_path = Path(repo_path)
488
517
  name = project_path.name or str(project_path)
489
518
  try:
@@ -495,15 +524,131 @@ def get_project_summary(repo_path: str | Path) -> dict[str, Any]:
495
524
  "path": str(project_path),
496
525
  "name": name,
497
526
  "lastModifiedMs": _normalize_optional_ms(last_modified_ms),
498
- "lastCommitMs": _normalize_optional_ms(get_project_last_commit_ms(project_path)),
527
+ }
528
+
529
+
530
+ def _project_summary_metadata(repo_path: str | Path) -> dict[str, Any]:
531
+ project_path = Path(repo_path)
532
+ return {
533
+ "lastCommitMs": _normalize_optional_ms(
534
+ get_project_last_commit_ms(project_path)
535
+ ),
499
536
  "status": get_project_status(project_path),
500
537
  "doctorResult": _doctor_result(project_path),
501
538
  "subRepos": _subrepo_payloads(project_path),
502
539
  }
503
540
 
504
541
 
542
+ def _project_summary_fallback_metadata() -> dict[str, Any]:
543
+ return {
544
+ "lastCommitMs": None,
545
+ "status": "unknown",
546
+ "doctorResult": None,
547
+ "subRepos": None,
548
+ }
549
+
550
+
551
+ def _cached_project_metadata(repo_path: str) -> tuple[dict[str, Any] | None, bool]:
552
+ now = time.monotonic()
553
+ with _project_summary_cache_lock:
554
+ cached = _cached_project_summary_metadata.get(repo_path)
555
+ if cached is None:
556
+ return None, False
557
+ cached_at, metadata = cached
558
+ return dict(
559
+ metadata
560
+ ), now - cached_at < PROJECT_SUMMARY_METADATA_CACHE_TTL_SECONDS
561
+
562
+
563
+ def _refresh_project_summary_metadata_task(repo_path: str) -> None:
564
+ try:
565
+ metadata = _project_summary_metadata(repo_path)
566
+ with _project_summary_cache_lock:
567
+ _cached_project_summary_metadata[repo_path] = (time.monotonic(), metadata)
568
+ finally:
569
+ with _project_summary_cache_lock:
570
+ _refreshing_project_summary_paths.discard(repo_path)
571
+
572
+
573
+ def _schedule_project_summary_metadata_refresh(repo_paths: list[str]) -> None:
574
+ now = time.monotonic()
575
+ scheduled: list[str] = []
576
+ with _project_summary_cache_lock:
577
+ for repo_path in dict.fromkeys(path for path in repo_paths if path):
578
+ cached = _cached_project_summary_metadata.get(repo_path)
579
+ if (
580
+ cached is not None
581
+ and now - cached[0] < PROJECT_SUMMARY_METADATA_CACHE_TTL_SECONDS
582
+ ):
583
+ continue
584
+ if repo_path in _refreshing_project_summary_paths:
585
+ continue
586
+ _refreshing_project_summary_paths.add(repo_path)
587
+ scheduled.append(repo_path)
588
+
589
+ for repo_path in scheduled:
590
+ _project_summary_executor.submit(
591
+ _refresh_project_summary_metadata_task, repo_path
592
+ )
593
+
594
+
595
+ def _project_summary_payload(repo_path: str | Path) -> dict[str, Any]:
596
+ path_key = str(repo_path)
597
+ metadata, is_fresh = _cached_project_metadata(path_key)
598
+ if not is_fresh:
599
+ _schedule_project_summary_metadata_refresh([path_key])
600
+ return {
601
+ **_project_summary_static_payload(repo_path),
602
+ **(metadata or _project_summary_fallback_metadata()),
603
+ }
604
+
605
+
606
+ def get_project_summary(repo_path: str | Path) -> dict[str, Any]:
607
+ return {
608
+ **_project_summary_static_payload(repo_path),
609
+ **_project_summary_metadata(repo_path),
610
+ }
611
+
612
+
505
613
  def get_projects_summary(repo_paths: list[str]) -> list[dict[str, Any]]:
506
- return [get_project_summary(repo_path) for repo_path in repo_paths]
614
+ return [_project_summary_payload(repo_path) for repo_path in repo_paths]
615
+
616
+
617
+ def refresh_projects_summary(repo_paths: list[str]) -> list[dict[str, Any]]:
618
+ unique_paths = list(dict.fromkeys(path for path in repo_paths if path))
619
+ futures = {
620
+ _project_summary_executor.submit(
621
+ _project_summary_metadata, repo_path
622
+ ): repo_path
623
+ for repo_path in unique_paths
624
+ }
625
+ refreshed: dict[str, dict[str, Any]] = {}
626
+ deadline = time.monotonic() + PROJECT_SUMMARY_TIMEOUT_SECONDS
627
+
628
+ try:
629
+ for future in as_completed(futures, timeout=PROJECT_SUMMARY_TIMEOUT_SECONDS):
630
+ repo_path = futures[future]
631
+ remaining = max(0.0, deadline - time.monotonic())
632
+ try:
633
+ metadata = future.result(timeout=remaining)
634
+ except Exception:
635
+ metadata = _project_summary_fallback_metadata()
636
+ refreshed[repo_path] = metadata
637
+ with _project_summary_cache_lock:
638
+ _cached_project_summary_metadata[repo_path] = (
639
+ time.monotonic(),
640
+ metadata,
641
+ )
642
+ except TimeoutError:
643
+ pass
644
+
645
+ return [
646
+ {
647
+ **_project_summary_static_payload(repo_path),
648
+ **refreshed.get(repo_path, _project_summary_fallback_metadata()),
649
+ }
650
+ for repo_path in unique_paths
651
+ ]
507
652
 
508
653
 
509
654
  def get_project_detail(repo_path: str | Path) -> dict[str, Any]:
@@ -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))
@@ -74,7 +74,7 @@ def _search_entries(paths: Paths) -> List[str]:
74
74
 
75
75
 
76
76
  def _subrepo_generated_entries(repo_path: Path, paths: Paths) -> List[str]:
77
- entries = ["# These files are generated by multi."]
77
+ entries = []
78
78
  if would_generate_agents_for_repo(repo_path, paths):
79
79
  entries.extend(["CLAUDE.md", "AGENTS.md"])
80
80
  repo_vscode_dir = repo_path / ".vscode"
@@ -82,7 +82,13 @@ def _subrepo_generated_entries(repo_path: Path, paths: Paths) -> List[str]:
82
82
  repo_vscode_dir / "settings.local.json"
83
83
  ).exists():
84
84
  entries.append(".vscode/settings.json")
85
- return entries
85
+ return _with_generated_files_header(entries)
86
+
87
+
88
+ def _with_generated_files_header(entries: List[str]) -> List[str]:
89
+ if not entries:
90
+ return []
91
+ return ["# These files are generated by multi.", *entries]
86
92
 
87
93
 
88
94
  def clear_subrepo_generated_file_block(repo_path: Path) -> None:
@@ -135,7 +141,6 @@ def remove_ignore_entries_for_repos(paths: Paths, repo_names: List[str]) -> None
135
141
 
136
142
  def update_gitignore_with_generated_files(paths: Paths):
137
143
  root_generated_entries = [
138
- "# These files are generated by multi.",
139
144
  ".vscode/settings.json",
140
145
  ".vscode/tasks.json",
141
146
  ".vscode/launch.json",
@@ -144,7 +149,7 @@ def update_gitignore_with_generated_files(paths: Paths):
144
149
  if would_generate_agents_for_repo(paths.root_dir, paths, is_root=True):
145
150
  root_generated_entries.extend(["CLAUDE.md", "AGENTS.md"])
146
151
  IgnoreFile(paths.gitignore_path, GENERATED_FILES_BLOCK).set_managed_lines(
147
- root_generated_entries
152
+ _with_generated_files_header(root_generated_entries)
148
153
  )
149
154
 
150
155
  if paths.settings.is_monorepo() or not paths.settings.get("repos"):
@@ -12,6 +12,7 @@ from multi.app_api import (
12
12
  get_history_group_diff,
13
13
  get_project_detail,
14
14
  get_projects_summary,
15
+ refresh_projects_summary,
15
16
  )
16
17
 
17
18
  logger = logging.getLogger(__name__)
@@ -66,6 +67,18 @@ class MultiServiceHandler(BaseHTTPRequestHandler):
66
67
  )
67
68
  return
68
69
 
70
+ if self.path == "/v1/projects/status":
71
+ repo_paths = body.get("repoPaths", [])
72
+ if not isinstance(repo_paths, list) or not all(
73
+ isinstance(item, str) for item in repo_paths
74
+ ):
75
+ raise ValueError("repoPaths must be a list of strings.")
76
+ self._write_json(
77
+ HTTPStatus.OK,
78
+ {"projects": refresh_projects_summary(repo_paths)},
79
+ )
80
+ return
81
+
69
82
  if self.path == "/v1/project/detail":
70
83
  repo_path = body.get("repoPath")
71
84
  if not isinstance(repo_path, str) or not repo_path:
@@ -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