Coverage for src/pullapprove/diff.py: 95%

240 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-07-17 12:59 -0500

1from __future__ import annotations 

2 

3import re 

4from collections.abc import Callable, Generator, Iterable, Iterator 

5from functools import cached_property 

6from typing import TYPE_CHECKING 

7 

8if TYPE_CHECKING: 

9 from .trust import Trust 

10 

11 

12class DiffFile: 

13 def __init__(self, *, old_path: str, new_path: str): 

14 self.old_path = old_path 

15 self.new_path = new_path 

16 self.hunks: list[DiffHunk] = [] 

17 

18 def __repr__(self) -> str: 

19 return f"<DiffFile old_path={self.old_path} new_path={self.new_path}>" 

20 

21 def is_move(self) -> bool: 

22 return self.old_path != self.new_path 

23 

24 

25class DiffHunk: 

26 def __init__( 

27 self, 

28 *, 

29 old_line: int, 

30 old_length: int | None, 

31 new_line: int, 

32 new_length: int | None, 

33 ): 

34 self.old_line = old_line 

35 self.old_length = old_length 

36 self.new_line = new_line 

37 self.new_length = new_length 

38 self.lines: list[DiffCode] = [] 

39 self.trust: Trust | None = None 

40 

41 @property 

42 def is_new_file(self) -> bool: 

43 """True when this hunk adds a brand-new file (no old content). 

44 

45 Both checks matter: a mid-file pure insertion in a zero-context diff 

46 (`@@ -5,0 +6,2 @@`) also has old_length 0, but only a genuinely new 

47 file anchors at old line 0 (`@@ -0,0 +1,N @@`).""" 

48 return self.old_length == 0 and self.old_line == 0 

49 

50 # Cached: a hunk's lines are appended once during parsing and never mutated 

51 # after, but the trust rules read these accessors ~12x per hunk as they walk 

52 # the rule chain — recomputing the list each time is pure waste. 

53 @cached_property 

54 def changed_lines(self) -> list[DiffCode]: 

55 return [line for line in self.lines if not line.is_context()] 

56 

57 @cached_property 

58 def added_lines(self) -> list[DiffCode]: 

59 return [line for line in self.lines if line.is_addition()] 

60 

61 @cached_property 

62 def removed_lines(self) -> list[DiffCode]: 

63 return [line for line in self.lines if line.is_deletion()] 

64 

65 

66class DiffCode: 

67 def __init__( 

68 self, 

69 *, 

70 old_line_number: int | None, 

71 new_line_number: int | None, 

72 content: str, 

73 change_type: str, 

74 ): 

75 self.old_line_number = old_line_number 

76 self.new_line_number = new_line_number 

77 self.content = content 

78 self.change_type = change_type 

79 

80 def is_addition(self) -> bool: 

81 return self.change_type == "+" 

82 

83 def is_deletion(self) -> bool: 

84 return self.change_type == "-" 

85 

86 def is_context(self) -> bool: 

87 return self.change_type == "" 

88 

89 @property 

90 def line_number(self) -> int: 

91 """For backwards compatibility - returns the appropriate line number.""" 

92 if self.is_deletion(): 

93 return self.old_line_number or 0 

94 return self.new_line_number or 0 

95 

96 def __str__(self) -> str: 

97 return f"{self.line_number}: {self.change_type or ' '}{self.content}" 

98 

99 def __repr__(self) -> str: 

100 return f"<DiffCode change_type={self.change_type} old_line={self.old_line_number} new_line={self.new_line_number} content={self.content}>" 

101 

102 def raw(self) -> str: 

103 return f"{self.change_type or ' '}{self.content}" 

104 

105 

106# git C-quote single-char escapes (a quoted diff path may use any of these). 

107_GIT_QUOTE_ESCAPES = { 

108 '"': '"', 

109 "\\": "\\", 

110 "t": "\t", 

111 "n": "\n", 

112 "r": "\r", 

113 "f": "\f", 

114 "b": "\b", 

115 "a": "\a", 

116 "v": "\v", 

117} 

