describe.py
python
| 1 | """muse describe — generate a structured description of what changed musically. |
| 2 | |
| 3 | Compares a commit against its parent (or two commits via ``--compare``) and |
| 4 | produces a structured description of the snapshot diff: which files changed |
| 5 | and how many. Depth controls verbosity: |
| 6 | |
| 7 | - **brief** — one-line summary (commit ID + file count) |
| 8 | - **standard** — commit message, changed files list, dimensions (default) |
| 9 | - **verbose** — standard plus parent commit info and full file paths |
| 10 | |
| 11 | NOTE: Full harmonic/melodic analysis (identifying chord progressions, melodic |
| 12 | motifs, rhythmic changes) requires ``muse harmony`` and ``muse motif`` — both |
| 13 | planned enhancements tracked in follow-up issues. This implementation uses |
| 14 | the snapshot manifest diff as a structural proxy: the set of files added, |
| 15 | removed, or modified between two commits. |
| 16 | |
| 17 | Output formats |
| 18 | -------------- |
| 19 | Default (human-readable):: |
| 20 | |
| 21 | Commit abc1234: "Add piano melody to verse" |
| 22 | Changed files: 2 (beat.mid, keys.mid) |
| 23 | Dimensions analyzed: structural (2 files modified) |
| 24 | Note: Full harmonic/melodic analysis requires muse harmony and muse motif (planned) |
| 25 | |
| 26 | JSON (``--json``):: |
| 27 | |
| 28 | { |
| 29 | "commit": "abc1234...", |
| 30 | "message": "Add piano melody to verse", |
| 31 | "depth": "standard", |
| 32 | "changed_files": ["beat.mid", "keys.mid"], |
| 33 | "added_files": [], |
| 34 | "removed_files": [], |
| 35 | "dimensions": ["structural"], |
| 36 | "file_count": 2, |
| 37 | "parent": "def5678...", |
| 38 | "note": "Full harmonic/melodic analysis requires muse harmony and muse motif (planned)" |
| 39 | } |
| 40 | |
| 41 | Auto-tag (``--auto-tag``) |
| 42 | -------------------------- |
| 43 | When ``--auto-tag`` is given, a suggested tag is printed (or included in JSON) |
| 44 | based on the file count and dimensions. This is a heuristic stub — a full |
| 45 | tagger would classify by musical dimension (rhythm, harmony, melody, etc.) |
| 46 | using instrument metadata. |
| 47 | """ |
| 48 | from __future__ import annotations |
| 49 | |
| 50 | import asyncio |
| 51 | import json |
| 52 | import logging |
| 53 | import pathlib |
| 54 | from enum import Enum |
| 55 | from typing import Optional |
| 56 | |
| 57 | import typer |
| 58 | from sqlalchemy.ext.asyncio import AsyncSession |
| 59 | |
| 60 | from maestro.muse_cli._repo import require_repo |
| 61 | from maestro.muse_cli.db import open_session |
| 62 | from maestro.muse_cli.errors import ExitCode |
| 63 | from maestro.muse_cli.models import MuseCliCommit, MuseCliSnapshot |
| 64 | |
| 65 | logger = logging.getLogger(__name__) |
| 66 | |
| 67 | app = typer.Typer() |
| 68 | |
| 69 | _LLM_NOTE = ( |
| 70 | "Full harmonic/melodic analysis requires muse harmony and muse motif (planned)" |
| 71 | ) |
| 72 | |
| 73 | |
| 74 | # --------------------------------------------------------------------------- |
| 75 | # Depth enum |
| 76 | # --------------------------------------------------------------------------- |
| 77 | |
| 78 | |
| 79 | class DescribeDepth(str, Enum): |
| 80 | """Verbosity level for ``muse describe`` output.""" |
| 81 | |
| 82 | brief = "brief" |
| 83 | standard = "standard" |
| 84 | verbose = "verbose" |
| 85 | |
| 86 | |
| 87 | # --------------------------------------------------------------------------- |
| 88 | # Core data types |
| 89 | # --------------------------------------------------------------------------- |
| 90 | |
| 91 | |
| 92 | class DescribeResult: |
| 93 | """Structured description of what changed between two commits. |
| 94 | |
| 95 | Returned by ``_describe_async`` and consumed by both the human-readable |
| 96 | renderer and the JSON serialiser. |
| 97 | """ |
| 98 | |
| 99 | def __init__( |
| 100 | self, |
| 101 | *, |
| 102 | commit_id: str, |
| 103 | message: str, |
| 104 | depth: DescribeDepth, |
| 105 | parent_id: str | None, |
| 106 | compare_commit_id: str | None, |
| 107 | changed_files: list[str], |
| 108 | added_files: list[str], |
| 109 | removed_files: list[str], |
| 110 | dimensions: list[str], |
| 111 | auto_tag: str | None, |
| 112 | ) -> None: |
| 113 | self.commit_id = commit_id |
| 114 | self.message = message |
| 115 | self.depth = depth |
| 116 | self.parent_id = parent_id |
| 117 | self.compare_commit_id = compare_commit_id |
| 118 | self.changed_files = changed_files |
| 119 | self.added_files = added_files |
| 120 | self.removed_files = removed_files |
| 121 | self.dimensions = dimensions |
| 122 | self.auto_tag = auto_tag |
| 123 | |
| 124 | def file_count(self) -> int: |
| 125 | return len(self.changed_files) + len(self.added_files) + len(self.removed_files) |
| 126 | |
| 127 | def to_dict(self) -> dict[str, object]: |
| 128 | """Serialise to a JSON-compatible dict.""" |
| 129 | result: dict[str, object] = { |
| 130 | "commit": self.commit_id, |
| 131 | "message": self.message, |
| 132 | "depth": self.depth.value, |
| 133 | "changed_files": self.changed_files, |
| 134 | "added_files": self.added_files, |
| 135 | "removed_files": self.removed_files, |
| 136 | "dimensions": self.dimensions, |
| 137 | "file_count": self.file_count(), |
| 138 | "parent": self.parent_id, |
| 139 | "note": _LLM_NOTE, |
| 140 | } |
| 141 | if self.compare_commit_id is not None: |
| 142 | result["compare_commit"] = self.compare_commit_id |
| 143 | if self.auto_tag is not None: |
| 144 | result["auto_tag"] = self.auto_tag |
| 145 | return result |
| 146 | |
| 147 | |
| 148 | # --------------------------------------------------------------------------- |
| 149 | # Snapshot diff helpers |
| 150 | # --------------------------------------------------------------------------- |
| 151 | |
| 152 | |
| 153 | def _diff_manifests( |
| 154 | base: dict[str, str], |
| 155 | target: dict[str, str], |
| 156 | ) -> tuple[list[str], list[str], list[str]]: |
| 157 | """Compute the diff between two snapshot manifests. |
| 158 | |
| 159 | Returns ``(changed, added, removed)`` where each entry is a relative |
| 160 | file path (as stored in the manifest keys). |
| 161 | |
| 162 | - *changed* — path exists in both manifests but object_id differs |
| 163 | - *added* — path exists in *target* but not *base* |
| 164 | - *removed* — path exists in *base* but not *target* |
| 165 | """ |
| 166 | all_paths = set(base) | set(target) |
| 167 | changed: list[str] = [] |
| 168 | added: list[str] = [] |
| 169 | removed: list[str] = [] |
| 170 | for path in sorted(all_paths): |
| 171 | base_obj = base.get(path) |
| 172 | target_obj = target.get(path) |
| 173 | if base_obj is None: |
| 174 | added.append(path) |
| 175 | elif target_obj is None: |
| 176 | removed.append(path) |
| 177 | elif base_obj != target_obj: |
| 178 | changed.append(path) |
| 179 | return changed, added, removed |
| 180 | |
| 181 | |
| 182 | def _infer_dimensions( |
| 183 | changed: list[str], |
| 184 | added: list[str], |
| 185 | removed: list[str], |
| 186 | requested: list[str], |
| 187 | ) -> list[str]: |
| 188 | """Infer musical dimensions from the file diff. |
| 189 | |
| 190 | This is a heuristic stub — always returns ``["structural"]`` with the |
| 191 | file count as context. A full implementation would inspect MIDI metadata |
| 192 | to classify changes as rhythmic, harmonic, or melodic. |
| 193 | """ |
| 194 | if requested: |
| 195 | return [d.strip() for d in requested if d.strip()] |
| 196 | total = len(changed) + len(added) + len(removed) |
| 197 | if total == 0: |
| 198 | return [] |
| 199 | return [f"structural ({total} file{'s' if total != 1 else ''} modified)"] |
| 200 | |
| 201 | |
| 202 | def _suggest_tag(dimensions: list[str], file_count: int) -> str: |
| 203 | """Return a heuristic tag based on dimensions and file count. |
| 204 | |
| 205 | Stub implementation — a full tagger would classify by instrument and |
| 206 | MIDI content. |
| 207 | """ |
| 208 | if file_count == 0: |
| 209 | return "no-change" |
| 210 | if file_count == 1: |
| 211 | return "single-file-edit" |
| 212 | if file_count <= 3: |
| 213 | return "minor-revision" |
| 214 | return "major-revision" |
| 215 | |
| 216 | |
| 217 | # --------------------------------------------------------------------------- |
| 218 | # Async core — fully injectable for tests |
| 219 | # --------------------------------------------------------------------------- |
| 220 | |
| 221 | |
| 222 | async def _load_commit_with_snapshot( |
| 223 | session: AsyncSession, |
| 224 | commit_id: str, |
| 225 | ) -> tuple[MuseCliCommit, dict[str, str]] | None: |
| 226 | """Load a commit and its snapshot manifest. |
| 227 | |
| 228 | Returns ``None`` when either the commit or its snapshot is missing from |
| 229 | the database (e.g. the repo is in a partially-consistent state). |
| 230 | """ |
| 231 | commit = await session.get(MuseCliCommit, commit_id) |
| 232 | if commit is None: |
| 233 | return None |
| 234 | snapshot = await session.get(MuseCliSnapshot, commit.snapshot_id) |
| 235 | if snapshot is None: |
| 236 | logger.warning( |
| 237 | "⚠️ Snapshot %s for commit %s not found", |
| 238 | commit.snapshot_id[:8], |
| 239 | commit_id[:8], |
| 240 | ) |
| 241 | return None |
| 242 | return commit, dict(snapshot.manifest) |
| 243 | |
| 244 | |
| 245 | async def _resolve_head_commit_id(root: pathlib.Path) -> str | None: |
| 246 | """Read the HEAD commit ID from the ``.muse/`` directory.""" |
| 247 | muse_dir = root / ".muse" |
| 248 | head_ref = (muse_dir / "HEAD").read_text().strip() |
| 249 | ref_path = muse_dir / pathlib.Path(head_ref) |
| 250 | if not ref_path.exists(): |
| 251 | return None |
| 252 | value = ref_path.read_text().strip() |
| 253 | return value or None |
| 254 | |
| 255 | |
| 256 | async def _describe_async( |
| 257 | *, |
| 258 | root: pathlib.Path, |
| 259 | session: AsyncSession, |
| 260 | commit_id: str | None, |
| 261 | compare_a: str | None, |
| 262 | compare_b: str | None, |
| 263 | depth: DescribeDepth, |
| 264 | dimensions_raw: str | None, |
| 265 | as_json: bool, |
| 266 | auto_tag: bool, |
| 267 | ) -> DescribeResult: |
| 268 | """Core describe logic — fully injectable for tests. |
| 269 | |
| 270 | Resolution order for which commits to diff: |
| 271 | |
| 272 | 1. ``--compare A B`` — compare A against B explicitly. |
| 273 | 2. ``<commit>`` positional argument — compare that commit against its parent. |
| 274 | 3. No argument — compare HEAD against its parent. |
| 275 | |
| 276 | Raises ``typer.Exit`` with an appropriate exit code on error. |
| 277 | """ |
| 278 | requested_dimensions = [d.strip() for d in (dimensions_raw or "").split(",") if d.strip()] |
| 279 | |
| 280 | # --- resolve target commit ------------------------------------------ |
| 281 | if compare_a and compare_b: |
| 282 | # Explicit compare mode: diff A → B |
| 283 | pair_a = await _load_commit_with_snapshot(session, compare_a) |
| 284 | if pair_a is None: |
| 285 | typer.echo(f"❌ Commit not found: {compare_a}") |
| 286 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 287 | |
| 288 | pair_b = await _load_commit_with_snapshot(session, compare_b) |
| 289 | if pair_b is None: |
| 290 | typer.echo(f"❌ Commit not found: {compare_b}") |
| 291 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 292 | |
| 293 | commit_a, manifest_a = pair_a |
| 294 | commit_b, manifest_b = pair_b |
| 295 | |
| 296 | changed, added, removed = _diff_manifests(manifest_a, manifest_b) |
| 297 | dims = _infer_dimensions(changed, added, removed, requested_dimensions) |
| 298 | tag = _suggest_tag(dims, len(changed) + len(added) + len(removed)) if auto_tag else None |
| 299 | |
| 300 | return DescribeResult( |
| 301 | commit_id=commit_b.commit_id, |
| 302 | message=commit_b.message, |
| 303 | depth=depth, |
| 304 | parent_id=commit_a.commit_id, |
| 305 | compare_commit_id=commit_a.commit_id, |
| 306 | changed_files=changed, |
| 307 | added_files=added, |
| 308 | removed_files=removed, |
| 309 | dimensions=dims, |
| 310 | auto_tag=tag, |
| 311 | ) |
| 312 | |
| 313 | # --- single commit (or HEAD) mode ----------------------------------- |
| 314 | target_id = commit_id or await _resolve_head_commit_id(root) |
| 315 | if not target_id: |
| 316 | typer.echo("No commits yet on this branch.") |
| 317 | raise typer.Exit(code=ExitCode.SUCCESS) |
| 318 | |
| 319 | pair_target = await _load_commit_with_snapshot(session, target_id) |
| 320 | if pair_target is None: |
| 321 | typer.echo(f"❌ Commit not found: {target_id}") |
| 322 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 323 | |
| 324 | target_commit, target_manifest = pair_target |
| 325 | parent_id = target_commit.parent_commit_id |
| 326 | |
| 327 | if parent_id: |
| 328 | pair_parent = await _load_commit_with_snapshot(session, parent_id) |
| 329 | if pair_parent is None: |
| 330 | # Parent referenced but missing — treat as empty base |
| 331 | logger.warning("⚠️ Parent commit %s not found; treating as empty", parent_id[:8]) |
| 332 | parent_manifest: dict[str, str] = {} |
| 333 | else: |
| 334 | _, parent_manifest = pair_parent |
| 335 | else: |
| 336 | # Root commit — everything in the snapshot is "added" |
| 337 | parent_manifest = {} |
| 338 | |
| 339 | changed, added, removed = _diff_manifests(parent_manifest, target_manifest) |
| 340 | dims = _infer_dimensions(changed, added, removed, requested_dimensions) |
| 341 | tag = _suggest_tag(dims, len(changed) + len(added) + len(removed)) if auto_tag else None |
| 342 | |
| 343 | return DescribeResult( |
| 344 | commit_id=target_commit.commit_id, |
| 345 | message=target_commit.message, |
| 346 | depth=depth, |
| 347 | parent_id=parent_id, |
| 348 | compare_commit_id=None, |
| 349 | changed_files=changed, |
| 350 | added_files=added, |
| 351 | removed_files=removed, |
| 352 | dimensions=dims, |
| 353 | auto_tag=tag, |
| 354 | ) |
| 355 | |
| 356 | |
| 357 | # --------------------------------------------------------------------------- |
| 358 | # Renderers |
| 359 | # --------------------------------------------------------------------------- |
| 360 | |
| 361 | |
| 362 | def _render_brief(result: DescribeResult) -> None: |
| 363 | """One-line summary: short commit ID + total file count.""" |
| 364 | short_id = result.commit_id[:8] |
| 365 | count = result.file_count() |
| 366 | typer.echo(f"Commit {short_id}: {count} file change{'s' if count != 1 else ''}") |
| 367 | if result.auto_tag: |
| 368 | typer.echo(f"Tag: {result.auto_tag}") |
| 369 | |
| 370 | |
| 371 | def _render_standard(result: DescribeResult) -> None: |
| 372 | """Standard output: commit message, changed files, dimensions.""" |
| 373 | short_id = result.commit_id[:8] |
| 374 | count = result.file_count() |
| 375 | typer.echo(f'Commit {short_id}: "{result.message}"') |
| 376 | |
| 377 | # Collect all changed paths for display |
| 378 | all_changed = result.changed_files + result.added_files + result.removed_files |
| 379 | file_names = [pathlib.Path(p).name for p in all_changed] |
| 380 | files_str = (", ".join(file_names)) if file_names else "none" |
| 381 | typer.echo(f"Changed files: {count} ({files_str})") |
| 382 | |
| 383 | dims_str = ", ".join(result.dimensions) if result.dimensions else "none" |
| 384 | typer.echo(f"Dimensions analyzed: {dims_str}") |
| 385 | |
| 386 | if result.auto_tag: |
| 387 | typer.echo(f"Tag: {result.auto_tag}") |
| 388 | |
| 389 | typer.echo(f"Note: {_LLM_NOTE}") |
| 390 | |
| 391 | |
| 392 | def _render_verbose(result: DescribeResult) -> None: |
| 393 | """Verbose output: adds parent commit and full file paths.""" |
| 394 | typer.echo(f"Commit: {result.commit_id}") |
| 395 | typer.echo(f'Message: "{result.message}"') |
| 396 | if result.parent_id: |
| 397 | typer.echo(f"Parent: {result.parent_id}") |
| 398 | if result.compare_commit_id: |
| 399 | typer.echo(f"Compare: {result.compare_commit_id} → {result.commit_id}") |
| 400 | |
| 401 | typer.echo("") |
| 402 | count = result.file_count() |
| 403 | typer.echo(f"Changed files ({count}):") |
| 404 | for p in result.changed_files: |
| 405 | typer.echo(f" M {p}") |
| 406 | for p in result.added_files: |
| 407 | typer.echo(f" A {p}") |
| 408 | for p in result.removed_files: |
| 409 | typer.echo(f" D {p}") |
| 410 | if count == 0: |
| 411 | typer.echo(" (no changes)") |
| 412 | |
| 413 | typer.echo("") |
| 414 | dims_str = ", ".join(result.dimensions) if result.dimensions else "none" |
| 415 | typer.echo(f"Dimensions: {dims_str}") |
| 416 | |
| 417 | if result.auto_tag: |
| 418 | typer.echo(f"Tag: {result.auto_tag}") |
| 419 | |
| 420 | typer.echo(f"\nNote: {_LLM_NOTE}") |
| 421 | |
| 422 | |
| 423 | def _render_result(result: DescribeResult, *, as_json: bool) -> None: |
| 424 | """Dispatch to the appropriate renderer.""" |
| 425 | if as_json: |
| 426 | typer.echo(json.dumps(result.to_dict(), indent=2)) |
| 427 | return |
| 428 | |
| 429 | if result.depth == DescribeDepth.brief: |
| 430 | _render_brief(result) |
| 431 | elif result.depth == DescribeDepth.verbose: |
| 432 | _render_verbose(result) |
| 433 | else: |
| 434 | _render_standard(result) |
| 435 | |
| 436 | |
| 437 | # --------------------------------------------------------------------------- |
| 438 | # Typer command |
| 439 | # --------------------------------------------------------------------------- |
| 440 | |
| 441 | |
| 442 | @app.callback(invoke_without_command=True) |
| 443 | def describe( |
| 444 | ctx: typer.Context, |
| 445 | commit: Optional[str] = typer.Argument( |
| 446 | default=None, |
| 447 | help="Commit ID to describe. Defaults to HEAD.", |
| 448 | ), |
| 449 | compare: Optional[list[str]] = typer.Option( |
| 450 | default=None, |
| 451 | help="Compare two commits: --compare COMMIT_A COMMIT_B.", |
| 452 | ), |
| 453 | depth: DescribeDepth = typer.Option( |
| 454 | DescribeDepth.standard, |
| 455 | "--depth", |
| 456 | help="Output verbosity: brief, standard (default), or verbose.", |
| 457 | ), |
| 458 | dimensions: Optional[str] = typer.Option( |
| 459 | None, |
| 460 | "--dimensions", |
| 461 | help="Comma-separated dimensions to analyze (e.g. 'rhythm,harmony'). " |
| 462 | "Currently informational; full analysis is a planned enhancement.", |
| 463 | ), |
| 464 | json_output: bool = typer.Option( |
| 465 | False, |
| 466 | "--json", |
| 467 | help="Output as JSON.", |
| 468 | ), |
| 469 | auto_tag: bool = typer.Option( |
| 470 | False, |
| 471 | "--auto-tag", |
| 472 | help="Suggest a heuristic tag based on the change scope.", |
| 473 | ), |
| 474 | ) -> None: |
| 475 | """Describe what changed musically in a commit. |
| 476 | |
| 477 | Compares the specified commit (default: HEAD) against its parent and |
| 478 | outputs a structured description of the snapshot diff. |
| 479 | |
| 480 | NOTE: Full harmonic/melodic analysis is a planned enhancement. |
| 481 | Current output is based on file-level snapshot diffs only. |
| 482 | """ |
| 483 | root = require_repo() |
| 484 | |
| 485 | # Validate --compare: exactly 0 or 2 values |
| 486 | compare_a: str | None = None |
| 487 | compare_b: str | None = None |
| 488 | if compare: |
| 489 | if len(compare) != 2: |
| 490 | typer.echo("❌ --compare requires exactly two commit IDs: --compare A B") |
| 491 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 492 | compare_a, compare_b = compare[0], compare[1] |
| 493 | |
| 494 | async def _run() -> None: |
| 495 | async with open_session() as session: |
| 496 | result = await _describe_async( |
| 497 | root=root, |
| 498 | session=session, |
| 499 | commit_id=commit, |
| 500 | compare_a=compare_a, |
| 501 | compare_b=compare_b, |
| 502 | depth=depth, |
| 503 | dimensions_raw=dimensions, |
| 504 | as_json=json_output, |
| 505 | auto_tag=auto_tag, |
| 506 | ) |
| 507 | _render_result(result, as_json=json_output) |
| 508 | |
| 509 | try: |
| 510 | asyncio.run(_run()) |
| 511 | except typer.Exit: |
| 512 | raise |
| 513 | except Exception as exc: |
| 514 | typer.echo(f"❌ muse describe failed: {exc}") |
| 515 | logger.error("❌ muse describe error: %s", exc, exc_info=True) |
| 516 | raise typer.Exit(code=ExitCode.INTERNAL_ERROR) |