gabriel / muse public
blame.py python
223 lines 7.7 KB
Raw
sha256:f9828efc523c2f8ccaee12ae76086a09a9a6a10d6dd20e53f0b13793f0fdcf50 docs: add symlog (#53) and reflog (#54) follow-up issue files Sonnet 4.6 19 days ago
1 """Core VCS blame — attribute each line of a text file to the commit that last changed it.
2
3 This is the domain-agnostic layer. The MIDI domain has ``note-blame``
4 (per-bar attribution); the code domain has ``muse code blame`` (per-symbol
5 attribution). This module provides line-level blame for *any text file*
6 tracked in ``state/``, making it useful for any domain that stores text —
7 configuration files, lyrics, scripts, prose.
8
9 Algorithm
10 ---------
11 Walk the commit graph from the requested ref backwards to the root:
12
13 1. At the starting commit, every line is "owned" by that commit.
14 2. At each parent commit, compute the *unified diff* between the parent's
15 version and the child's version of the file.
16 3. Lines that appear in both versions (context/unchanged) are attributed to
17 the *earliest* commit that produced them; we update the attribution when
18 we encounter a commit where those lines are *unchanged from the parent* —
19 i.e. they existed before this commit.
20 4. Lines that are *added* by a commit stay attributed to that commit.
21
22 This is equivalent to the ``git blame`` algorithm for single-parent chains.
23 For merge commits (two parents), we take the parent whose file content most
24 closely matches the merge result to avoid over-attributing lines to merges.
25
26 Output
27 ------
28 A list of ``BlameLine`` objects, one per line of the file at the requested
29 ref.
30 """
31
32 import difflib
33 import logging
34 import pathlib
35 from dataclasses import dataclass
36
37 from muse.core.graph import iter_ancestors
38 from muse.core.object_store import read_object
39 from muse.core.commits import (
40 read_commit,
41 resolve_commit_ref,
42 )
43 from muse.core.snapshots import read_snapshot
44
45 type _CommitCache = dict[str, tuple[str, str, str]]
46 logger = logging.getLogger(__name__)
47
48 # ---------------------------------------------------------------------------
49 # Types
50 # ---------------------------------------------------------------------------
51
52 @dataclass(frozen=True)
53 class BlameLine:
54 """Attribution for one line of text."""
55
56 lineno: int
57 """1-based line number in the final version of the file."""
58
59 commit_id: str
60 """Commit that last changed this line."""
61
62 author: str
63 """Author field from the commit record."""
64
65 committed_at: str
66 """ISO timestamp of the commit."""
67
68 message: str
69 """First line of the commit message."""
70
71 content: str
72 """The line content (without trailing newline)."""
73
74 # ---------------------------------------------------------------------------
75 # File reading helper
76 # ---------------------------------------------------------------------------
77
78 def _read_file_at_commit(
79 repo_root: pathlib.Path,
80 commit_id: str,
81 rel_path: str,
82 ) -> list[str] | None:
83 """Return lines of *rel_path* as it existed at *commit_id*, or None."""
84 commit = read_commit(repo_root, commit_id)
85 if commit is None:
86 return None
87 snap = read_snapshot(repo_root, commit.snapshot_id)
88 if snap is None:
89 return None
90 obj_id = snap.manifest.get(rel_path)
91 if obj_id is None:
92 return None
93 try:
94 data = read_object(repo_root, obj_id)
95 except OSError:
96 return None
97 if data is None:
98 return None
99 return data.decode("utf-8", errors="replace").splitlines()
100
101 # ---------------------------------------------------------------------------
102 # Commit graph walker
103 # ---------------------------------------------------------------------------
104
105 def _walk_ancestry(
106 repo_root: pathlib.Path,
107 start_id: str,
108 ) -> list[str]:
109 """Return commit IDs from *start_id* to the root, newest-first."""
110 return [c.commit_id for c in iter_ancestors(repo_root, start_id)]
111
112 # ---------------------------------------------------------------------------
113 # Public API
114 # ---------------------------------------------------------------------------
115
116 def blame_file(
117 repo_root: pathlib.Path,
118 rel_path: str,
119 commit_id: str,
120 ) -> list[BlameLine] | None:
121 """Attribute each line of *rel_path* to the commit that last modified it.
122
123 Args:
124 repo_root: Repository root.
125 rel_path: Path relative to ``state/``, e.g. ``"README.md"``.
126 commit_id: The commit to start the blame from (usually HEAD).
127
128 Returns:
129 A list of :class:`BlameLine` objects (1-indexed), or ``None`` if the
130 file does not exist at *commit_id*.
131 """
132 current_lines = _read_file_at_commit(repo_root, commit_id, rel_path)
133 if current_lines is None:
134 return None
135
136 n = len(current_lines)
137 # attribution[i] = commit_id that last changed line i (0-indexed)
138 attribution: list[str] = [commit_id] * n
139
140 ancestry = _walk_ancestry(repo_root, commit_id)
141
142 def _obj_id_at(cid: str) -> str | None:
143 """Return the object_id of rel_path at cid, without reading file bytes."""
144 c = read_commit(repo_root, cid)
145 if c is None:
146 return None
147 s = read_snapshot(repo_root, c.snapshot_id)
148 if s is None:
149 return None
150 return s.manifest.get(rel_path)
151
152 # Walk from the commit towards the root. When a parent has the same line
153 # content the attribution moves back to the parent (older is better).
154 child_lines = current_lines[:]
155 child_id = commit_id
156 child_obj_id = _obj_id_at(commit_id)
157
158 for parent_id in ancestry[1:]: # skip the starting commit itself
159 parent_obj_id = _obj_id_at(parent_id)
160
161 if parent_obj_id is None:
162 # File didn't exist in this ancestor — stop the walk.
163 break
164
165 if parent_obj_id == child_obj_id:
166 # File content identical — all lines move attribution to this parent.
167 for k in range(n):
168 if attribution[k] == child_id:
169 attribution[k] = parent_id
170 child_id = parent_id
171 # child_obj_id stays the same (parent_obj_id == child_obj_id)
172 continue
173
174 parent_lines = _read_file_at_commit(repo_root, parent_id, rel_path)
175 if parent_lines is None:
176 break
177
178 # Use SequenceMatcher to align lines.
179 sm = difflib.SequenceMatcher(None, parent_lines, child_lines, autojunk=False)
180 for tag, i1, i2, j1, j2 in sm.get_opcodes():
181 if tag == "equal":
182 # These lines are unchanged from parent → child.
183 # They may be attributed to an even older commit; update those
184 # that are currently attributed to child_id.
185 # j indices reference child_lines which may be larger than the
186 # original current_lines — skip out-of-bounds positions.
187 for k in range(j2 - j1):
188 idx = j1 + k
189 if idx < len(attribution) and attribution[idx] == child_id:
190 attribution[idx] = parent_id
191
192 child_lines = parent_lines
193 child_id = parent_id
194 child_obj_id = parent_obj_id
195
196 # Build the final BlameLine list.
197 result: list[BlameLine] = []
198 commit_cache: _CommitCache = {}
199
200 def _commit_meta(cid: str) -> tuple[str, str, str]:
201 if cid in commit_cache:
202 return commit_cache[cid]
203 c = read_commit(repo_root, cid)
204 if c is None:
205 meta = ("unknown", "", "")
206 else:
207 first_line = c.message.split("\n", 1)[0] if c.message else ""
208 meta = (c.author or "unknown", c.committed_at.isoformat(), first_line)
209 commit_cache[cid] = meta
210 return meta
211
212 for idx, (line, cid) in enumerate(zip(current_lines, attribution)):
213 author, committed_at, message = _commit_meta(cid)
214 result.append(BlameLine(
215 lineno=idx + 1,
216 commit_id=cid,
217 author=author,
218 committed_at=committed_at,
219 message=message,
220 content=line,
221 ))
222
223 return result
File History 1 commit
sha256:f9828efc523c2f8ccaee12ae76086a09a9a6a10d6dd20e53f0b13793f0fdcf50 docs: add symlog (#53) and reflog (#54) follow-up issue files Sonnet 4.6 19 days ago