gabriel / muse public
note_log.py python
175 lines 6.4 KB
Raw
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 73 days ago
1 """muse note-log — note-level commit history for a MIDI track.
2
3 Walks the commit history and shows exactly which notes were added and
4 removed in each commit that touched a specific MIDI track. Every change
5 is expressed in musical notation, not as a binary blob diff.
6
7 Usage::
8
9 muse note-log tracks/melody.mid
10 muse note-log tracks/melody.mid --from HEAD~10
11 muse note-log tracks/melody.mid --json
12
13 Output::
14
15 Note history: tracks/melody.mid
16 Commits analysed: 12
17
18 cb4afaed 2026-03-16 "Perf: vectorise melody" (3 changes)
19 + C4 vel=80 @beat=1.00 dur=1.00 ch 0
20 + E4 vel=75 @beat=2.00 dur=0.50 ch 0
21 - D4 vel=72 @beat=2.00 dur=0.50 ch 0 (removed)
22
23 1d2e3faa 2026-03-15 "Add bridge section" (4 changes)
24 + A4 vel=78 @beat=9.00 dur=1.00 ch 0
25 + B4 vel=75 @beat=10.00 dur=1.00 ch 0
26 ...
27 """
28
29 from __future__ import annotations
30
31 import argparse
32 import json
33 import logging
34 import pathlib
35 import sys
36
37 from muse.core.errors import ExitCode
38 from muse.core.repo import read_repo_id, require_repo
39 from muse.core.store import read_current_branch, resolve_commit_ref
40 from muse.domain import DomainOp
41 from muse.plugins.midi._query import (
42 NoteInfo,
43 load_track,
44 walk_commits_for_track,
45 )
46 from muse.plugins.midi.midi_diff import NoteKey, _note_summary, extract_notes
47 from muse.core.object_store import read_object
48 from muse.core.validation import clamp_int
49 from muse.core.validation import sanitize_display
50
51 logger = logging.getLogger(__name__)
52
53
54
55 def _read_branch(root: pathlib.Path) -> str:
56 return read_current_branch(root)
57
58
59 def _flat_ops(ops: list[DomainOp]) -> list[DomainOp]:
60 """Flatten PatchOp child_ops for the given track."""
61 result: list[DomainOp] = []
62 for op in ops:
63 if op["op"] == "patch":
64 result.extend(op["child_ops"])
65 else:
66 result.append(op)
67 return result
68
69
70 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
71 """Register the note-log subcommand."""
72 parser = subparsers.add_parser("note-log", help="Show the note-level commit history for a MIDI track.", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
73 parser.add_argument("track", metavar="TRACK", help="Workspace-relative path to a .mid file.")
74 parser.add_argument("--from", metavar="REF", default=None, dest="from_ref", help="Start walking from this commit (default: HEAD).")
75 parser.add_argument("--max", "-n", metavar="N", type=int, default=50, dest="max_commits", help="Maximum number of commits to walk (default: 50).")
76 parser.add_argument("--json", action="store_true", dest="as_json", help="Emit results as JSON.")
77 parser.set_defaults(func=run)
78
79
80 def run(args: argparse.Namespace) -> None:
81 """Show the note-level commit history for a MIDI track.
82
83 ``muse note-log`` walks the commit history and, for each commit that
84 touched *TRACK*, shows exactly which notes were added and removed —
85 expressed in musical notation (pitch name, beat position, velocity,
86 duration), not as a binary diff.
87
88 This is the music-domain equivalent of ``muse symbol-log``: a
89 semantic history of a single artefact, at the level of individual notes.
90
91 Use ``--from`` to start at a different point in history. Use ``--json``
92 to pipe the output to an agent for further processing.
93 """
94 track: str = args.track
95 from_ref: str | None = args.from_ref
96 max_commits: int = clamp_int(args.max_commits, 1, 100000, 'max_commits')
97 as_json: bool = args.as_json
98
99 root = require_repo()
100 repo_id = read_repo_id(root)
101 branch = _read_branch(root)
102
103 start_commit = resolve_commit_ref(root, repo_id, branch, from_ref)
104 if start_commit is None:
105 print(f"❌ Commit '{from_ref or 'HEAD'}' not found.", file=sys.stderr)
106 raise SystemExit(ExitCode.USER_ERROR)
107
108 commits_with_manifest = walk_commits_for_track(
109 root, start_commit.commit_id, track, max_commits=max_commits
110 )
111
112 # Collect events: (commit, note_summary, op_kind) per commit that touched the track.
113 EventEntry = tuple[str, str, str, str, str, list[tuple[str, str]]]
114 events: list[EventEntry] = []
115
116 for commit, manifest in commits_with_manifest:
117 if commit.structured_delta is None:
118 continue
119 # Find the PatchOp for this track.
120 track_ops: list[DomainOp] = []
121 for op in commit.structured_delta["ops"]:
122 if op["address"] == track:
123 if op["op"] == "patch":
124 track_ops.extend(op["child_ops"])
125 else:
126 # File-level insert/delete/replace — not note-level.
127 track_ops.append(op)
128
129 if not track_ops:
130 continue
131
132 note_changes: list[tuple[str, str]] = []
133 for op in track_ops:
134 if op["op"] == "insert":
135 note_changes.append(("+", op.get("content_summary", op["address"])))
136 elif op["op"] == "delete":
137 note_changes.append(("-", op.get("content_summary", op["address"])))
138
139 if note_changes:
140 date_str = commit.committed_at.strftime("%Y-%m-%d")
141 events.append((
142 commit.commit_id[:8],
143 date_str,
144 commit.message,
145 commit.author or "unknown",
146 commit.commit_id,
147 note_changes,
148 ))
149
150 if as_json:
151 out: list[dict[str, str | list[dict[str, str]]]] = []
152 for short_id, date, msg, author, full_id, changes in events:
153 out.append({
154 "commit_id": full_id,
155 "date": date,
156 "message": msg,
157 "author": author,
158 "changes": [{"op": op, "note": note} for op, note in changes],
159 })
160 print(json.dumps({"track": track, "events": out}, indent=2))
161 return
162
163 print(f"\nNote history: {sanitize_display(track)}")
164 print(f"Commits analysed: {len(commits_with_manifest)}")
165
166 if not events:
167 print("\n (no note-level changes found for this track)")
168 return
169
170 for short_id, date, msg, author, _full_id, changes in events:
171 print(f"\n{short_id} {date} \"{sanitize_display(msg)}\" ({len(changes)} change(s))")
172 for op_kind, note_summary in changes:
173 prefix = " +" if op_kind == "+" else " -"
174 suffix = " (removed)" if op_kind == "-" else ""
175 print(f"{prefix} {note_summary}{suffix}")
File History 1 commit
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 73 days ago