grep_cmd.py
python
| 1 | """muse grep — search for a musical pattern across all commits. |
| 2 | |
| 3 | This command searches Muse VCS commit history for a given pattern and returns |
| 4 | all commits where the pattern is found. |
| 5 | |
| 6 | **Current implementation (stub):** The pattern is matched against commit |
| 7 | *messages* and *branch names* using case-insensitive substring matching. |
| 8 | Full MIDI content analysis — scanning note sequences, intervals, and chord |
| 9 | shapes inside committed snapshots — is reserved for a future iteration. |
| 10 | |
| 11 | Pattern formats recognised (planned for MIDI content search, not yet analysed): |
| 12 | |
| 13 | - Note sequence: ``"C4 E4 G4"`` |
| 14 | - Interval run: ``"+4 +3"`` |
| 15 | - Chord symbol: ``"Cm7"`` |
| 16 | |
| 17 | Future work (MIDI analysis) |
| 18 | --------------------------- |
| 19 | When MIDI content search is implemented each committed snapshot will be decoded |
| 20 | from its object store, parsed into note events, and compared against the |
| 21 | pattern using the flags below: |
| 22 | |
| 23 | - ``--transposition-invariant`` (default ``True``): match regardless of key. |
| 24 | - ``--rhythm-invariant``: match regardless of rhythm/timing. |
| 25 | - ``--track``: restrict search to a named track. |
| 26 | - ``--section``: restrict search to a labelled section. |
| 27 | |
| 28 | Until then these flags are accepted to preserve the CLI contract but only |
| 29 | ``--commits`` and ``--json`` affect output; the four MIDI-analysis flags are |
| 30 | recorded for future use and produce a warning when supplied. |
| 31 | """ |
| 32 | from __future__ import annotations |
| 33 | |
| 34 | import asyncio |
| 35 | import dataclasses |
| 36 | import json |
| 37 | import logging |
| 38 | import pathlib |
| 39 | |
| 40 | import typer |
| 41 | from sqlalchemy.ext.asyncio import AsyncSession |
| 42 | |
| 43 | from maestro.muse_cli._repo import require_repo |
| 44 | from maestro.muse_cli.db import open_session |
| 45 | from maestro.muse_cli.errors import ExitCode |
| 46 | from maestro.muse_cli.models import MuseCliCommit |
| 47 | |
| 48 | logger = logging.getLogger(__name__) |
| 49 | |
| 50 | app = typer.Typer() |
| 51 | |
| 52 | _DEFAULT_LIMIT = 1000 |
| 53 | |
| 54 | |
| 55 | # --------------------------------------------------------------------------- |
| 56 | # Domain model |
| 57 | # --------------------------------------------------------------------------- |
| 58 | |
| 59 | |
| 60 | @dataclasses.dataclass |
| 61 | class GrepMatch: |
| 62 | """A single commit that matched the search pattern. |
| 63 | |
| 64 | ``match_source`` records *where* the pattern was found: |
| 65 | - ``"message"`` — in the commit message (implemented) |
| 66 | - ``"branch"`` — in the branch name (implemented) |
| 67 | - ``"midi_content"`` — inside a committed MIDI snapshot (future work) |
| 68 | """ |
| 69 | |
| 70 | commit_id: str |
| 71 | branch: str |
| 72 | message: str |
| 73 | committed_at: str # ISO-8601 string |
| 74 | match_source: str |
| 75 | |
| 76 | |
| 77 | # --------------------------------------------------------------------------- |
| 78 | # Testable async core |
| 79 | # --------------------------------------------------------------------------- |
| 80 | |
| 81 | |
| 82 | async def _load_all_commits( |
| 83 | session: AsyncSession, |
| 84 | head_commit_id: str, |
| 85 | limit: int, |
| 86 | ) -> list[MuseCliCommit]: |
| 87 | """Walk the parent chain from *head_commit_id*, returning newest-first. |
| 88 | |
| 89 | Stops when the chain is exhausted or *limit* is reached. |
| 90 | """ |
| 91 | commits: list[MuseCliCommit] = [] |
| 92 | current_id: str | None = head_commit_id |
| 93 | while current_id and len(commits) < limit: |
| 94 | commit = await session.get(MuseCliCommit, current_id) |
| 95 | if commit is None: |
| 96 | logger.warning("⚠️ Commit %s not found in DB — chain broken", current_id[:8]) |
| 97 | break |
| 98 | commits.append(commit) |
| 99 | current_id = commit.parent_commit_id |
| 100 | return commits |
| 101 | |
| 102 | |
| 103 | def _match_commit( |
| 104 | commit: MuseCliCommit, |
| 105 | pattern: str, |
| 106 | *, |
| 107 | track: str | None, |
| 108 | section: str | None, |
| 109 | transposition_invariant: bool, |
| 110 | rhythm_invariant: bool, |
| 111 | ) -> GrepMatch | None: |
| 112 | """Return a :class:`GrepMatch` if the commit matches *pattern*, else ``None``. |
| 113 | |
| 114 | Currently performs case-insensitive substring matching against commit |
| 115 | messages and branch names. The ``track``, ``section``, |
| 116 | ``transposition_invariant``, and ``rhythm_invariant`` flags are accepted |
| 117 | for API stability but are no-ops until MIDI content search is implemented. |
| 118 | """ |
| 119 | pat = pattern.lower() |
| 120 | |
| 121 | if pat in commit.message.lower(): |
| 122 | return GrepMatch( |
| 123 | commit_id=commit.commit_id, |
| 124 | branch=commit.branch, |
| 125 | message=commit.message, |
| 126 | committed_at=commit.committed_at.isoformat(), |
| 127 | match_source="message", |
| 128 | ) |
| 129 | |
| 130 | if pat in commit.branch.lower(): |
| 131 | return GrepMatch( |
| 132 | commit_id=commit.commit_id, |
| 133 | branch=commit.branch, |
| 134 | message=commit.message, |
| 135 | committed_at=commit.committed_at.isoformat(), |
| 136 | match_source="branch", |
| 137 | ) |
| 138 | |
| 139 | # NOTE: MIDI content search (track / section / transposition / rhythm filters) |
| 140 | # is not yet implemented. The flags above will be wired here in future. |
| 141 | return None |
| 142 | |
| 143 | |
| 144 | async def _grep_async( |
| 145 | *, |
| 146 | root: pathlib.Path, |
| 147 | session: AsyncSession, |
| 148 | pattern: str, |
| 149 | track: str | None, |
| 150 | section: str | None, |
| 151 | transposition_invariant: bool, |
| 152 | rhythm_invariant: bool, |
| 153 | show_commits: bool, |
| 154 | output_json: bool, |
| 155 | ) -> list[GrepMatch]: |
| 156 | """Core grep logic — fully injectable for tests. |
| 157 | |
| 158 | Reads repo state from ``.muse/``, walks the commit chain, and returns |
| 159 | all :class:`GrepMatch` objects that satisfy *pattern*. |
| 160 | """ |
| 161 | muse_dir = root / ".muse" |
| 162 | head_ref = (muse_dir / "HEAD").read_text().strip() # "refs/heads/main" |
| 163 | branch = head_ref.rsplit("/", 1)[-1] # "main" |
| 164 | ref_path = muse_dir / pathlib.Path(head_ref) |
| 165 | |
| 166 | head_commit_id = "" |
| 167 | if ref_path.exists(): |
| 168 | head_commit_id = ref_path.read_text().strip() |
| 169 | |
| 170 | if not head_commit_id: |
| 171 | typer.echo(f"No commits yet on branch {branch} — nothing to search.") |
| 172 | return [] |
| 173 | |
| 174 | commits = await _load_all_commits(session, head_commit_id=head_commit_id, limit=_DEFAULT_LIMIT) |
| 175 | |
| 176 | matches: list[GrepMatch] = [] |
| 177 | for commit in commits: |
| 178 | m = _match_commit( |
| 179 | commit, |
| 180 | pattern, |
| 181 | track=track, |
| 182 | section=section, |
| 183 | transposition_invariant=transposition_invariant, |
| 184 | rhythm_invariant=rhythm_invariant, |
| 185 | ) |
| 186 | if m is not None: |
| 187 | matches.append(m) |
| 188 | |
| 189 | return matches |
| 190 | |
| 191 | |
| 192 | def _render_matches( |
| 193 | matches: list[GrepMatch], |
| 194 | *, |
| 195 | pattern: str, |
| 196 | show_commits: bool, |
| 197 | output_json: bool, |
| 198 | ) -> None: |
| 199 | """Write grep results to stdout. |
| 200 | |
| 201 | Three output modes: |
| 202 | - ``--json``: machine-readable JSON array. |
| 203 | - ``--commits``: one ``commit_id`` per line (like ``git grep --name-only``). |
| 204 | - default: human-readable summary with context. |
| 205 | """ |
| 206 | if output_json: |
| 207 | typer.echo( |
| 208 | json.dumps( |
| 209 | [dataclasses.asdict(m) for m in matches], |
| 210 | indent=2, |
| 211 | ) |
| 212 | ) |
| 213 | return |
| 214 | |
| 215 | if not matches: |
| 216 | typer.echo(f"No commits match pattern: {pattern!r}") |
| 217 | return |
| 218 | |
| 219 | if show_commits: |
| 220 | for m in matches: |
| 221 | typer.echo(m.commit_id) |
| 222 | return |
| 223 | |
| 224 | # Default human-readable output |
| 225 | typer.echo(f"Pattern: {pattern!r} ({len(matches)} match(es))\n") |
| 226 | for m in matches: |
| 227 | typer.echo(f"commit {m.commit_id}") |
| 228 | typer.echo(f"Branch: {m.branch}") |
| 229 | typer.echo(f"Date: {m.committed_at}") |
| 230 | typer.echo(f"Match: [{m.match_source}]") |
| 231 | typer.echo(f"Message: {m.message}") |
| 232 | typer.echo("") |
| 233 | |
| 234 | |
| 235 | # --------------------------------------------------------------------------- |
| 236 | # Typer command |
| 237 | # --------------------------------------------------------------------------- |
| 238 | |
| 239 | |
| 240 | @app.callback(invoke_without_command=True) |
| 241 | def grep( |
| 242 | ctx: typer.Context, |
| 243 | pattern: str = typer.Argument(..., help="Pattern to search for (note sequence, interval, chord, or text)."), |
| 244 | track: str | None = typer.Option( |
| 245 | None, |
| 246 | "--track", |
| 247 | help="[Future] Restrict search to a named track.", |
| 248 | show_default=False, |
| 249 | ), |
| 250 | section: str | None = typer.Option( |
| 251 | None, |
| 252 | "--section", |
| 253 | help="[Future] Restrict search to a labelled section.", |
| 254 | show_default=False, |
| 255 | ), |
| 256 | transposition_invariant: bool = typer.Option( |
| 257 | True, |
| 258 | "--transposition-invariant/--no-transposition-invariant", |
| 259 | help="[Future] Match regardless of key/transposition (default: on).", |
| 260 | ), |
| 261 | rhythm_invariant: bool = typer.Option( |
| 262 | False, |
| 263 | "--rhythm-invariant", |
| 264 | help="[Future] Match regardless of rhythm/timing.", |
| 265 | ), |
| 266 | show_commits: bool = typer.Option( |
| 267 | False, |
| 268 | "--commits", |
| 269 | help="Output one commit ID per line (like git grep --name-only).", |
| 270 | ), |
| 271 | output_json: bool = typer.Option( |
| 272 | False, |
| 273 | "--json", |
| 274 | help="Output results as a JSON array.", |
| 275 | ), |
| 276 | ) -> None: |
| 277 | """Search for a musical pattern across all commits. |
| 278 | |
| 279 | NOTE: The current implementation searches commit *messages* and *branch |
| 280 | names* for the pattern string. Full MIDI content analysis (note |
| 281 | sequences, intervals, chord symbols) is planned for a future release. |
| 282 | Flags marked [Future] are accepted now for API stability but have no |
| 283 | effect on text-only matching. |
| 284 | """ |
| 285 | root = require_repo() |
| 286 | |
| 287 | # Warn when MIDI-analysis flags are supplied — they are no-ops right now. |
| 288 | if track is not None: |
| 289 | typer.echo("⚠️ --track is not yet implemented (MIDI analysis is future work).") |
| 290 | if section is not None: |
| 291 | typer.echo("⚠️ --section is not yet implemented (MIDI analysis is future work).") |
| 292 | if rhythm_invariant: |
| 293 | typer.echo("⚠️ --rhythm-invariant is not yet implemented (MIDI analysis is future work).") |
| 294 | |
| 295 | async def _run() -> list[GrepMatch]: |
| 296 | async with open_session() as session: |
| 297 | return await _grep_async( |
| 298 | root=root, |
| 299 | session=session, |
| 300 | pattern=pattern, |
| 301 | track=track, |
| 302 | section=section, |
| 303 | transposition_invariant=transposition_invariant, |
| 304 | rhythm_invariant=rhythm_invariant, |
| 305 | show_commits=show_commits, |
| 306 | output_json=output_json, |
| 307 | ) |
| 308 | |
| 309 | try: |
| 310 | matches = asyncio.run(_run()) |
| 311 | _render_matches(matches, pattern=pattern, show_commits=show_commits, output_json=output_json) |
| 312 | except typer.Exit: |
| 313 | raise |
| 314 | except Exception as exc: |
| 315 | typer.echo(f"❌ muse grep failed: {exc}") |
| 316 | logger.error("❌ muse grep error: %s", exc, exc_info=True) |
| 317 | raise typer.Exit(code=ExitCode.INTERNAL_ERROR) |