workspace.py
python
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