gabriel / muse public
workspace.py python
782 lines 31.0 KB
Raw
sha256:18b983389ee1b55900fcd799bfbb496552d2e3ecded9d18cefbfef188947a12e chore: remove blob-debug test marker file Sonnet 4.6 1 day 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 JSON envelope fields (all subcommands)
29 ---------------------------------------
30 ``exit_code``
31 Integer exit code: 0 on success, 1 on any failure. Mirrors the process
32 exit code so callers can act without inspecting inner fields.
33
34 ``duration_ms``
35 Wall-clock time for the operation in milliseconds (float).
36
37 ``members`` (list and status only)
38 Array of member status objects. Each object has ``branch_mismatch: bool``
39 which is ``true`` when the checked-out branch differs from the configured
40 tracking branch.
41 """
42
43 import argparse
44 import json
45 import pathlib
46 import sys
47 import logging
48 from typing import TypedDict
49
50 from muse.core.envelope import EnvelopeJson, make_envelope
51 from muse.core.errors import ExitCode
52 from muse.core.timing import start_timer
53 from muse.core.validation import sanitize_display
54 from muse.core.workspace import (
55 WorkspaceMemberStatus,
56 WorkspaceSyncResult,
57 add_workspace_member,
58 find_workspace_root,
59 get_workspace_member,
60 list_workspace_members,
61 remove_workspace_member,
62 require_workspace_root,
63 sync_workspace,
64 update_workspace_member,
65 )
66
67 logger = logging.getLogger(__name__)
68
69 # ---------------------------------------------------------------------------
70 # JSON wire formats
71 # ---------------------------------------------------------------------------
72
73 class _WorkspaceAddJson(EnvelopeJson):
74 name: str
75 url: str
76 path: str
77 branch: str
78
79 class _WorkspaceUpdateJson(EnvelopeJson):
80 name: str
81 url: str
82 path: str
83 branch: str
84
85 class _WorkspaceMemberJson(TypedDict):
86 name: str
87 url: str
88 path: str
89 branch: str # configured tracking branch from workspace.toml
90 present: bool
91 head_commit: str | None # actual HEAD commit (what HEAD resolves to)
92 dirty: bool
93 actual_branch: str | None # currently checked-out branch
94 shelf_count: int # number of shelved changesets
95 feature_branches: list[str] # local branches other than main / dev
96 branch_mismatch: bool # True when actual_branch != configured branch
97
98 class _WorkspaceListJson(EnvelopeJson):
99 members: list[_WorkspaceMemberJson]
100
101 class _WorkspaceStatusJson(EnvelopeJson):
102 members: list[_WorkspaceMemberJson]
103
104 class _WorkspaceRemoveJson(EnvelopeJson):
105 name: str
106 removed: bool
107
108 class _WorkspaceSyncResultJson(TypedDict):
109 name: str
110 status: str
111 ok: bool
112
113 class _WorkspaceSyncJson(EnvelopeJson):
114 dry_run: bool
115 workers: int
116 results: list[_WorkspaceSyncResultJson]
117 total: int
118 ok_count: int
119 error_count: int
120
121 # ---------------------------------------------------------------------------
122 # Helpers
123 # ---------------------------------------------------------------------------
124
125 def _member_to_json(m: WorkspaceMemberStatus) -> _WorkspaceMemberJson:
126 return _WorkspaceMemberJson(
127 name=sanitize_display(m.name),
128 url=sanitize_display(m.url),
129 path=sanitize_display(str(m.path)),
130 branch=sanitize_display(m.branch),
131 present=m.present,
132 head_commit=m.head_commit,
133 dirty=m.dirty,
134 actual_branch=sanitize_display(m.actual_branch) if m.actual_branch else None,
135 shelf_count=m.shelf_count,
136 feature_branches=[sanitize_display(b) for b in m.feature_branches],
137 branch_mismatch=bool(m.actual_branch and m.actual_branch != m.branch),
138 )
139
140 def _sync_result_to_json(r: WorkspaceSyncResult) -> _WorkspaceSyncResultJson:
141 return _WorkspaceSyncResultJson(
142 name=r["name"],
143 status=r["status"],
144 ok=not r["status"].startswith("error"),
145 )
146
147 # ---------------------------------------------------------------------------
148 # Registration
149 # ---------------------------------------------------------------------------
150
151 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
152 """Register the ``muse workspace`` subcommand tree."""
153 parser = subparsers.add_parser(
154 "workspace",
155 help="Compose multiple Muse repositories.",
156 description=__doc__,
157 formatter_class=argparse.RawDescriptionHelpFormatter,
158 )
159 subs = parser.add_subparsers(dest="subcommand", metavar="SUBCOMMAND")
160 subs.required = True
161
162 # workspace add
163 add_p = subs.add_parser(
164 "add",
165 help="Add a member repository to the workspace manifest.",
166 description=(
167 "Register a member repository in .muse/workspace.toml.\n"
168 "No network I/O — run 'muse workspace sync' to clone it.\n\n"
169 "NAME must be 1–64 alphanumeric characters, hyphens, underscores,\n"
170 "or dots. URL must be https://, http://, or a bare filesystem\n"
171 "path. PATH must not escape the workspace root.\n\n"
172 "Agent quickstart\n"
173 "----------------\n"
174 " muse workspace add core https://musehub.ai/acme/core --json\n"
175 " muse workspace add data /local/dataset --branch v2 --json\n\n"
176 "JSON output schema\n"
177 "------------------\n"
178 ' {"name": "<name>", "url": "<url>",\n'
179 ' "path": "<relative-path>", "branch": "<branch>"}\n\n'
180 "Exit codes\n"
181 "----------\n"
182 " 0 — success\n"
183 " 1 — invalid name/URL/path, duplicate member, or invalid branch\n"
184 " 2 — not inside a Muse repository\n"
185 ),
186 formatter_class=argparse.RawDescriptionHelpFormatter,
187 )
188 add_p.add_argument("name", metavar="NAME", help="Member name (alphanumeric, hyphens, underscores, dots).")
189 add_p.add_argument("url", metavar="URL", help="Remote URL (https/http) or local path of the member repository.")
190 add_p.add_argument("--path", default="", metavar="PATH", help="Relative checkout path (default: repos/<name>).")
191 add_p.add_argument("--branch", "-b", default="main", metavar="BRANCH", help="Branch to track (default: main).")
192 add_p.add_argument("--json", "-j", action="store_true", dest="json_out", help="Emit JSON on stdout.")
193 add_p.set_defaults(func=run_workspace_add, json_out=False)
194
195 # workspace list
196 list_p = subs.add_parser(
197 "list",
198 help="List all workspace members from the manifest.",
199 description=(
200 "List every registered workspace member with its checkout status.\n\n"
201 "Each entry shows whether the directory is present, whether the\n"
202 "working tree is dirty, and the current HEAD commit. All output\n"
203 "fields are sanitized — ANSI control sequences are stripped.\n\n"
204 "Agent quickstart\n"
205 "----------------\n"
206 " muse workspace list --json\n\n"
207 "JSON output schema (array element)\n"
208 "----------------------------------\n"
209 ' {"name": "<name>", "url": "<url>", "path": "<absolute-path>",\n'
210 ' "branch": "<branch>", "present": true|false,\n'
211 ' "head_commit": "<sha256> | null", "dirty": true|false}\n\n'
212 "Exit codes\n"
213 "----------\n"
214 " 0 — success (empty list when no members registered)\n"
215 " 2 — not inside a Muse repository\n"
216 ),
217 formatter_class=argparse.RawDescriptionHelpFormatter,
218 )
219 list_p.add_argument("--json", "-j", action="store_true", dest="json_out", help="Emit JSON on stdout.")
220 list_p.set_defaults(func=run_workspace_list, json_out=False)
221
222 # workspace remove
223 remove_p = subs.add_parser(
224 "remove",
225 help="Remove a member from the workspace manifest (does not delete its directory).",
226 description=(
227 "Unregister a member from .muse/workspace.toml.\n"
228 "The member's checked-out directory is left untouched — only\n"
229 "the manifest entry is deleted.\n\n"
230 "Agent quickstart\n"
231 "----------------\n"
232 " muse workspace remove sounds --json\n\n"
233 "JSON output schema\n"
234 "------------------\n"
235 ' {"name": "<name>", "removed": true}\n\n'
236 "Exit codes\n"
237 "----------\n"
238 " 0 — member removed successfully\n"
239 " 1 — member not found, or no workspace manifest exists\n"
240 " 2 — not inside a Muse repository\n"
241 ),
242 formatter_class=argparse.RawDescriptionHelpFormatter,
243 )
244 remove_p.add_argument("name", metavar="NAME", help="Member name to remove.")
245 remove_p.add_argument("--json", "-j", action="store_true", dest="json_out", help="Emit JSON on stdout.")
246 remove_p.set_defaults(func=run_workspace_remove, json_out=False)
247
248 # workspace status
249 status_p = subs.add_parser(
250 "status",
251 help="Show status of all (or one named) workspace member.",
252 description=(
253 "Report checkout status for every registered workspace member,\n"
254 "or for a single named member. Shows whether the directory is\n"
255 "present, the current HEAD commit, and whether the working tree\n"
256 "is dirty. All output fields are sanitized — ANSI control\n"
257 "sequences are stripped.\n\n"
258 "Agent quickstart\n"
259 "----------------\n"
260 " muse workspace status --json\n"
261 " muse workspace status core --json\n\n"
262 "JSON output schema (array element)\n"
263 "----------------------------------\n"
264 ' {"name": "<name>", "url": "<url>", "path": "<absolute-path>",\n'
265 ' "branch": "<branch>", "present": true|false,\n'
266 ' "head_commit": "<sha256> | null", "dirty": true|false}\n\n'
267 "Exit codes\n"
268 "----------\n"
269 " 0 — success (empty array when no members registered)\n"
270 " 1 — named member not found, or no workspace manifest\n"
271 " 2 — not inside a Muse repository\n"
272 ),
273 formatter_class=argparse.RawDescriptionHelpFormatter,
274 )
275 status_p.add_argument("name", nargs="?", default=None, metavar="NAME", help="Show only this member.")
276 status_p.add_argument("--json", "-j", action="store_true", dest="json_out", help="Emit JSON on stdout.")
277 status_p.set_defaults(func=run_workspace_status, json_out=False)
278
279 # workspace sync
280 sync_p = subs.add_parser(
281 "sync",
282 help="Clone or pull the latest state for workspace members.",
283 description=(
284 "Clone members that do not exist locally; pull members that do.\n"
285 "Use --workers to parallelise across members."
286 ),
287 )
288 sync_p.add_argument("name", nargs="?", default=None, metavar="NAME", help="Sync only this member (default: all).")
289 sync_p.add_argument("-n", "--dry-run", action="store_true", dest="dry_run", help="Show what would happen without doing it.")
290 sync_p.add_argument("--workers", type=int, default=1, metavar="N", help="Parallel sync workers (default: 1).")
291 sync_p.add_argument("--json", "-j", action="store_true", dest="json_out", help="Emit JSON on stdout.")
292 sync_p.set_defaults(func=run_workspace_sync, json_out=False)
293
294 # workspace update
295 update_p = subs.add_parser(
296 "update",
297 help="Update the URL, path, or branch for an existing member.",
298 description=(
299 "Modify a registered workspace member without re-adding it.\n"
300 "Only the supplied flags are changed; omitted fields keep their\n"
301 "current values. At least one of --url, --path, or --branch\n"
302 "must be supplied.\n\n"
303 "Agent quickstart\n"
304 "----------------\n"
305 " muse workspace update core --branch dev --json\n"
306 " muse workspace update data --url https://musehub.ai/acme/data2 --json\n\n"
307 "JSON output schema\n"
308 "------------------\n"
309 ' {"name": "<name>", "url": "<url>",\n'
310 ' "path": "<relative-path>", "branch": "<branch>"}\n\n'
311 "Exit codes\n"
312 "----------\n"
313 " 0 — success\n"
314 " 1 — member not found, no flags supplied, or invalid URL/path/branch\n"
315 " 2 — not inside a Muse repository\n"
316 ),
317 formatter_class=argparse.RawDescriptionHelpFormatter,
318 )
319 update_p.add_argument("name", metavar="NAME", help="Member name to update.")
320 update_p.add_argument("--url", default=None, metavar="URL", help="New remote URL.")
321 update_p.add_argument("--path", default=None, metavar="PATH", help="New relative checkout path.")
322 update_p.add_argument("--branch", "-b", default=None, metavar="BRANCH", help="New branch to track.")
323 update_p.add_argument("--json", "-j", action="store_true", dest="json_out", help="Emit JSON on stdout.")
324 update_p.set_defaults(func=run_workspace_update, json_out=False)
325
326 # ---------------------------------------------------------------------------
327 # Subcommand handlers
328 # ---------------------------------------------------------------------------
329
330 def run_workspace_add(args: argparse.Namespace) -> None:
331 """Register a member repository in the workspace manifest.
332
333 Writes to ``.muse/workspace.toml`` without any network I/O — run
334 ``muse workspace sync`` afterward to clone the member. Rejects invalid
335 names, disallowed URL schemes, and paths that escape the workspace root.
336
337 Agent quickstart::
338
339 muse workspace add core https://musehub.ai/acme/core --json
340 muse workspace add sounds https://musehub.ai/acme/sounds --branch v2 --json
341
342 JSON fields::
343
344 name Registered member name.
345 url Remote URL.
346 path Relative checkout path (default: repos/<name>).
347 branch Tracking branch (default: main).
348 muse_version Muse release that produced this output.
349 schema Envelope schema version (int).
350 exit_code 0 success, 1 validation error.
351 duration_ms Wall-clock milliseconds for the command.
352 timestamp ISO-8601 UTC timestamp of command completion.
353 warnings List of non-fatal advisory messages.
354
355 Exit codes::
356
357 0 Success.
358 1 Invalid name/URL/path, duplicate member, or invalid branch.
359 """
360 name: str = args.name
361 url: str = args.url
362 path: str = args.path
363 branch: str = args.branch
364 json_out: bool = args.json_out
365
366 elapsed = start_timer()
367 # Use CWD as workspace root when no workspace.toml exists yet, enabling
368 # workspace add as a bootstrap operation without a pre-existing workspace.
369 root = find_workspace_root() or pathlib.Path.cwd()
370 try:
371 add_workspace_member(root, name, url, path=path, branch=branch)
372 except ValueError as exc:
373 if json_out:
374 print(json.dumps({**make_envelope(elapsed, exit_code=int(ExitCode.USER_ERROR)),
375 "error": "add_failed", "name": name, "message": str(exc)}))
376 print(f"❌ {exc}", file=sys.stderr)
377 raise SystemExit(ExitCode.USER_ERROR)
378
379 effective_path = path or f"repos/{name}"
380 if json_out:
381 payload = _WorkspaceAddJson(
382 **make_envelope(elapsed),
383 name=sanitize_display(name),
384 url=sanitize_display(url),
385 path=sanitize_display(effective_path),
386 branch=sanitize_display(branch),
387 )
388 print(json.dumps(payload))
389 else:
390 print(f"✅ Added workspace member '{sanitize_display(name)}' ({sanitize_display(url)})")
391 print(" Run 'muse workspace sync' to clone it.")
392
393 def run_workspace_update(args: argparse.Namespace) -> None:
394 """Update the URL, path, or branch for an existing workspace member.
395
396 Only the supplied flags are changed; omitted fields keep their current
397 values. At least one of ``--url``, ``--path``, or ``--branch`` must be
398 given. All string fields are sanitized before JSON serialisation.
399
400 Agent quickstart::
401
402 muse workspace update core --branch dev --json
403 muse workspace update data --url https://musehub.ai/acme/data2 --json
404
405 JSON fields::
406
407 name Updated member name.
408 url Current remote URL.
409 path Current relative checkout path.
410 branch Current tracking branch.
411 muse_version Muse release that produced this output.
412 schema Envelope schema version (int).
413 exit_code 0 success, 1 error.
414 duration_ms Wall-clock milliseconds for the command.
415 timestamp ISO-8601 UTC timestamp of command completion.
416 warnings List of non-fatal advisory messages.
417
418 Exit codes::
419
420 0 Success.
421 1 Member not found, no flags supplied, or invalid URL/path/branch.
422 """
423 name: str = args.name
424 url: str | None = args.url
425 path: str | None = args.path
426 branch: str | None = args.branch
427 json_out: bool = args.json_out
428
429 elapsed = start_timer()
430 if url is None and path is None and branch is None:
431 if json_out:
432 print(json.dumps({**make_envelope(elapsed, exit_code=int(ExitCode.USER_ERROR)),
433 "error": "no_flags", "name": name,
434 "message": "specify at least one of --url, --path, or --branch"}))
435 print("❌ Specify at least one of --url, --path, or --branch.", file=sys.stderr)
436 raise SystemExit(ExitCode.USER_ERROR)
437
438 root = find_workspace_root()
439 if root is None:
440 if json_out:
441 print(json.dumps({**make_envelope(elapsed, exit_code=int(ExitCode.USER_ERROR)),
442 "error": "not_found", "name": name,
443 "message": f"workspace member '{name}' not found"}))
444 print(f"❌ Workspace member '{name}' not found.", file=sys.stderr)
445 raise SystemExit(ExitCode.USER_ERROR)
446 try:
447 update_workspace_member(root, name, url=url, path=path, branch=branch)
448 except ValueError as exc:
449 if json_out:
450 print(json.dumps({**make_envelope(elapsed, exit_code=int(ExitCode.USER_ERROR)),
451 "error": "update_failed", "name": name, "message": str(exc)}))
452 print(f"❌ {exc}", file=sys.stderr)
453 raise SystemExit(ExitCode.USER_ERROR)
454
455 member = get_workspace_member(root, name)
456 if json_out:
457 payload = _WorkspaceUpdateJson(
458 **make_envelope(elapsed),
459 name=sanitize_display(member.name),
460 url=sanitize_display(member.url),
461 path=sanitize_display(str(member.path)),
462 branch=sanitize_display(member.branch),
463 )
464 print(json.dumps(payload))
465 else:
466 print(f"✅ Updated workspace member '{sanitize_display(name)}'.")
467
468 def run_workspace_remove(args: argparse.Namespace) -> None:
469 """Remove a member from the workspace manifest (does not delete files).
470
471 Only its registration in ``.muse/workspace.toml`` is removed. The
472 checkout directory on disk is left untouched.
473
474 Agent quickstart::
475
476 muse workspace remove sounds --json
477
478 JSON fields::
479
480 name Member name that was removed.
481 removed Always true on success.
482 muse_version Muse release that produced this output.
483 schema Envelope schema version (int).
484 exit_code 0 success, 1 error.
485 duration_ms Wall-clock milliseconds for the command.
486 timestamp ISO-8601 UTC timestamp of command completion.
487 warnings List of non-fatal advisory messages.
488
489 Exit codes::
490
491 0 Member removed successfully.
492 1 Member not found, or no workspace manifest exists.
493
494 Examples::
495
496 muse workspace remove sounds
497 muse workspace remove sounds --json
498 """
499 name: str = args.name
500 json_out: bool = args.json_out
501
502 elapsed = start_timer()
503 root = find_workspace_root()
504 if root is None:
505 if json_out:
506 print(json.dumps({**make_envelope(elapsed, exit_code=int(ExitCode.USER_ERROR)),
507 "error": "not_found", "name": name,
508 "message": f"workspace member '{name}' not found"}))
509 print(f"❌ Workspace member '{name}' not found.", file=sys.stderr)
510 raise SystemExit(ExitCode.USER_ERROR)
511 try:
512 remove_workspace_member(root, name)
513 except ValueError as exc:
514 if json_out:
515 print(json.dumps({**make_envelope(elapsed, exit_code=int(ExitCode.USER_ERROR)),
516 "error": "remove_failed", "name": name, "message": str(exc)}))
517 print(f"❌ {exc}", file=sys.stderr)
518 raise SystemExit(ExitCode.USER_ERROR)
519
520 if json_out:
521 payload = _WorkspaceRemoveJson(**make_envelope(elapsed), name=sanitize_display(name), removed=True)
522 print(json.dumps(payload))
523 else:
524 print(f"✅ Removed workspace member '{sanitize_display(name)}'.")
525
526 def run_workspace_list(args: argparse.Namespace) -> None:
527 """List all workspace members and their current status.
528
529 Returns status for every registered member: presence, actual branch,
530 HEAD commit, dirty state, shelf count, and feature branches. An empty
531 member list exits 0 — no workspace manifest is silently treated as
532 an empty workspace.
533
534 Agent quickstart::
535
536 muse workspace list --json
537
538 JSON fields::
539
540 members List of member status objects (name, url, path, branch,
541 present, head_commit, dirty, actual_branch, shelf_count,
542 feature_branches, branch_mismatch).
543 muse_version Muse release that produced this output.
544 schema Envelope schema version (int).
545 exit_code Always 0.
546 duration_ms Wall-clock milliseconds for the command.
547 timestamp ISO-8601 UTC timestamp of command completion.
548 warnings List of non-fatal advisory messages.
549
550 Exit codes::
551
552 0 Success (empty list when no members registered).
553 """
554 json_out: bool = args.json_out
555 elapsed = start_timer()
556 root = find_workspace_root()
557 members = list_workspace_members(root) if root is not None else []
558
559 if json_out:
560 payload = _WorkspaceListJson(
561 **make_envelope(elapsed),
562 members=[_member_to_json(m) for m in members],
563 )
564 print(json.dumps(payload))
565 return
566
567 if not members:
568 print("No workspace members. Add one with 'muse workspace add'.")
569 return
570 header = f"{'name':<20} {'on branch':<18} {'tracking':<14} {'HEAD':<12} flags"
571 print(header)
572 print("-" * 80)
573 for m in members:
574 if not m.present:
575 print(
576 f"{'❌ ' + sanitize_display(m.name):<20} "
577 f"{'(not cloned)':<18} "
578 f"{sanitize_display(m.branch):<14} "
579 f"{'—':<12} run: muse workspace sync {sanitize_display(m.name)}"
580 )
581 continue
582 actual = sanitize_display(m.actual_branch or "unknown")
583 tracking = sanitize_display(m.branch)
584 branch_mismatch = m.actual_branch and m.actual_branch != m.branch
585 head_str = m.head_commit if m.head_commit else "unknown"
586 flags: list[str] = []
587 if m.dirty:
588 flags.append("dirty")
589 if m.shelf_count:
590 flags.append(f"{m.shelf_count} shelf")
591 if m.feature_branches:
592 flags.append(f"branches:{','.join(sanitize_display(b) for b in m.feature_branches)}")
593 if branch_mismatch:
594 flags.append("⚠️ branch-mismatch")
595 flags_str = " ".join(flags) if flags else "clean"
596 print(
597 f"{sanitize_display(m.name):<20} "
598 f"{actual:<18} "
599 f"{tracking:<14} "
600 f"{head_str:<12} {flags_str}"
601 )
602
603 def run_workspace_status(args: argparse.Namespace) -> None:
604 """Show status of all (or one named) workspace members.
605
606 Without NAME, reports every registered member. With NAME, reports only
607 that member. ``branch_mismatch`` is true when ``actual_branch`` (currently
608 checked out) differs from the configured tracking branch.
609
610 Agent quickstart::
611
612 muse workspace status --json
613 muse workspace status core --json
614
615 JSON fields::
616
617 members List of member status objects (name, url, path, branch,
618 present, head_commit, dirty, actual_branch, shelf_count,
619 feature_branches, branch_mismatch).
620 muse_version Muse release that produced this output.
621 schema Envelope schema version (int).
622 exit_code 0 success, 1 named member not found.
623 duration_ms Wall-clock milliseconds for the command.
624 timestamp ISO-8601 UTC timestamp of command completion.
625 warnings List of non-fatal advisory messages.
626
627 Exit codes::
628
629 0 Success (empty array when no members registered).
630 1 Named member not found, or no workspace manifest.
631 """
632 json_out: bool = args.json_out
633 name: str | None = args.name
634 elapsed = start_timer()
635 root = find_workspace_root()
636
637 if name is not None:
638 if root is None:
639 if json_out:
640 print(json.dumps({**make_envelope(elapsed, exit_code=int(ExitCode.USER_ERROR)),
641 "error": "no_workspace", "message": "no workspace manifest found"}))
642 print("❌ No workspace manifest found.", file=sys.stderr)
643 raise SystemExit(ExitCode.USER_ERROR)
644 try:
645 members = [get_workspace_member(root, name)]
646 except ValueError as exc:
647 if json_out:
648 print(json.dumps({**make_envelope(elapsed, exit_code=int(ExitCode.USER_ERROR)),
649 "error": "not_found", "name": name, "message": str(exc)}))
650 print(f"❌ {exc}", file=sys.stderr)
651 raise SystemExit(ExitCode.USER_ERROR)
652 else:
653 members = list_workspace_members(root) if root is not None else []
654
655 if json_out:
656 payload = _WorkspaceStatusJson(
657 **make_envelope(elapsed),
658 members=[_member_to_json(m) for m in members],
659 )
660 print(json.dumps(payload))
661 return
662
663 if not members:
664 print("No workspace members. Add one with 'muse workspace add'.")
665 return
666 print(f"Workspace: {root}\n")
667 for m in members:
668 if not m.present:
669 print(
670 f"❌ {sanitize_display(m.name):<20} "
671 f"NOT CHECKED OUT branch={sanitize_display(m.branch)}"
672 )
673 print(f" url: {sanitize_display(m.url)}")
674 print(f" hint: muse workspace sync {sanitize_display(m.name)}")
675 continue
676 head = m.head_commit if m.head_commit else "unknown"
677 actual = m.actual_branch or "unknown"
678 tracking = m.branch
679 branch_mismatch = m.actual_branch and m.actual_branch != m.branch
680 if branch_mismatch:
681 branch_display = (
682 f"{sanitize_display(actual)} "
683 f"⚠️ (tracking: {sanitize_display(tracking)})"
684 )
685 else:
686 branch_display = sanitize_display(actual)
687 dirty_tag = " ⚠️ dirty" if m.dirty else ""
688 print(
689 f"✅ {sanitize_display(m.name):<20} "
690 f"branch={branch_display} head={head}{dirty_tag}"
691 )
692 print(f" path: {sanitize_display(str(m.path))}")
693 print(f" url: {sanitize_display(m.url)}")
694 if m.shelf_count:
695 print(f" ⚠️ shelved: {m.shelf_count} — run 'muse shelf list' to review")
696 if m.feature_branches:
697 fb = ", ".join(sanitize_display(b) for b in m.feature_branches)
698 print(f" ⚠️ feature branches: {fb}")
699
700 def run_workspace_sync(args: argparse.Namespace) -> None:
701 """Clone or pull the latest state for workspace members.
702
703 Without NAME, syncs all members. With NAME, syncs only that one.
704 Parallel workers default to 1; use ``--workers N`` to clone members
705 concurrently. ``--dry-run`` shows what would happen without touching disk.
706
707 Agent quickstart::
708
709 muse workspace sync --json
710 muse workspace sync --workers 8 --json
711 muse workspace sync core --json
712 muse workspace sync --dry-run --json
713
714 JSON fields::
715
716 dry_run true when --dry-run was passed.
717 workers Number of parallel workers used.
718 results List of {name, status, ok} per member.
719 total Total member count.
720 ok_count Members that succeeded.
721 error_count Members that failed.
722 muse_version Muse release that produced this output.
723 schema Envelope schema version (int).
724 exit_code 0 all ok, 3 any member failed.
725 duration_ms Wall-clock milliseconds for the command.
726 timestamp ISO-8601 UTC timestamp of command completion.
727 warnings List of non-fatal advisory messages.
728
729 Exit codes::
730
731 0 All members synced successfully.
732 3 One or more members failed to sync.
733 """
734 name: str | None = args.name
735 dry_run: bool = args.dry_run
736 workers: int = args.workers
737 json_out: bool = args.json_out
738
739 elapsed = start_timer()
740 root = find_workspace_root()
741 results = sync_workspace(root, member_name=name, dry_run=dry_run, workers=workers) if root is not None else []
742
743 if not results:
744 if json_out:
745 payload = _WorkspaceSyncJson(
746 **make_envelope(elapsed),
747 dry_run=dry_run,
748 workers=workers,
749 results=[],
750 total=0,
751 ok_count=0,
752 error_count=0,
753 )
754 print(json.dumps(payload))
755 else:
756 print("No members to sync. Add one with 'muse workspace add'.")
757 return
758
759 json_results = [_sync_result_to_json(r) for r in results]
760 ok_count = sum(1 for r in json_results if r["ok"])
761 error_count = len(json_results) - ok_count
762 exit_code = int(ExitCode.INTERNAL_ERROR) if error_count else 0
763
764 if json_out:
765 payload = _WorkspaceSyncJson(
766 **make_envelope(elapsed, exit_code=exit_code),
767 dry_run=dry_run,
768 workers=workers,
769 results=json_results,
770 total=len(json_results),
771 ok_count=ok_count,
772 error_count=error_count,
773 )
774 print(json.dumps(payload))
775 return
776
777 for r in results:
778 icon = "✅" if not r["status"].startswith("error") else "❌"
779 print(f"{icon} {sanitize_display(r['name'])}: {sanitize_display(r['status'])}")
780 if error_count:
781 print(f"\n⚠️ {error_count} member(s) failed to sync.", file=sys.stderr)
782 raise SystemExit(ExitCode.INTERNAL_ERROR)
File History 7 commits
sha256:18b983389ee1b55900fcd799bfbb496552d2e3ecded9d18cefbfef188947a12e chore: remove blob-debug test marker file Sonnet 4.6 1 day ago
sha256:e452ad9a6ace6ccc6d875a35e06caf9da5576a970c1c36133b69a891ce5fefa8 chore: prebuild timing test Sonnet 4.6 8 days ago
sha256:0008ab6695e3e064b3e236b24fd19e538fef6a588eb0d211622f4466d919c0b1 merge: pull staging/dev — advance to 0.2.0rc12 Sonnet 4.6 patch 10 days ago
sha256:9c33d61749fff814c5226d5386aa2af7064c2c02788594a25fdd709358132eea fix: _PROPOSAL_PREFIX_RESOLVE_LIMIT 200 → 100 to match hub … Sonnet 4.6 21 days ago
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e fix: rename objects→blobs in push client and all stale test… Sonnet 4.6 patch 24 days ago
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a fix: repair four test failures from post-migration audit Sonnet 4.6 patch 30 days ago
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf fix: unified object store migration — idempotent writes, JS… Sonnet 4.6 minor 30 days ago