gabriel / muse public
note_log.py python
168 lines 6.3 KB
Raw
sha256:2eaa5d95f9d9383498e76947410a26e5a3ba23d182f339910c424cf88fad412b fix: try fetch/presign before fetch/mpack to avoid Cloudfla… Sonnet 4.6 patch 8 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 import argparse
30 import json
31 import logging
32 import pathlib
33 import sys
34
35 from muse.core.errors import ExitCode
36 from muse.core.repo import require_repo
37 from muse.core.refs import read_current_branch
38 from muse.core.commits import resolve_commit_ref
39 from muse.domain import DomainOp
40 from muse.plugins.midi._query import (
41 NoteInfo,
42 load_track,
43 walk_commits_for_track,
44 )
45 from muse.plugins.midi.midi_diff import NoteKey, _note_summary, extract_notes
46 from muse.core.object_store import read_object
47 from muse.core.validation import clamp_int
48 from muse.core.validation import sanitize_display
49
50 logger = logging.getLogger(__name__)
51
52 def _read_branch(root: pathlib.Path) -> str:
53 return read_current_branch(root)
54
55 def _flat_ops(ops: list[DomainOp]) -> list[DomainOp]:
56 """Flatten PatchOp child_ops for the given track."""
57 result: list[DomainOp] = []
58 for op in ops:
59 if op["op"] == "patch":
60 result.extend(op["child_ops"])
61 else:
62 result.append(op)
63 return result
64
65 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
66 """Register the note-log subcommand."""
67 parser = subparsers.add_parser("note-log", help="Show the note-level commit history for a MIDI track.", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
68 parser.add_argument("track", metavar="TRACK", help="Workspace-relative path to a .mid file.")
69 parser.add_argument("--from", metavar="REF", default=None, dest="from_ref", help="Start walking from this commit (default: HEAD).")
70 parser.add_argument("--max", "-n", metavar="N", type=int, default=50, dest="max_commits", help="Maximum number of commits to walk (default: 50).")
71 parser.add_argument("--json", action="store_true", dest="as_json", help="Emit results as JSON.")
72 parser.set_defaults(func=run)
73
74 def run(args: argparse.Namespace) -> None:
75 """Show the note-level commit history for a MIDI track.
76
77 ``muse note-log`` walks the commit history and, for each commit that
78 touched *TRACK*, shows exactly which notes were added and removed —
79 expressed in musical notation (pitch name, beat position, velocity,
80 duration), not as a binary diff.
81
82 This is the music-domain equivalent of ``muse symbol-log``: a
83 semantic history of a single artefact, at the level of individual notes.
84
85 Use ``--from`` to start at a different point in history. Use ``--json``
86 to pipe the output to an agent for further processing.
87 """
88 track: str = args.track
89 from_ref: str | None = args.from_ref
90 max_commits: int = clamp_int(args.max_commits, 1, 100000, 'max_commits')
91 as_json: bool = args.as_json
92
93 root = require_repo()
94 branch = _read_branch(root)
95
96 start_commit = resolve_commit_ref(root, branch, from_ref)
97 if start_commit is None:
98 print(f"❌ Commit '{from_ref or 'HEAD'}' not found.", file=sys.stderr)
99 raise SystemExit(ExitCode.USER_ERROR)
100
101 commits_with_manifest = walk_commits_for_track(
102 root, start_commit.commit_id, track, max_commits=max_commits
103 )
104
105 # Collect events: (commit, note_summary, op_kind) per commit that touched the track.
106 EventEntry = tuple[str, str, str, str, str, list[tuple[str, str]]]
107 events: list[EventEntry] = []
108
109 for commit, manifest in commits_with_manifest:
110 if commit.structured_delta is None:
111 continue
112 # Find the PatchOp for this track.
113 track_ops: list[DomainOp] = []
114 for op in commit.structured_delta["ops"]:
115 if op["address"] == track:
116 if op["op"] == "patch":
117 track_ops.extend(op["child_ops"])
118 else:
119 # File-level insert/delete/replace — not note-level.
120 track_ops.append(op)
121
122 if not track_ops:
123 continue
124
125 note_changes: list[tuple[str, str]] = []
126 for op in track_ops:
127 if op["op"] == "insert":
128 note_changes.append(("+", op.get("content_summary", op["address"])))
129 elif op["op"] == "delete":
130 note_changes.append(("-", op.get("content_summary", op["address"])))
131
132 if note_changes:
133 date_str = commit.committed_at.strftime("%Y-%m-%d")
134 events.append((
135 commit.commit_id,
136 date_str,
137 commit.message,
138 commit.author or "unknown",
139 commit.commit_id,
140 note_changes,
141 ))
142
143 if as_json:
144 out = []
145 for commit_id, date, msg, author, _full_id2, changes in events:
146 out.append({
147 "commit_id": commit_id,
148 "date": date,
149 "message": msg,
150 "author": author,
151 "changes": [{"op": op, "note": note} for op, note in changes],
152 })
153 print(json.dumps({"track": track, "events": out}))
154 return
155
156 print(f"\nNote history: {sanitize_display(track)}")
157 print(f"Commits analysed: {len(commits_with_manifest)}")
158
159 if not events:
160 print("\n (no note-level changes found for this track)")
161 return
162
163 for commit_id, date, msg, author, _full_id, changes in events:
164 print(f"\n{commit_id} {date} \"{sanitize_display(msg)}\" ({len(changes)} change(s))")
165 for op_kind, note_summary in changes:
166 prefix = " +" if op_kind == "+" else " -"
167 suffix = " (removed)" if op_kind == "-" else ""
168 print(f"{prefix} {note_summary}{suffix}")
File History 1 commit
sha256:2eaa5d95f9d9383498e76947410a26e5a3ba23d182f339910c424cf88fad412b fix: try fetch/presign before fetch/mpack to avoid Cloudfla… Sonnet 4.6 patch 8 days ago