workspace.py python
708 lines 26.4 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 32 days ago
1 """``muse workspace`` — compose multiple Muse repositories.
2
3 A workspace links several independent Muse repos together under a single
4 manifest, giving you a unified status view, one-shot sync, and a clear model
5 for multi-repo projects.
6
7 Subcommands::
8
9 muse workspace add <name> <url> [--path repos/<name>] [--branch main]
10 muse workspace update <name> [--url URL] [--path PATH] [--branch BRANCH]
11 muse workspace list [--json]
12 muse workspace remove <name>
13 muse workspace status [<name>] [--json]
14 muse workspace sync [<name>] [--dry-run] [--workers N] [--json]
15
16 Agent workflow::
17
18 # Register members (no network I/O)
19 muse workspace add core https://musehub.ai/acme/core
20 muse workspace add sounds https://musehub.ai/acme/sounds --branch v2
21
22 # Clone / pull everything, 8 parallel workers, structured output
23 muse workspace sync --workers 8 --json
24
25 # Inspect state
26 muse workspace status --json
27 """
28
29 from __future__ import annotations
30
31 import argparse
32 import json
33 import pathlib
34 import sys
35 import logging
36 from typing import TypedDict
37
38 from muse.core.errors import ExitCode
39 from muse.core.validation import sanitize_display
40 from muse.core.workspace import (
41 WorkspaceMemberStatus,
42 WorkspaceSyncResult,
43 add_workspace_member,
44 find_workspace_root,
45 get_workspace_member,
46 list_workspace_members,
47 remove_workspace_member,
48 require_workspace_root,
49 sync_workspace,
50 update_workspace_member,
51 )
52
53 logger = logging.getLogger(__name__)
54
55
56 # ---------------------------------------------------------------------------
57 # JSON wire formats
58 # ---------------------------------------------------------------------------
59
60
61 class _WorkspaceAddJson(TypedDict):
62 name: str
63 url: str
64 path: str
65 branch: str
66
67
68 class _WorkspaceUpdateJson(TypedDict):
69 name: str
70 url: str
71 path: str
72 branch: str
73
74
75 class _WorkspaceMemberJson(TypedDict):
76 name: str
77 url: str
78 path: str
79 branch: str # configured tracking branch from workspace.toml
80 present: bool
81 head_commit: str | None # actual HEAD commit (what HEAD resolves to)
82 dirty: bool
83 actual_branch: str | None # currently checked-out branch
84 stash_count: int # number of stashed changesets
85 feature_branches: list[str] # local branches other than main / dev
86
87
88 class _WorkspaceRemoveJson(TypedDict):
89 name: str
90 removed: bool
91
92
93 class _WorkspaceSyncResultJson(TypedDict):
94 name: str
95 status: str
96 ok: bool
97
98
99 class _WorkspaceSyncJson(TypedDict):
100 dry_run: bool
101 workers: int
102 results: list[_WorkspaceSyncResultJson]
103 total: int
104 ok_count: int
105 error_count: int
106
107
108 # ---------------------------------------------------------------------------
109 # Helpers
110 # ---------------------------------------------------------------------------
111
112
113 def _member_to_json(m: WorkspaceMemberStatus) -> _WorkspaceMemberJson:
114 return _WorkspaceMemberJson(
115 name=sanitize_display(m.name),
116 url=sanitize_display(m.url),
117 path=sanitize_display(str(m.path)),
118 branch=sanitize_display(m.branch),
119 present=m.present,
120 head_commit=m.head_commit,
121 dirty=m.dirty,
122 actual_branch=sanitize_display(m.actual_branch) if m.actual_branch else None,
123 stash_count=m.stash_count,
124 feature_branches=[sanitize_display(b) for b in m.feature_branches],
125 )
126
127
128 def _sync_result_to_json(r: WorkspaceSyncResult) -> _WorkspaceSyncResultJson:
129 return _WorkspaceSyncResultJson(
130 name=r["name"],
131 status=r["status"],
132 ok=not r["status"].startswith("error"),
133 )
134
135
136 # ---------------------------------------------------------------------------
137 # Registration
138 # ---------------------------------------------------------------------------
139
140
141 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
142 """Register the ``muse workspace`` subcommand tree."""
143 parser = subparsers.add_parser(
144 "workspace",
145 help="Compose multiple Muse repositories.",
146 description=__doc__,
147 formatter_class=argparse.RawDescriptionHelpFormatter,
148 )
149 subs = parser.add_subparsers(dest="subcommand", metavar="SUBCOMMAND")
150 subs.required = True
151
152 # workspace add
153 add_p = subs.add_parser(
154 "add",
155 help="Add a member repository to the workspace manifest.",
156 description=(
157 "Register a member repository in .muse/workspace.toml.\n"
158 "No network I/O — run 'muse workspace sync' to clone it.\n\n"
159 "NAME must be 1–64 alphanumeric characters, hyphens, underscores,\n"
160 "or dots. URL must be https://, http://, or a bare filesystem\n"
161 "path. PATH must not escape the workspace root.\n\n"
162 "Agent quickstart\n"
163 "----------------\n"
164 " muse workspace add core https://musehub.ai/acme/core --json\n"
165 " muse workspace add data /local/dataset --branch v2 --json\n\n"
166 "JSON output schema\n"
167 "------------------\n"
168 ' {"name": "<name>", "url": "<url>",\n'
169 ' "path": "<relative-path>", "branch": "<branch>"}\n\n'
170 "Exit codes\n"
171 "----------\n"
172 " 0 — success\n"
173 " 1 — invalid name/URL/path, duplicate member, or invalid branch\n"
174 " 2 — not inside a Muse repository\n"
175 ),
176 formatter_class=argparse.RawDescriptionHelpFormatter,
177 )
178 add_p.add_argument("name", metavar="NAME", help="Member name (alphanumeric, hyphens, underscores, dots).")
179 add_p.add_argument("url", metavar="URL", help="Remote URL (https/http) or local path of the member repository.")
180 add_p.add_argument("--path", default="", metavar="PATH", help="Relative checkout path (default: repos/<name>).")
181 add_p.add_argument("--branch", "-b", default="main", metavar="BRANCH", help="Branch to track (default: main).")
182 add_p.add_argument("--json", "-j", action="store_true", dest="output_json", help="Emit JSON on stdout.")
183 add_p.set_defaults(func=run_workspace_add)
184
185 # workspace list
186 list_p = subs.add_parser(
187 "list",
188 help="List all workspace members from the manifest.",
189 description=(
190 "List every registered workspace member with its checkout status.\n\n"
191 "Each entry shows whether the directory is present, whether the\n"
192 "working tree is dirty, and the current HEAD commit. All output\n"
193 "fields are sanitized — ANSI control sequences are stripped.\n\n"
194 "Agent quickstart\n"
195 "----------------\n"
196 " muse workspace list --json\n\n"
197 "JSON output schema (array element)\n"
198 "----------------------------------\n"
199 ' {"name": "<name>", "url": "<url>", "path": "<absolute-path>",\n'
200 ' "branch": "<branch>", "present": true|false,\n'
201 ' "head_commit": "<sha256> | null", "dirty": true|false}\n\n'
202 "Exit codes\n"
203 "----------\n"
204 " 0 — success (empty list when no members registered)\n"
205 " 2 — not inside a Muse repository\n"
206 ),
207 formatter_class=argparse.RawDescriptionHelpFormatter,
208 )
209 list_p.add_argument("--json", "-j", action="store_true", dest="output_json", help="Emit JSON on stdout.")
210 list_p.set_defaults(func=run_workspace_list)
211
212 # workspace remove
213 remove_p = subs.add_parser(
214 "remove",
215 help="Remove a member from the workspace manifest (does not delete its directory).",
216 description=(
217 "Unregister a member from .muse/workspace.toml.\n"
218 "The member's checked-out directory is left untouched — only\n"
219 "the manifest entry is deleted.\n\n"
220 "Agent quickstart\n"
221 "----------------\n"
222 " muse workspace remove sounds --json\n\n"
223 "JSON output schema\n"
224 "------------------\n"
225 ' {"name": "<name>", "removed": true}\n\n'
226 "Exit codes\n"
227 "----------\n"
228 " 0 — member removed successfully\n"
229 " 1 — member not found, or no workspace manifest exists\n"
230 " 2 — not inside a Muse repository\n"
231 ),
232 formatter_class=argparse.RawDescriptionHelpFormatter,
233 )
234 remove_p.add_argument("name", metavar="NAME", help="Member name to remove.")
235 remove_p.add_argument("--json", "-j", action="store_true", dest="output_json", help="Emit JSON on stdout.")
236 remove_p.set_defaults(func=run_workspace_remove)
237
238 # workspace status
239 status_p = subs.add_parser(
240 "status",
241 help="Show status of all (or one named) workspace member.",
242 description=(
243 "Report checkout status for every registered workspace member,\n"
244 "or for a single named member. Shows whether the directory is\n"
245 "present, the current HEAD commit, and whether the working tree\n"
246 "is dirty. All output fields are sanitized — ANSI control\n"
247 "sequences are stripped.\n\n"
248 "Agent quickstart\n"
249 "----------------\n"
250 " muse workspace status --json\n"
251 " muse workspace status core --json\n\n"
252 "JSON output schema (array element)\n"
253 "----------------------------------\n"
254 ' {"name": "<name>", "url": "<url>", "path": "<absolute-path>",\n'
255 ' "branch": "<branch>", "present": true|false,\n'
256 ' "head_commit": "<sha256> | null", "dirty": true|false}\n\n'
257 "Exit codes\n"
258 "----------\n"
259 " 0 — success (empty array when no members registered)\n"
260 " 1 — named member not found, or no workspace manifest\n"
261 " 2 — not inside a Muse repository\n"
262 ),
263 formatter_class=argparse.RawDescriptionHelpFormatter,
264 )
265 status_p.add_argument("name", nargs="?", default=None, metavar="NAME", help="Show only this member.")
266 status_p.add_argument("--json", "-j", action="store_true", dest="output_json", help="Emit JSON on stdout.")
267 status_p.set_defaults(func=run_workspace_status)
268
269 # workspace sync
270 sync_p = subs.add_parser(
271 "sync",
272 help="Clone or pull the latest state for workspace members.",
273 description=(
274 "Clone members that do not exist locally; pull members that do.\n"
275 "Use --workers to parallelise across members."
276 ),
277 )
278 sync_p.add_argument("name", nargs="?", default=None, metavar="NAME", help="Sync only this member (default: all).")
279 sync_p.add_argument("-n", "--dry-run", action="store_true", dest="dry_run", help="Show what would happen without doing it.")
280 sync_p.add_argument("--workers", type=int, default=1, metavar="N", help="Parallel sync workers (default: 1).")
281 sync_p.add_argument("--json", action="store_true", dest="output_json", help="Emit JSON on stdout.")
282 sync_p.set_defaults(func=run_workspace_sync)
283
284 # workspace update
285 update_p = subs.add_parser(
286 "update",
287 help="Update the URL, path, or branch for an existing member.",
288 description=(
289 "Modify a registered workspace member without re-adding it.\n"
290 "Only the supplied flags are changed; omitted fields keep their\n"
291 "current values. At least one of --url, --path, or --branch\n"
292 "must be supplied.\n\n"
293 "Agent quickstart\n"
294 "----------------\n"
295 " muse workspace update core --branch dev --json\n"
296 " muse workspace update data --url https://musehub.ai/acme/data2 --json\n\n"
297 "JSON output schema\n"
298 "------------------\n"
299 ' {"name": "<name>", "url": "<url>",\n'
300 ' "path": "<relative-path>", "branch": "<branch>"}\n\n'
301 "Exit codes\n"
302 "----------\n"
303 " 0 — success\n"
304 " 1 — member not found, no flags supplied, or invalid URL/path/branch\n"
305 " 2 — not inside a Muse repository\n"
306 ),
307 formatter_class=argparse.RawDescriptionHelpFormatter,
308 )
309 update_p.add_argument("name", metavar="NAME", help="Member name to update.")
310 update_p.add_argument("--url", default=None, metavar="URL", help="New remote URL.")
311 update_p.add_argument("--path", default=None, metavar="PATH", help="New relative checkout path.")
312 update_p.add_argument("--branch", "-b", default=None, metavar="BRANCH", help="New branch to track.")
313 update_p.add_argument("--json", "-j", action="store_true", dest="output_json", help="Emit JSON on stdout.")
314 update_p.set_defaults(func=run_workspace_update)
315
316
317 # ---------------------------------------------------------------------------
318 # Subcommand handlers
319 # ---------------------------------------------------------------------------
320
321
322 def run_workspace_add(args: argparse.Namespace) -> None:
323 """Add a member repository to the workspace manifest.
324
325 The member is *registered* in ``.muse/workspace.toml``. No network
326 I/O is performed — run ``muse workspace sync`` to clone it.
327
328 Validation rejects invalid member names, disallowed URL schemes (only
329 https/http and bare paths are allowed), and paths that escape the
330 workspace root. All output fields are sanitized — ANSI control
331 sequences are stripped before display or JSON serialisation.
332
333 JSON schema::
334
335 {
336 "name": "<member-name>",
337 "url": "<url>",
338 "path": "<relative-checkout-path>",
339 "branch": "<branch>"
340 }
341
342 Exit codes::
343
344 0 — success
345 1 — invalid name/URL/path, duplicate member, or invalid branch
346 2 — not inside a Muse repository
347 """
348 name: str = args.name
349 url: str = args.url
350 path: str = args.path
351 branch: str = args.branch
352 output_json: bool = args.output_json
353
354 # Use CWD as workspace root when no workspace.toml exists yet, enabling
355 # workspace add as a bootstrap operation without a pre-existing workspace.
356 root = find_workspace_root() or pathlib.Path.cwd()
357 try:
358 add_workspace_member(root, name, url, path=path, branch=branch)
359 except ValueError as exc:
360 print(f"❌ {exc}", file=sys.stderr)
361 raise SystemExit(ExitCode.USER_ERROR)
362
363 effective_path = path or f"repos/{name}"
364 if output_json:
365 payload = _WorkspaceAddJson(
366 name=sanitize_display(name),
367 url=sanitize_display(url),
368 path=sanitize_display(effective_path),
369 branch=sanitize_display(branch),
370 )
371 print(json.dumps(payload))
372 else:
373 print(f"✅ Added workspace member '{sanitize_display(name)}' ({sanitize_display(url)})")
374 print(" Run 'muse workspace sync' to clone it.")
375
376
377 def run_workspace_update(args: argparse.Namespace) -> None:
378 """Update the URL, path, or branch for an existing workspace member.
379
380 Only the supplied flags are changed; omitted fields keep their current
381 values. Useful for re-pointing a member at a new remote or switching
382 the tracked branch without removing and re-adding it.
383
384 At least one of ``--url``, ``--path``, or ``--branch`` must be given.
385 All output fields are sanitized — ANSI control sequences are stripped
386 before display or JSON serialisation.
387
388 JSON schema::
389
390 {
391 "name": "<member-name>",
392 "url": "<url>",
393 "path": "<relative-checkout-path>",
394 "branch": "<branch>"
395 }
396
397 Exit codes::
398
399 0 — success
400 1 — member not found, no flags supplied, or invalid URL/path/branch
401 2 — not inside a Muse repository
402 """
403 name: str = args.name
404 url: str | None = args.url
405 path: str | None = args.path
406 branch: str | None = args.branch
407 output_json: bool = args.output_json
408
409 if url is None and path is None and branch is None:
410 print("❌ Specify at least one of --url, --path, or --branch.", file=sys.stderr)
411 raise SystemExit(ExitCode.USER_ERROR)
412
413 root = find_workspace_root()
414 if root is None:
415 print(f"❌ Workspace member '{name}' not found.", file=sys.stderr)
416 raise SystemExit(ExitCode.USER_ERROR)
417 try:
418 update_workspace_member(root, name, url=url, path=path, branch=branch)
419 except ValueError as exc:
420 print(f"❌ {exc}", file=sys.stderr)
421 raise SystemExit(ExitCode.USER_ERROR)
422
423 member = get_workspace_member(root, name)
424 if output_json:
425 payload = _WorkspaceUpdateJson(
426 name=sanitize_display(member.name),
427 url=sanitize_display(member.url),
428 path=sanitize_display(str(member.path)),
429 branch=sanitize_display(member.branch),
430 )
431 print(json.dumps(payload))
432 else:
433 print(f"✅ Updated workspace member '{sanitize_display(name)}'.")
434
435
436 def run_workspace_remove(args: argparse.Namespace) -> None:
437 """Remove a member from the workspace manifest.
438
439 This does **not** delete the member's directory — only its registration
440 in the workspace manifest is removed.
441
442 JSON schema::
443
444 {"name": "<name>", "removed": true}
445
446 Exit codes:
447 0 — member removed successfully
448 1 — member not found, or no workspace manifest exists
449 2 — not inside a Muse repository
450
451 Examples::
452
453 muse workspace remove sounds
454 muse workspace remove sounds --json
455 """
456 name: str = args.name
457 output_json: bool = args.output_json
458
459 root = find_workspace_root()
460 if root is None:
461 print(f"❌ Workspace member '{name}' not found.", file=sys.stderr)
462 raise SystemExit(ExitCode.USER_ERROR)
463 try:
464 remove_workspace_member(root, name)
465 except ValueError as exc:
466 print(f"❌ {exc}", file=sys.stderr)
467 raise SystemExit(ExitCode.USER_ERROR)
468
469 if output_json:
470 payload = _WorkspaceRemoveJson(name=sanitize_display(name), removed=True)
471 print(json.dumps(payload))
472 else:
473 print(f"✅ Removed workspace member '{sanitize_display(name)}'.")
474
475
476 def run_workspace_list(args: argparse.Namespace) -> None:
477 """List all workspace members from the manifest.
478
479 Returns status for every registered member: whether the checkout
480 directory is present, the actual checked-out branch (which may differ
481 from the configured tracking branch), the HEAD commit, whether the
482 working tree is dirty, stash count, and any lingering feature branches.
483 All string fields in both text and JSON output are sanitized — ANSI
484 control sequences are stripped.
485
486 JSON schema (array element)::
487
488 {
489 "name": "<member-name>",
490 "url": "<url>",
491 "path": "<absolute-checkout-path>",
492 "branch": "<configured-tracking-branch>",
493 "present": true | false,
494 "head_commit": "<sha256>" | null,
495 "dirty": true | false,
496 "actual_branch": "<checked-out-branch>" | null,
497 "stash_count": 0,
498 "feature_branches": []
499 }
500
501 ``branch`` is the tracking branch from workspace.toml. ``actual_branch``
502 is what is currently checked out. When they differ the member is not on
503 the expected branch — surface this to the user.
504
505 Exit codes::
506
507 0 — success (empty list when no members registered)
508 2 — not inside a Muse repository
509 """
510 output_json: bool = args.output_json
511 root = find_workspace_root()
512 members = list_workspace_members(root) if root is not None else []
513
514 if output_json:
515 print(json.dumps([_member_to_json(m) for m in members]))
516 return
517
518 if not members:
519 print("No workspace members. Add one with 'muse workspace add'.")
520 return
521 header = f"{'name':<20} {'on branch':<18} {'tracking':<14} {'HEAD':<12} flags"
522 print(header)
523 print("-" * 80)
524 for m in members:
525 if not m.present:
526 print(
527 f"{'❌ ' + sanitize_display(m.name):<20} "
528 f"{'(not cloned)':<18} "
529 f"{sanitize_display(m.branch):<14} "
530 f"{'—':<12} run: muse workspace sync {sanitize_display(m.name)}"
531 )
532 continue
533 actual = sanitize_display(m.actual_branch or "unknown")
534 tracking = sanitize_display(m.branch)
535 branch_mismatch = m.actual_branch and m.actual_branch != m.branch
536 head_str = m.head_commit[:12] if m.head_commit else "unknown"
537 flags: list[str] = []
538 if m.dirty:
539 flags.append("dirty")
540 if m.stash_count:
541 flags.append(f"{m.stash_count} stash")
542 if m.feature_branches:
543 flags.append(f"branches:{','.join(sanitize_display(b) for b in m.feature_branches)}")
544 if branch_mismatch:
545 flags.append("⚠️ branch-mismatch")
546 flags_str = " ".join(flags) if flags else "clean"
547 print(
548 f"{sanitize_display(m.name):<20} "
549 f"{actual:<18} "
550 f"{tracking:<14} "
551 f"{head_str:<12} {flags_str}"
552 )
553
554
555 def run_workspace_status(args: argparse.Namespace) -> None:
556 """Show status of all (or one named) workspace members.
557
558 Without a NAME argument, reports every registered member. With NAME,
559 reports only that member. All string fields in both text and JSON output
560 are sanitized — ANSI control sequences are stripped.
561
562 JSON schema (array element)::
563
564 {
565 "name": "<member-name>",
566 "url": "<remote-url>",
567 "path": "<absolute-path>",
568 "branch": "<configured-tracking-branch>",
569 "present": true | false,
570 "head_commit": "<sha256>" | null,
571 "dirty": true | false,
572 "actual_branch": "<checked-out-branch>" | null,
573 "stash_count": 0,
574 "feature_branches": []
575 }
576
577 ``branch`` is the tracking branch from workspace.toml (the branch this
578 member *should* be on). ``actual_branch`` is the branch currently checked
579 out. When they differ the member is unexpectedly off-track.
580
581 Exit codes:
582 0 — success (empty array when no members are registered)
583 1 — named member not found, or no workspace manifest
584 2 — not inside a Muse repository
585
586 Examples::
587
588 muse workspace status
589 muse workspace status core
590 muse workspace status --json
591 muse workspace status core --json
592 """
593 output_json: bool = args.output_json
594 name: str | None = args.name
595 root = find_workspace_root()
596
597 if name is not None:
598 if root is None:
599 print("❌ No workspace manifest found.", file=sys.stderr)
600 raise SystemExit(ExitCode.USER_ERROR)
601 try:
602 members = [get_workspace_member(root, name)]
603 except ValueError as exc:
604 print(f"❌ {exc}", file=sys.stderr)
605 raise SystemExit(ExitCode.USER_ERROR)
606 else:
607 members = list_workspace_members(root) if root is not None else []
608
609 if output_json:
610 print(json.dumps([_member_to_json(m) for m in members]))
611 return
612
613 if not members:
614 print("No workspace members. Add one with 'muse workspace add'.")
615 return
616 print(f"Workspace: {root}\n")
617 for m in members:
618 if not m.present:
619 print(
620 f"❌ {sanitize_display(m.name):<20} "
621 f"NOT CHECKED OUT branch={sanitize_display(m.branch)}"
622 )
623 print(f" url: {sanitize_display(m.url)}")
624 print(f" hint: muse workspace sync {sanitize_display(m.name)}")
625 continue
626 head = m.head_commit[:12] if m.head_commit else "unknown"
627 actual = m.actual_branch or "unknown"
628 tracking = m.branch
629 branch_mismatch = m.actual_branch and m.actual_branch != m.branch
630 if branch_mismatch:
631 branch_display = (
632 f"{sanitize_display(actual)} "
633 f"⚠️ (tracking: {sanitize_display(tracking)})"
634 )
635 else:
636 branch_display = sanitize_display(actual)
637 dirty_tag = " ⚠️ dirty" if m.dirty else ""
638 print(
639 f"✅ {sanitize_display(m.name):<20} "
640 f"branch={branch_display} head={head}{dirty_tag}"
641 )
642 print(f" path: {sanitize_display(str(m.path))}")
643 print(f" url: {sanitize_display(m.url)}")
644 if m.stash_count:
645 print(f" ⚠️ stashes: {m.stash_count} — run 'muse stash list' to review")
646 if m.feature_branches:
647 fb = ", ".join(sanitize_display(b) for b in m.feature_branches)
648 print(f" ⚠️ feature branches: {fb}")
649
650
651 def run_workspace_sync(args: argparse.Namespace) -> None:
652 """Clone or pull the latest state for workspace members.
653
654 Run without arguments to sync all members. Provide a member name to
655 sync only that one. Use ``--workers`` to parallelise across members.
656
657 Examples::
658
659 muse workspace sync # sync everything, sequential
660 muse workspace sync --workers 8 # 8 parallel workers
661 muse workspace sync core # sync only 'core'
662 muse workspace sync --dry-run --json # show plan, machine-readable
663 """
664 name: str | None = args.name
665 dry_run: bool = args.dry_run
666 workers: int = args.workers
667 output_json: bool = args.output_json
668
669 root = find_workspace_root()
670 results = sync_workspace(root, member_name=name, dry_run=dry_run, workers=workers) if root is not None else []
671
672 if not results:
673 if output_json:
674 payload = _WorkspaceSyncJson(
675 dry_run=dry_run,
676 workers=workers,
677 results=[],
678 total=0,
679 ok_count=0,
680 error_count=0,
681 )
682 print(json.dumps(payload))
683 else:
684 print("No members to sync. Add one with 'muse workspace add'.")
685 return
686
687 json_results = [_sync_result_to_json(r) for r in results]
688 ok_count = sum(1 for r in json_results if r["ok"])
689 error_count = len(json_results) - ok_count
690
691 if output_json:
692 payload = _WorkspaceSyncJson(
693 dry_run=dry_run,
694 workers=workers,
695 results=json_results,
696 total=len(json_results),
697 ok_count=ok_count,
698 error_count=error_count,
699 )
700 print(json.dumps(payload))
701 return
702
703 for r in results:
704 icon = "✅" if not r["status"].startswith("error") else "❌"
705 print(f"{icon} {sanitize_display(r['name'])}: {sanitize_display(r['status'])}")
706 if error_count:
707 print(f"\n⚠️ {error_count} member(s) failed to sync.", file=sys.stderr)
708 raise SystemExit(ExitCode.INTERNAL_ERROR)
File History 2 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 32 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 32 days ago