cgcardona / muse public
describe.py python
516 lines 17.1 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
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)