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.
- {multi_workspace-3.2.2 → multi_workspace-3.2.4}/PKG-INFO +4 -1
- {multi_workspace-3.2.2 → multi_workspace-3.2.4}/README.md +3 -0
- {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/_version.py +2 -2
- {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/app_api.py +160 -15
- {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/collaborator.py +133 -0
- {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/ignore_files.py +9 -4
- {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/service.py +13 -0
- {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/worktree.py +78 -11
- {multi_workspace-3.2.2 → multi_workspace-3.2.4}/.gitignore +0 -0
- {multi_workspace-3.2.2 → multi_workspace-3.2.4}/LICENSE +0 -0
- {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/__init__.py +0 -0
- {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/__main__.py +0 -0
- {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/add.py +0 -0
- {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/api.py +0 -0
- {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/bootstrap.py +0 -0
- {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/cli.py +0 -0
- {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/cli_helpers.py +0 -0
- {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/convert_monorepo.py +0 -0
- {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/doctor.py +0 -0
- {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/errors.py +0 -0
- {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/git_helpers.py +0 -0
- {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/git_run.py +0 -0
- {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/git_set_branch.py +0 -0
- {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/init.py +0 -0
- {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/logging.py +0 -0
- {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/managed_blocks.py +0 -0
- {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/open.py +0 -0
- {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/paths.py +0 -0
- {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/registry.py +0 -0
- {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/remove.py +0 -0
- {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/repo_urls.py +0 -0
- {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/repos.py +0 -0
- {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/resources/init_readme.md +0 -0
- {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/settings.py +0 -0
- {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/sync.py +0 -0
- {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/sync_agents.py +0 -0
- {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/sync_github.py +0 -0
- {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/sync_vscode.py +0 -0
- {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/sync_vscode_devcontainer.py +0 -0
- {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/sync_vscode_extensions.py +0 -0
- {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/sync_vscode_helpers.py +0 -0
- {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/sync_vscode_launch.py +0 -0
- {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/sync_vscode_settings.py +0 -0
- {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/sync_vscode_tasks.py +0 -0
- {multi_workspace-3.2.2 → multi_workspace-3.2.4}/multi/utils.py +0 -0
- {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.
|
|
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.
|
|
22
|
-
__version_tuple__ = version_tuple = (3, 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[
|
|
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 "
|
|
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(
|
|
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
|
-
"
|
|
198
|
-
"
|
|
199
|
-
"
|
|
200
|
-
"
|
|
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", "
|
|
241
|
+
if worst in {"missing", "no_git"}:
|
|
218
242
|
worst = "clean"
|
|
219
243
|
for status in statuses[1:]:
|
|
220
|
-
if status in {"missing", "
|
|
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")
|
|
338
|
+
"authoredAtIso": __import__("datetime")
|
|
339
|
+
.datetime.fromtimestamp(
|
|
315
340
|
authored_at_ms / 1000,
|
|
316
341
|
tz=__import__("datetime").timezone.utc,
|
|
317
|
-
)
|
|
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]
|
|
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
|
|
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
|
-
|
|
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 [
|
|
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 = [
|
|
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(
|
|
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),
|
|
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
|
-
|
|
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
|
|
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(
|
|
181
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|