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