118 

119 

120def _unquote_git_path(inner: str) -> str: 

121 """Decode a git C-quoted path (the text between the surrounding quotes), 

122 resolving backslash escapes and octal byte escapes to a UTF-8 string.""" 

123 buf = bytearray() 

124 i, n = 0, len(inner) 

125 while i < n: 

126 ch = inner[i] 

127 if ch == "\\" and i + 1 < n: 

128 nxt = inner[i + 1] 

129 if nxt in _GIT_QUOTE_ESCAPES: 

130 buf.extend(_GIT_QUOTE_ESCAPES[nxt].encode()) 

131 i += 2 

132 continue 

133 if "0" <= nxt <= "7": # octal byte, e.g. \303 for a UTF-8 lead byte 

134 # Consume only consecutive octal digits (max 3) — git emits exactly 

135 # 3, but arbitrary stdin might not, and `int("7y", 8)` would raise. 

136 end = i + 2 

137 while end < n and end < i + 4 and "0" <= inner[end] <= "7": 

138 end += 1 

139 buf.append(int(inner[i + 1 : end], 8) & 0xFF) 

140 i = end 

141 continue 

142 buf.extend(ch.encode()) 

143 i += 1 

144 return buf.decode("utf-8", "replace") 

145 

146 

147def _read_quoted_git_path(rest: str, i: int) -> tuple[str, int] | None: 

148 """Read one `"C-quoted"` `a/…` path starting at `rest[i]` (which must be the 

149 opening quote). Returns (decoded path, index just past the closing quote), or 

150 None for an unterminated quote.""" 

151 j = i + 1 

152 while j < len(rest): 

153 if rest[j] == "\\": 

154 j += 2 # an escaped char can't close the quote 

155 continue 

156 if rest[j] == '"': 

157 return _unquote_git_path(rest[i + 1 : j]), j + 1 

158 j += 1 

159 return None 

160 

161 

162def _split_equal_git_names(rest: str) -> tuple[str, str] | None: 

163 """Split an unquoted `a/PATH b/PATH` header when both sides name the SAME 

164 path (a non-rename), returning (path, path). 

165 

166 Git doesn't quote spaces, so `diff --git a/Section 1/report.py b/Section 

167 1/report.py` is ambiguous to a greedy ` \\w/` split (which lands on the last 

168 separator and mis-paths the file). But a non-rename repeats one identical 

169 path on both sides, so the split is exactly the center: `<p>/NAME <p>/NAME` 

170 with equal-length halves. Resolve it structurally, the way git's own header 

171 parser assumes equal names first. Returns None when the halves differ (a 

172 rename — handled by the caller's fallback).""" 

173 # rest == prefix(2) + NAME + " " + prefix(2) + NAME -> len 5 + 2*len(NAME) 

174 if len(rest) < 5 or (len(rest) - 5) % 2 != 0: 

175 return None 

176 name_len = (len(rest) - 5) // 2 

177 left, middle, right = ( 

178 rest[2 : 2 + name_len], 

179 rest[2 + name_len : 5 + name_len], 

180 rest[5 + name_len :], 

181 ) 

182 # Both prefixes are one word-char + "/", the separator is a space, and the 

183 # two names are identical. 

184 if left == right and re.match(r"^\w/$", rest[:2]) and re.match(r"^ \w/$", middle): 

185 return left, right 

186 return None 

187 

188 

189def parse_diff_file_line(line: str) -> DiffFile | None: 

190 # Cheap guard: skip the parsing on the vast majority of lines that can't match. 

191 if not line.startswith("diff --git "): 

192 return None 

193 # Streamed lines keep their trailing newline; a name must never absorb it. 

194 rest = line[len("diff --git ") :].strip() 

195 if '"' not in rest: 

196 # A non-rename repeats the same path on both sides; resolve that 

197 # exactly (so a space-containing path isn't mis-split). Fall back to the 

