multi-workspace 3.2.2__tar.gz → 3.2.3__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.3}/PKG-INFO +1 -1
  2. {multi_workspace-3.2.2 → multi_workspace-3.2.3}/multi/_version.py +2 -2
  3. {multi_workspace-3.2.2 → multi_workspace-3.2.3}/multi/app_api.py +160 -15
  4. {multi_workspace-3.2.2 → multi_workspace-3.2.3}/multi/ignore_files.py +9 -4
  5. {multi_workspace-3.2.2 → multi_workspace-3.2.3}/multi/service.py +13 -0
  6. {multi_workspace-3.2.2 → multi_workspace-3.2.3}/.gitignore +0 -0
  7. {multi_workspace-3.2.2 → multi_workspace-3.2.3}/LICENSE +0 -0
  8. {multi_workspace-3.2.2 → multi_workspace-3.2.3}/README.md +0 -0
  9. {multi_workspace-3.2.2 → multi_workspace-3.2.3}/multi/__init__.py +0 -0
  10. {multi_workspace-3.2.2 → multi_workspace-3.2.3}/multi/__main__.py +0 -0
  11. {multi_workspace-3.2.2 → multi_workspace-3.2.3}/multi/add.py +0 -0
  12. {multi_workspace-3.2.2 → multi_workspace-3.2.3}/multi/api.py +0 -0
  13. {multi_workspace-3.2.2 → multi_workspace-3.2.3}/multi/bootstrap.py +0 -0
  14. {multi_workspace-3.2.2 → multi_workspace-3.2.3}/multi/cli.py +0 -0
  15. {multi_workspace-3.2.2 → multi_workspace-3.2.3}/multi/cli_helpers.py +0 -0
  16. {multi_workspace-3.2.2 → multi_workspace-3.2.3}/multi/collaborator.py +0 -0
  17. {multi_workspace-3.2.2 → multi_workspace-3.2.3}/multi/convert_monorepo.py +0 -0
  18. {multi_workspace-3.2.2 → multi_workspace-3.2.3}/multi/doctor.py +0 -0
  19. {multi_workspace-3.2.2 → multi_workspace-3.2.3}/multi/errors.py +0 -0
  20. {multi_workspace-3.2.2 → multi_workspace-3.2.3}/multi/git_helpers.py +0 -0
  21. {multi_workspace-3.2.2 → multi_workspace-3.2.3}/multi/git_run.py +0 -0
  22. {multi_workspace-3.2.2 → multi_workspace-3.2.3}/multi/git_set_branch.py +0 -0
  23. {multi_workspace-3.2.2 → multi_workspace-3.2.3}/multi/init.py +0 -0
  24. {multi_workspace-3.2.2 → multi_workspace-3.2.3}/multi/logging.py +0 -0
  25. {multi_workspace-3.2.2 → multi_workspace-3.2.3}/multi/managed_blocks.py +0 -0
  26. {multi_workspace-3.2.2 → multi_workspace-3.2.3}/multi/open.py +0 -0
  27. {multi_workspace-3.2.2 → multi_workspace-3.2.3}/multi/paths.py +0 -0
  28. {multi_workspace-3.2.2 → multi_workspace-3.2.3}/multi/registry.py +0 -0
  29. {multi_workspace-3.2.2 → multi_workspace-3.2.3}/multi/remove.py +0 -0
  30. {multi_workspace-3.2.2 → multi_workspace-3.2.3}/multi/repo_urls.py +0 -0
  31. {multi_workspace-3.2.2 → multi_workspace-3.2.3}/multi/repos.py +0 -0
  32. {multi_workspace-3.2.2 → multi_workspace-3.2.3}/multi/resources/init_readme.md +0 -0
  33. {multi_workspace-3.2.2 → multi_workspace-3.2.3}/multi/settings.py +0 -0
  34. {multi_workspace-3.2.2 → multi_workspace-3.2.3}/multi/sync.py +0 -0
  35. {multi_workspace-3.2.2 → multi_workspace-3.2.3}/multi/sync_agents.py +0 -0
  36. {multi_workspace-3.2.2 → multi_workspace-3.2.3}/multi/sync_github.py +0 -0
  37. {multi_workspace-3.2.2 → multi_workspace-3.2.3}/multi/sync_vscode.py +0 -0
  38. {multi_workspace-3.2.2 → multi_workspace-3.2.3}/multi/sync_vscode_devcontainer.py +0 -0
  39. {multi_workspace-3.2.2 → multi_workspace-3.2.3}/multi/sync_vscode_extensions.py +0 -0
  40. {multi_workspace-3.2.2 → multi_workspace-3.2.3}/multi/sync_vscode_helpers.py +0 -0
  41. {multi_workspace-3.2.2 → multi_workspace-3.2.3}/multi/sync_vscode_launch.py +0 -0
  42. {multi_workspace-3.2.2 → multi_workspace-3.2.3}/multi/sync_vscode_settings.py +0 -0
  43. {multi_workspace-3.2.2 → multi_workspace-3.2.3}/multi/sync_vscode_tasks.py +0 -0
  44. {multi_workspace-3.2.2 → multi_workspace-3.2.3}/multi/utils.py +0 -0
  45. {multi_workspace-3.2.2 → multi_workspace-3.2.3}/multi/worktree.py +0 -0
  46. {multi_workspace-3.2.2 → multi_workspace-3.2.3}/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.3
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.2'
22
- __version_tuple__ = version_tuple = (3, 2, 2)
21
+ __version__ = version = '3.2.3'
22
+ __version_tuple__ = version_tuple = (3, 2, 3)
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]:
@@ -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:
File without changes