gabriel / muse public
workspace.py python
724 lines 27.4 KB
Raw
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor ⚠ breaking 150 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 shelf_count: int # number of shelved 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 shelf_count=m.shelf_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 if output_json:
361 print(json.dumps({"error": "add_failed", "name": name, "message": str(exc)}))
362 print(f"❌ {exc}", file=sys.stderr)
363 raise SystemExit(ExitCode.USER_ERROR)
364
365 effective_path = path or f"repos/{name}"
366 if output_json:
367 payload = _WorkspaceAddJson(
368 name=sanitize_display(name),
369 url=sanitize_display(url),
370 path=sanitize_display(effective_path),
371 branch=sanitize_display(branch),
372 )
373 print(json.dumps(payload))
374 else:
375 print(f"✅ Added workspace member '{sanitize_display(name)}' ({sanitize_display(url)})")
376 print(" Run 'muse workspace sync' to clone it.")
377
378
379 def run_workspace_update(args: argparse.Namespace) -> None:
380 """Update the URL, path, or branch for an existing workspace member.
381
382 Only the supplied flags are changed; omitted fields keep their current
383 values. Useful for re-pointing a member at a new remote or switching
384 the tracked branch without removing and re-adding it.
385
386 At least one of ``--url``, ``--path``, or ``--branch`` must be given.
387 All output fields are sanitized — ANSI control sequences are stripped
388 before display or JSON serialisation.
389
390 JSON schema::
391
392 {
393 "name": "<member-name>",
394 "url": "<url>",
395 "path": "<relative-checkout-path>",
396 "branch": "<branch>"
397 }
398
399 Exit codes::
400
401 0 — success
402 1 — member not found, no flags supplied, or invalid URL/path/branch
403 2 — not inside a Muse repository
404 """
405 name: str = args.name
406 url: str | None = args.url
407 path: str | None = args.path
408 branch: str | None = args.branch
409 output_json: bool = args.output_json
410
411 if url is None and path is None and branch is None:
412 if output_json:
413 print(json.dumps({"error": "no_flags", "name": name, "message": "specify at least one of --url, --path, or --branch"}))
414 print("❌ Specify at least one of --url, --path, or --branch.", file=sys.stderr)
415 raise SystemExit(ExitCode.USER_ERROR)
416
417 root = find_workspace_root()
418 if root is None:
419 if output_json:
420 print(json.dumps({"error": "not_found", "name": name, "message": f"workspace member '{name}' not found"}))
421 print(f"❌ Workspace member '{name}' not found.", file=sys.stderr)
422 raise SystemExit(ExitCode.USER_ERROR)
423 try:
424 update_workspace_member(root, name, url=url, path=path, branch=branch)
425 except ValueError as exc:
426 if output_json:
427 print(json.dumps({"error": "update_failed", "name": name, "message": str(exc)}))
428 print(f"❌ {exc}", file=sys.stderr)
429 raise SystemExit(ExitCode.USER_ERROR)
430
431 member = get_workspace_member(root, name)
432 if output_json:
433 payload = _WorkspaceUpdateJson(
434 name=sanitize_display(member.name),
435 url=sanitize_display(member.url),
436 path=sanitize_display(str(member.path)),
437 branch=sanitize_display(member.branch),
438 )
439 print(json.dumps(payload))
440 else:
441 print(f"✅ Updated workspace member '{sanitize_display(name)}'.")
442
443
444 def run_workspace_remove(args: argparse.Namespace) -> None:
445 """Remove a member from the workspace manifest.
446
447 This does **not** delete the member's directory — only its registration
448 in the workspace manifest is removed.
449
450 JSON schema::
451
452 {"name": "<name>", "removed": true}
453
454 Exit codes:
455 0 — member removed successfully
456 1 — member not found, or no workspace manifest exists
457 2 — not inside a Muse repository
458
459 Examples::
460
461 muse workspace remove sounds
462 muse workspace remove sounds --json
463 """
464 name: str = args.name
465 output_json: bool = args.output_json
466
467 root = find_workspace_root()
468 if root is None:
469 if output_json:
470 print(json.dumps({"error": "not_found", "name": name, "message": f"workspace member '{name}' not found"}))
471 print(f"❌ Workspace member '{name}' not found.", file=sys.stderr)
472 raise SystemExit(ExitCode.USER_ERROR)
473 try:
474 remove_workspace_member(root, name)
475 except ValueError as exc:
476 if output_json:
477 print(json.dumps({"error": "remove_failed", "name": name, "message": str(exc)}))
478 print(f"❌ {exc}", file=sys.stderr)
479 raise SystemExit(ExitCode.USER_ERROR)
480
481 if output_json:
482 payload = _WorkspaceRemoveJson(name=sanitize_display(name), removed=True)
483 print(json.dumps(payload))
484 else:
485 print(f"✅ Removed workspace member '{sanitize_display(name)}'.")
486
487
488 def run_workspace_list(args: argparse.Namespace) -> None:
489 """List all workspace members from the manifest.
490
491 Returns status for every registered member: whether the checkout
492 directory is present, the actual checked-out branch (which may differ
493 from the configured tracking branch), the HEAD commit, whether the
494 working tree is dirty, shelf count, and any lingering feature branches.
495 All string fields in both text and JSON output are sanitized — ANSI
496 control sequences are stripped.
497
498 JSON schema (array element)::
499
500 {
501 "name": "<member-name>",
502 "url": "<url>",
503 "path": "<absolute-checkout-path>",
504 "branch": "<configured-tracking-branch>",
505 "present": true | false,
506 "head_commit": "<sha256>" | null,
507 "dirty": true | false,
508 "actual_branch": "<checked-out-branch>" | null,
509 "shelf_count": 0,
510 "feature_branches": []
511 }
512
513 ``branch`` is the tracking branch from workspace.toml. ``actual_branch``
514 is what is currently checked out. When they differ the member is not on
515 the expected branch — surface this to the user.
516
517 Exit codes::
518
519 0 — success (empty list when no members registered)
520 2 — not inside a Muse repository
521 """
522 output_json: bool = args.output_json
523 root = find_workspace_root()
524 members = list_workspace_members(root) if root is not None else []
525
526 if output_json:
527 print(json.dumps([_member_to_json(m) for m in members]))
528 return
529
530 if not members:
531 print("No workspace members. Add one with 'muse workspace add'.")
532 return
533 header = f"{'name':<20} {'on branch':<18} {'tracking':<14} {'HEAD':<12} flags"
534 print(header)
535 print("-" * 80)
536 for m in members:
537 if not m.present:
538 print(
539 f"{'❌ ' + sanitize_display(m.name):<20} "
540 f"{'(not cloned)':<18} "
541 f"{sanitize_display(m.branch):<14} "
542 f"{'—':<12} run: muse workspace sync {sanitize_display(m.name)}"
543 )
544 continue
545 actual = sanitize_display(m.actual_branch or "unknown")
546 tracking = sanitize_display(m.branch)
547 branch_mismatch = m.actual_branch and m.actual_branch != m.branch
548 head_str = m.head_commit[:12] if m.head_commit else "unknown"
549 flags: list[str] = []
550 if m.dirty:
551 flags.append("dirty")
552 if m.shelf_count:
553 flags.append(f"{m.shelf_count} shelf")
554 if m.feature_branches:
555 flags.append(f"branches:{','.join(sanitize_display(b) for b in m.feature_branches)}")
556 if branch_mismatch:
557 flags.append("⚠️ branch-mismatch")
558 flags_str = " ".join(flags) if flags else "clean"
559 print(
560 f"{sanitize_display(m.name):<20} "
561 f"{actual:<18} "
562 f"{tracking:<14} "
563 f"{head_str:<12} {flags_str}"
564 )
565
566
567 def run_workspace_status(args: argparse.Namespace) -> None:
568 """Show status of all (or one named) workspace members.
569
570 Without a NAME argument, reports every registered member. With NAME,
571 reports only that member. All string fields in both text and JSON output
572 are sanitized — ANSI control sequences are stripped.
573
574 JSON schema (array element)::
575
576 {
577 "name": "<member-name>",
578 "url": "<remote-url>",
579 "path": "<absolute-path>",
580 "branch": "<configured-tracking-branch>",
581 "present": true | false,
582 "head_commit": "<sha256>" | null,
583 "dirty": true | false,
584 "actual_branch": "<checked-out-branch>" | null,
585 "shelf_count": 0,
586 "feature_branches": []
587 }
588
589 ``branch`` is the tracking branch from workspace.toml (the branch this
590 member *should* be on). ``actual_branch`` is the branch currently checked
591 out. When they differ the member is unexpectedly off-track.
592
593 Exit codes:
594 0 — success (empty array when no members are registered)
595 1 — named member not found, or no workspace manifest
596 2 — not inside a Muse repository
597
598 Examples::
599
600 muse workspace status
601 muse workspace status core
602 muse workspace status --json
603 muse workspace status core --json
604 """
605 output_json: bool = args.output_json
606 name: str | None = args.name
607 root = find_workspace_root()
608
609 if name is not None:
610 if root is None:
611 if output_json:
612 print(json.dumps({"error": "no_workspace", "message": "no workspace manifest found"}))
613 print("❌ No workspace manifest found.", file=sys.stderr)
614 raise SystemExit(ExitCode.USER_ERROR)
615 try:
616 members = [get_workspace_member(root, name)]
617 except ValueError as exc:
618 if output_json:
619 print(json.dumps({"error": "not_found", "name": name, "message": str(exc)}))
620 print(f"❌ {exc}", file=sys.stderr)
621 raise SystemExit(ExitCode.USER_ERROR)
622 else:
623 members = list_workspace_members(root) if root is not None else []
624
625 if output_json:
626 print(json.dumps([_member_to_json(m) for m in members]))
627 return
628
629 if not members:
630 print("No workspace members. Add one with 'muse workspace add'.")
631 return
632 print(f"Workspace: {root}\n")
633 for m in members:
634 if not m.present:
635 print(
636 f"❌ {sanitize_display(m.name):<20} "
637 f"NOT CHECKED OUT branch={sanitize_display(m.branch)}"
638 )
639 print(f" url: {sanitize_display(m.url)}")
640 print(f" hint: muse workspace sync {sanitize_display(m.name)}")
641 continue
642 head = m.head_commit[:12] if m.head_commit else "unknown"
643 actual = m.actual_branch or "unknown"
644 tracking = m.branch
645 branch_mismatch = m.actual_branch and m.actual_branch != m.branch
646 if branch_mismatch:
647 branch_display = (
648 f"{sanitize_display(actual)} "
649 f"⚠️ (tracking: {sanitize_display(tracking)})"
650 )
651 else:
652 branch_display = sanitize_display(actual)
653 dirty_tag = " ⚠️ dirty" if m.dirty else ""
654 print(
655 f"✅ {sanitize_display(m.name):<20} "
656 f"branch={branch_display} head={head}{dirty_tag}"
657 )
658 print(f" path: {sanitize_display(str(m.path))}")
659 print(f" url: {sanitize_display(m.url)}")
660 if m.shelf_count:
661 print(f" ⚠️ shelved: {m.shelf_count} — run 'muse shelf list' to review")
662 if m.feature_branches:
663 fb = ", ".join(sanitize_display(b) for b in m.feature_branches)
664 print(f" ⚠️ feature branches: {fb}")
665
666
667 def run_workspace_sync(args: argparse.Namespace) -> None:
668 """Clone or pull the latest state for workspace members.
669
670 Run without arguments to sync all members. Provide a member name to
671 sync only that one. Use ``--workers`` to parallelise across members.
672
673 Examples::
674
675 muse workspace sync # sync everything, sequential
676 muse workspace sync --workers 8 # 8 parallel workers
677 muse workspace sync core # sync only 'core'
678 muse workspace sync --dry-run --json # show plan, machine-readable
679 """
680 name: str | None = args.name
681 dry_run: bool = args.dry_run
682 workers: int = args.workers
683 output_json: bool = args.output_json
684
685 root = find_workspace_root()
686 results = sync_workspace(root, member_name=name, dry_run=dry_run, workers=workers) if root is not None else []
687
688 if not results:
689 if output_json:
690 payload = _WorkspaceSyncJson(
691 dry_run=dry_run,
692 workers=workers,
693 results=[],
694 total=0,
695 ok_count=0,
696 error_count=0,
697 )
698 print(json.dumps(payload))
699 else:
700 print("No members to sync. Add one with 'muse workspace add'.")
701 return
702
703 json_results = [_sync_result_to_json(r) for r in results]
704 ok_count = sum(1 for r in json_results if r["ok"])
705 error_count = len(json_results) - ok_count
706
707 if output_json:
708 payload = _WorkspaceSyncJson(
709 dry_run=dry_run,
710 workers=workers,
711 results=json_results,
712 total=len(json_results),
713 ok_count=ok_count,
714 error_count=error_count,
715 )
716 print(json.dumps(payload))
717 return
718
719 for r in results:
720 icon = "✅" if not r["status"].startswith("error") else "❌"
721 print(f"{icon} {sanitize_display(r['name'])}: {sanitize_display(r['status'])}")
722 if error_count:
723 print(f"\n⚠️ {error_count} member(s) failed to sync.", file=sys.stderr)
724 raise SystemExit(ExitCode.INTERNAL_ERROR)
File History 1 commit
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 150 days ago