198 # greedy split for renames — different paths whose spaces make this line 

199 # genuinely ambiguous (git also emits `--- a/`/`rename from` for those). 

200 if names := _split_equal_git_names(rest): 

201 return DiffFile(old_path=names[0], new_path=names[1]) 

202 match = re.match(r"^\w/(.*) \w/(.*)$", rest) 

203 if match: 

204 return DiffFile( 

205 old_path=match.group(1), 

206 new_path=match.group(2), 

207 ) 

208 return None 

209 # A name with special/non-ASCII bytes is C-quoted (prefix inside the quotes), 

210 # e.g. `diff --git "a/caf\303\251.py" b/plain.txt` — git quotes each side 

211 # independently, and a BARE side may still contain spaces. Read both tokens so 

212 # the header is recognized — else, mid-filter, a hidden hunk before it would 

213 # swallow this file's whole diff — and quoted paths decoded. 

214 if rest.startswith('"'): 

215 first = _read_quoted_git_path(rest, 0) 

216 if first is None or first[1] >= len(rest) or rest[first[1]] != " ": 

217 return None 

218 old_path, second_start = first[0], first[1] + 1 

219 second_raw = rest[second_start:] 

220 else: 

221 # Bare first + quoted second: the second token starts at the ` "` split, 

222 # so a bare first name keeps any spaces it contains. 

223 split = rest.find(' "') 

224 if split == -1: 

225 return None 

226 old_path, second_raw = rest[:split], rest[split + 1 :] 

227 if second_raw.startswith('"'): 

228 second = _read_quoted_git_path(second_raw, 0) 

229 if second is None: 

230 return None 

231 new_path = second[0] 

232 else: 

233 new_path = second_raw # bare to end of line — spaces stay in the name 

234 return DiffFile( 

235 old_path=re.sub(r"^\w/", "", old_path), 

236 new_path=re.sub(r"^\w/", "", new_path), 

237 ) 

238 

239 

240def parse_diff_hunk_line(line: str) -> DiffHunk | None: 

241 # Cheap guard: skip the regex on the vast majority of lines that can't match. 

242 if not line.startswith("@@ "): 

243 return None 

244 match = re.match(r"^@@ -(\d+),?(\d+)? \+(\d+),?(\d+)? @@", line) 

245 if match: 

246 old_line, old_length, new_line, new_length = match.groups() 

247 return DiffHunk( 

248 old_line=int(old_line), 

249 old_length=int(old_length) if old_length else None, 

250 new_line=int(new_line), 

251 new_length=int(new_length) if new_length else None, 

252 ) 

253 return None 

254 

255 

256def _lookahead(lines: Iterable[str]) -> Generator[tuple[str, bool]]: 

257 """Yield each line paired with whether another line follows it.""" 

258 it = iter(lines) 

259 try: 

260 prev = next(it) 

261 except StopIteration: 

262 return 

263 for line in it: 

264 yield prev, True 

265 prev = line 

266 yield prev, False 

267 

268 

269def _iterate_diff_parts( 

270 diff: Iterable[str] | str, 

271) -> Generator[DiffFile | DiffHunk | DiffCode]: 

272 """Single source of truth for diff parsing. 

273 

274 Yields files, hunk headers, and code lines in order. `iterate_diff_parts` 

275 filters out the hunk headers to preserve its long-standing 

276 `DiffFile | DiffCode` contract; `parse_diff` keeps them to group lines 

277 under their hunks. 

278 """ 

279 current_file: DiffFile | None = None 

280 current_hunk: DiffHunk | None = None 

281 

282 # Track where we are in the hunk as we go. 

283 minus_line = plus_line = 0 

284 

285 diff_iterator = diff.splitlines() if isinstance(diff, str) else diff 

286 

287 # A bare `""` body line is ambiguous: it's either a genuine blank context 

288 # line (some hosts strip its leading space) or the trailing artifact that 

289 # `some_diff_text.split("\n")` appends when the text ends with a newline 

290 # (e.g. the CLI's `diff --hide`, which splits that way on purpose so its 

291 # two parsing passes stay aligned — see cli.py). The two are only told 

292 # apart by position: the split artifact is always the LAST line of the 

293 # whole input, with nothing after it, while a real blank context line is 

294 # always followed by more content (more body, the next hunk, the next 

295 # file, or at minimum its own file's closing newline). So a `""` only 

296 # counts as context when something follows it — `_lookahead` reports that 

297 # via `has_next`. Header count mismatches (real diffs, and this test 

298 # suite's fixtures, aren't always internally consistent) rule out using 

299 # the hunk's declared old/new length for this instead. 

300 for raw, has_next in _lookahead(diff_iterator): 

301 if new_file := parse_diff_file_line(raw): 

302 current_file, current_hunk = new_file, None 

303 yield new_file 

304 elif current_file: 

305 if new_hunk := parse_diff_hunk_line(raw): 

306 current_hunk = new_hunk 

307 minus_line, plus_line = new_hunk.old_line, new_hunk.new_line 

308 yield new_hunk 

309 

310 # Git can pack the first context line onto the hunk header, 

311 # after the closing `@@` (e.g. `@@ -6,7 +6,7 @@ def foo():`). 

312 # Emit it as a context line so it isn't dropped. 

313 trailing = ( 

314 raw.split("@@", 2)[-1].lstrip() if raw.count("@@") > 1 else "" 

315 ) 

316 if trailing: 

317 yield DiffCode( 

318 old_line_number=minus_line, 

319 new_line_number=plus_line, 

320 content=trailing, 

321 change_type="", 

322 ) 

323 minus_line += 1 

324 plus_line += 1 

325 elif current_hunk: 

326 if raw.startswith("+"): 

327 yield DiffCode( 

328 old_line_number=None, 

329 new_line_number=plus_line, 

330 content=raw[1:], 

331 change_type="+", 

332 ) 

333 plus_line += 1 

334 elif raw.startswith("-"): 

335 yield DiffCode( 

336 old_line_number=minus_line, 

337 new_line_number=None, 

338 content=raw[1:], 

339 change_type="-", 

340 ) 

341 minus_line += 1 

342 elif (raw.startswith(" ") or raw == "") and (raw != "" or has_next): 

343 # Context exists on both sides (some hosts strip a blank 

344 # context line's leading space, hence the `""` case — 

345 # matches the JS parsers in file-contents.ts/review-state.ts). 

346 yield DiffCode( 

347 old_line_number=minus_line, 

348 new_line_number=plus_line, 

349 content="" if raw == "" else raw[1:], 

350 change_type="", 

351 ) 

352 minus_line += 1 

353 plus_line += 1 

354 

355 

356def iterate_diff_parts( 

357 diff: Iterator[str] | str, 

358) -> Generator[DiffFile | DiffCode]: 

359 """Stream a diff as files and code lines (hunk headers omitted).""" 

360 for part in _iterate_diff_parts(diff): 

361 if not isinstance(part, DiffHunk): 

362 yield part 

363 

364 

365def parse_diff(diff: Iterator[str] | str) -> list[DiffFile]: 

366 """Parse a diff into files, each grouping its hunks and their lines.""" 

367 files: list[DiffFile] = [] 

368 current_file: DiffFile | None = None 

369 current_hunk: DiffHunk | None = None 

370 

371 for part in _iterate_diff_parts(diff): 

372 if isinstance(part, DiffFile): 

373 current_file, current_hunk = part, None 

374 files.append(part) 

375 elif isinstance(part, DiffHunk): 

376 current_hunk = part 

377 if current_file is not None: 

378 current_file.hunks.append(part) 

379 elif current_hunk is not None: 

380 current_hunk.lines.append(part) 

381 

382 return files 

383 

384 

385def iter_file_hunks(diff: Iterable[str] | str) -> Iterator[tuple[DiffFile, DiffHunk]]: 

386 """Stream each completed `(file, hunk)` pair as the diff parses, holding only 

387 the current hunk's lines in memory. (`parse_diff` keeps the whole tree; this 

388 keeps one hunk — which is all a per-hunk consumer needs.) A new file or hunk, 

389 and end-of-input, each flush the hunk that just finished; a hunk only exists 

390 after its file, so both are set whenever a pair is yielded. Files without 

391 hunks (binary, pure rename, mode-only) are never yielded. 

392 

393 This mirrors `parse_diff`'s grouping of the same parts stream; keep the two 

394 in sync if that dispatch changes.""" 

395 file: DiffFile | None = None 

396 hunk: DiffHunk | None = None 

397 

398 for part in _iterate_diff_parts(diff): 

399 if isinstance(part, DiffFile): 

400 if file is not None and hunk is not None: 

401 yield file, hunk 

402 file, hunk = part, None 

403 elif isinstance(part, DiffHunk): 

404 if file is not None and hunk is not None: 

405 yield file, hunk 

406 hunk = part 

407 elif hunk is not None: 

408 hunk.lines.append(part) 

409 

410 if file is not None and hunk is not None: 

411 yield file, hunk 

412 

413 

414def filter_diff_text( 

415 diff_text: str, 

416 keep_hunk: Callable[[str, int, int], bool], 

417) -> str: 

418 """Re-emit a unified diff, keeping only the hunks `keep_hunk` accepts. 

419 

420 `keep_hunk(new_path, old_line, new_line)` is called once per hunk. Hunk 

421 bodies are copied verbatim — the diff is split on "\\n" only (not 

422 str.splitlines, which also breaks on "\\r", "\\f", and Unicode separators), 

423 and bytes are never rebuilt from the parsed model — so CRLF endings and any 

424 control characters in the content survive, and the result stays a valid 

425 unified diff. 

426 

427 A file whose hunks are all dropped is removed, header and all. A file with 

428 no hunks at all — a binary, pure-rename, or mode-only change — has nothing 

429 to classify and is never "trusted", so it is kept unchanged. Any preamble 

430 before the first file (e.g. a `git show` commit message) passes through. 

431 """ 

432 out: list[str] = [] 

433 header: list[str] = [] # file-level lines buffered until a hunk is kept 

434 new_path: str | None = None 

435 in_hunk = False # have we passed this file's first @@ yet? 

436 keeping = False # is the current hunk being kept? 

437 

438 def flush_unhunked_file() -> None: 

439 # A file with header lines but no @@ hunks (binary, pure rename, or 

440 # mode-only change) has nothing to classify, so emit it as-is rather 

441 # than dropping it. A file whose hunks were dropped has in_hunk set and 

442 # is intentionally skipped. 

443 if header and not in_hunk: 

444 out.extend(header) 

445 

446 for line in diff_text.split("\n"): 

447 if file := parse_diff_file_line(line): 

448 flush_unhunked_file() 

449 new_path = file.new_path 

450 header = [line] 

451 in_hunk = False 

452 keeping = False 

453 elif new_path is not None and (hunk := parse_diff_hunk_line(line)): 

454 in_hunk = True 

455 keeping = keep_hunk(new_path, hunk.old_line, hunk.new_line) 

456 if keeping: 

457 out.extend(header) 

458 header = [] 

459 out.append(line) 

460 elif not in_hunk: 

461 # File metadata before the first hunk, or preamble before any file. 

462 (header if new_path is not None else out).append(line) 

463 elif keeping: 

464 out.append(line) # a body line of a kept hunk 

465 flush_unhunked_file() 

466 

467 result = "\n".join(out) 

468 # The input's final newline arrives as a trailing "" split element attached 

469 # to the last hunk's body; if that hunk was dropped, restore the newline so 

470 # the output stays a well-terminated diff. 

471 if diff_text.endswith("\n") and result and not result.endswith("\n"): 

472 result += "\n" 

473 return result