SLOPSHOPPER

replay-theater-plus

Spiel die Dateiänderungen des letzten Turns Schritt für Schritt ab, auch Änderungen durch Shell-Befehle (in Git-Repos).

newpanebandguardcommandprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · replay-theater-plus
│ ┃ Replay Theater ✕ › fix the failing auth test and add an audit log call │ ┃ ╭─────────────────────────────────────────── │ ┃ │ ▶ Replay Theater Schritt 1 von 3 ⏺ Read(src/auth.ts) │ ┃ │ 1 2 3 ⎿ Read 6 lines │ ┃ │ ⏺ Update(src/auth.ts) │ ┃ │ src/auth.ts +2 -1 ⎿ Added 2 lines, removed 1 line │ ┃ │ Edit ⏺ Bash(bun test) │ ┃ │ ⎿ 3 pass, 1 fail │ ┃ │ - if (!claims) throw new Error('invalid… │ ┃ │ + if (!claims || claims.exp < Date.now(… ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ + await audit('refresh', claims.sub) │ ┃ │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ [ ◀ Zurück ] [ Weiter ▶ ] [ Schließen ] │ ┃ ╰─────────────────────────────────────────── › /replay │ ⎿ replay-theater-plus: Replay Theater: 3 Änderungen │ │ ▶ Replay: 3 Änderungen (r drücken) [ Abspielen ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▶ Replay: 3 Änderungen (r drücken) [ Abspielen ]
Pane · Replay Theater
╭──────────────────────────────────────────────────────────╮ │ ▶ Replay Theater Schritt 1 von 3 │ │ 1 2 3 │ │ │ │ src/auth.ts +2 -1 │ │ Edit │ │ │ │ - if (!claims) throw new Error('invalid token') │ │ + if (!claims || claims.exp < Date.now() / 1000) throw … │ │ + await audit('refresh', claims.sub) │ │ │ │ [ ◀ Zurück ] [ Weiter ▶ ] [ Schließen ] │ ╰──────────────────────────────────────────────────────────╯
README

Replay Theater Plus

Nach jedem Turn siehst du alle Dateiänderungen von Claude noch mal, Schritt für Schritt, als kleinen Diff in Rot und Grün.

Was es macht

Während ein Turn läuft, merkt sich der Mod jede Änderung durch Edit, Write und MultiEdit. In einem Git-Repo erkennt er zusätzlich Dateien, die ein Shell-Befehl geändert hat (sed -i, mv, ein Codemod). Die stehen als Schritt mit dem Label "Shell" drin.

Wenn der Turn fertig ist, erscheint über dem Prompt:

▶ Replay: 3 Änderungen (r drücken)  [ Abspielen ]

Im Fenster siehst du pro Schritt:

  • oben "Schritt 2 von 5" und eine Leiste mit allen Schritten
  • den Dateipfad groß, daneben +3 -1
  • darunter Tool und Hinweis, z. B. Write · neue Datei oder MultiEdit · Bearbeitung 1 von 3
  • den Diff, höchstens 12 Zeilen, danach "… 7 weitere Zeilen"

Der Mod schaut nur zu. Er blockiert, ändert oder verzögert keine Änderung.

Installation

/plugin marketplace add Verselo-Orbit/claude-mods
/plugin install replay-theater-plus@verselo-mods

Bedienung

Öffnen, wenn der Turn fertig ist:

  • /replay eingeben, oder
  • ctrl+x und dann Tab (Fokus auf die Zeile über dem Prompt), dann r

Im Fenster:

TasteWas passiert
nWeiter ▶
p◀ Zurück
c oder EscapeSchließen

Klicks auf die Buttons gehen auch. Das Replay bleibt, bis ein neuer Turn wieder Dateien ändert.

Grenzen

  • Shell-Änderungen siehst du nur in einem Git-Repo. Außerhalb läuft nur die Aufzeichnung von Edit, Write und MultiEdit.
  • Pro Turn höchstens 20 Dateien aus Shell-Befehlen. Jeder Git-Aufruf hat 5 Sekunden Timeout.
  • Verglichen wird mit git diff (nicht gestagte Änderungen). Was ein Befehl direkt mit git add staged, taucht ohne Diff auf.
  • Eine Datei, die vor dem Turn schon ungetrackt war und dann per Shell geändert wird, erkennt der Mod nicht.
  • Dateien, die schon per Edit/Write erfasst sind, kommen nicht doppelt als Shell-Schritt.
  • Pfade mit Sonderzeichen, die Git in Anführungszeichen setzt, werden bei Shell-Änderungen übersprungen.
  • Bei Edit zeigt der Diff old_string gegen new_string, nicht die ganze Datei. Zeilennummern gibt es nicht.
  • Write liest die alte Datei kurz vor dem Schreiben. Über 400 Zeilen wird alles als entfernt und neu gezeigt.
  • Eine Änderung, die du ablehnst oder die fehlschlägt, steht trotzdem im Replay.
  • Das Replay lebt nur im Speicher dieser Session.
  • Änderungen von Subagents landen im Replay des Haupt-Turns.
  • Ist das Terminal zu schmal für ein Fenster, zeichnet der Mod das Replay in die Zeile über dem Prompt. Diesen Fall haben wir nicht getestet.
  • Die Zeile über dem Prompt teilen sich alle Mods. Zeichnet dort noch ein anderer Mod, siehst du nur einen.

Getestet auf macOS mit Claude Code 2.1.295. Windows: nicht getestet.

Credits

Basiert auf Replay Theater von Anthropic (Apache-2.0). Änderungen von Verselo Systems SL, siehe NOTICE.md.

Source 2 files
hooks/replay-theater.mjs 303 lines
1// Copyright 2026 Anthropic PBC
2// SPDX-License-Identifier: Apache-2.0
3// Modified by Verselo Systems SL, 2026: German UI, readability, fixes (see NOTICE.md)
4//
5// Replay Theater: records the file edits Claude makes in a turn, then lets you
6// step through them in a pane, one diff at a time. Inside a git repo it also
7// picks up files changed by shell commands (sed -i, mv, codemods).
8//
9// Claude Code function hooks. Load with --plugin-dir.
10
11import { MAX_DIFF_LINES, diffLines, countChanges, parseUnifiedDiff, parseStatusZ, splitGitDiff } from "./diff.mjs";
12
13const PANE_ID = "replay-theater";
14const EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit"]);
15const MAX_SHELL_FILES = 20;
16const GIT_TIMEOUT_MS = 5000;
17const MAX_STRIP_CELLS = 15;
18
19// Module state. `pending` fills during a turn; `replay` is the last finished
20// turn's steps, the ones the pane shows. `snap` is the git snapshot taken when
21// the main-loop turn started (null outside a git repo).
22const state = { pending: [], replay: [], index: 0, isOpen: false, inBand: false, turns: 0, snap: null };
23
24function relPath(cwd, path) {
25  if (!path) return "(unbekannte Datei)";
26  if (cwd && path.startsWith(cwd + "/")) return path.slice(cwd.length + 1);
27  return path;
28}
29
30function plural(n) {
31  return `${n} Änderung${n === 1 ? "" : "en"}`;
32}
33
34async function sessionCwd($) {
35  try { return (await $.session.cwd()) || ""; } catch { return ""; }
36}
37
38// Turns one tool call into one or more steps. `abs` is kept so the shell
39// detection can skip files an edit tool already recorded.
40async function stepsFor($, e) {
41  const cwd = await sessionCwd($);
42  const file = relPath(cwd, e.file_path);
43  const abs = e.file_path;
44  if (e.tool === "Edit") {
45    return [{ tool: "Edit", file, abs, diff: diffLines(e.old_string, e.new_string), note: e.replace_all ? "alles ersetzt" : "" }];
46  }
47  if (e.tool === "MultiEdit" && Array.isArray(e.edits)) {
48    return e.edits.map((ed, k) => ({
49      tool: "MultiEdit", file, abs, diff: diffLines(ed.old_string, ed.new_string), note: `Bearbeitung ${k + 1} von ${e.edits.length}`,
50    }));
51  }
52  if (e.tool === "Write") {
53    let before = "";
54    let isNew = true;
55    try {
56      if (e.file_path && (await $.fs.exists(e.file_path))) {
57        before = await $.fs.read(e.file_path);
58        isNew = false;
59      }
60    } catch { /* unreadable: show it as a new file */ }
61    return [{ tool: "Write", file, abs, diff: diffLines(before, e.content), note: isNew ? "neue Datei" : "überschrieben" }];
62  }
63  return [];
64}
65
66// --- Shell changes (git) ---------------------------------------------------
67
68async function git($, cwd, args) {
69  return $.process.run(["git", ...args], { cwd, timeoutMs: GIT_TIMEOUT_MS });
70}
71
72// The repo's state: its top folder, each changed path's status code and its
73// `git diff` chunk. null outside a git repo, or when git fails.
74async function gitSnapshot($) {
75  try {
76    const cwd = await sessionCwd($);
77    if (!cwd) return null;
78    const top = await git($, cwd, ["rev-parse", "--show-toplevel"]);
79    if (top.exitCode !== 0) return null;
80    const root = top.stdout.trim();
81    const [status, diff] = await Promise.all([
82      git($, root, ["status", "--porcelain", "-z", "--untracked-files=all"]),
83      git($, root, ["-c", "core.quotePath=false", "diff", "--no-color", "--no-ext-diff"]),
84    ]);
85    if (status.exitCode !== 0 || diff.exitCode !== 0) return null;
86    return { cwd, root, status: parseStatusZ(status.stdout), chunks: splitGitDiff(diff.stdout) };
87  } catch {
88    return null;
89  }
90}
91
92// One "Shell" step per file whose status or diff changed between the two
93// snapshots and that no edit tool recorded this turn. At most 20 files.
94async function shellSteps($, before, after, recorded) {
95  if (!before || !after || before.root !== after.root) return [];
96  const key = (snap, p) => `${snap.status.get(p) ?? ""}\0${snap.chunks.get(p) ?? ""}`;
97  const paths = [...new Set([...before.status.keys(), ...after.status.keys()])]
98    .filter((p) => key(before, p) !== key(after, p))
99    .filter((p) => !recorded.has(`${after.root}/${p}`))
100    .sort()
101    .slice(0, MAX_SHELL_FILES);
102  const steps = [];
103  for (const p of paths) {
104    const abs = `${after.root}/${p}`;
105    const code = after.status.get(p);
106    let text = after.chunks.get(p) ?? "";
107    let note = "";
108    if (code === "??") {
109      // Untracked: plain `git diff` shows nothing, so diff against nothing.
110      note = "neue Datei";
111      try {
112        text = (await git($, after.root, ["diff", "--no-color", "--no-ext-diff", "--no-index", "--", "/dev/null", p])).stdout;
113      } catch { text = ""; }
114    } else if (code === undefined) {
115      note = "zurückgesetzt";
116    } else if (code.includes("D")) {
117      note = "gelöscht";
118    }
119    steps.push({ tool: "Shell", file: relPath(after.cwd, abs), abs, diff: parseUnifiedDiff(text), note });
120  }
121  return steps;
122}
123
124// --- UI --------------------------------------------------------------------
125
126function hintText() {
127  return `▶ Replay: ${plural(state.replay.length)} (r drücken)`;
128}
129
130async function openReplay($) {
131  if (!state.replay.length) return false;
132  state.index = 0;
133  state.isOpen = true;
134  // The pane only takes the keyboard while nothing else holds it. Hide the
135  // band first (it held the keys if its button was pressed), then open.
136  $.ui.invalidate("ui.render");
137  await $.clock.sleep(200);
138  const rows = Math.min(MAX_DIFF_LINES + 9, 23);
139  const placed = await $.ui.open({ id: PANE_ID, title: "Replay Theater", focus: true, closeOnEscape: true, rows, columns: 64 });
140  // No room for a pane (a narrow terminal): draw the replay in the band.
141  state.inBand = placed?.isPlaced === false;
142  $.ui.invalidate("ui.render");
143  return true;
144}
145
146// Which step numbers the strip shows: all of them when they fit, else a
147// window around the current one.
148function stripWindow(k, total) {
149  if (total <= MAX_STRIP_CELLS) return { from: 0, to: total };
150  const from = Math.max(0, Math.min(k - Math.floor(MAX_STRIP_CELLS / 2), total - MAX_STRIP_CELLS));
151  return { from, to: from + MAX_STRIP_CELLS };
152}
153
154// The replay view: one step at a time. Drawn in the Pane, or in the band
155// above the prompt when the surface can't place a pane.
156function replayView($, e, inBand) {
157  const { Box, Text, Button } = $.ui.resolve(e);
158  const maxDiff = inBand ? Math.max(3, Math.min(MAX_DIFF_LINES, (e.maxRows || 20) - 8)) : MAX_DIFF_LINES;
159  const total = state.replay.length;
160  if (!total) return Text({ dimColor: true, children: "Keine Änderungen zum Abspielen." });
161  const k = Math.max(0, Math.min(state.index, total - 1));
162  const step = state.replay[k];
163  const width = Math.max(40, (e.bodyColumns || 100) - 4);
164  const { add, del } = countChanges(step.diff);
165  const shown = step.diff.slice(0, maxDiff);
166
167  const diffRows = shown.map((l, n) => {
168    const color = l.op === "+" ? "green" : l.op === "-" ? "red" : undefined;
169    const line = `${l.op === "~" ? " " : l.op} ${l.t}`.slice(0, width);
170    return Text({ key: `d${n}`, color, dimColor: l.op === " " || l.op === "~", wrap: "truncate-end", children: line });
171  });
172  if (step.diff.length > maxDiff) diffRows.push(Text({ key: "more", dimColor: true, children: `  … ${step.diff.length - maxDiff} weitere Zeilen` }));
173  if (!shown.length) diffRows.push(Text({ key: "empty", dimColor: true, children: "  (keine Zeilenänderungen)" }));
174
175  // The step strip: one cell per step, the current one highlighted. Many
176  // steps scroll in a window with "…" on the cut sides.
177  const { from, to } = stripWindow(k, total);
178  const strip = [];
179  if (from > 0) strip.push(Text({ key: "s-left", dimColor: true, children: " … " }));
180  for (let n = from; n < to; n++) {
181    strip.push(Text({ key: `s${n}`, inverse: n === k, bold: n === k, color: n === k ? "cyan" : undefined, dimColor: n !== k, children: ` ${n + 1} ` }));
182  }
183  if (to < total) strip.push(Text({ key: "s-right", dimColor: true, children: " … " }));
184
185  const go = (to) => { state.index = Math.max(0, Math.min(to, total - 1)); $.ui.invalidate("ui.render"); };
186  const close = () => {
187    state.isOpen = false;
188    if (!state.inBand) $.ui.close({ id: PANE_ID });
189    state.inBand = false;
190    $.ui.invalidate("ui.render");
191  };
192
193  return Box({
194    flexDirection: "column", borderStyle: "round", borderColor: "magenta", paddingX: 1,
195    children: [
196      Box({ flexDirection: "row", justifyContent: "space-between", children: [
197        Text({ bold: true, color: "magenta", children: "▶ Replay Theater" }),
198        Text({ key: "counter", bold: true, children: `Schritt ${k + 1} von ${total}` }),
199      ] }),
200      Box({ flexDirection: "row", children: strip }),
201      // The file is the headline: bold, with its +/- counts right beside it.
202      Box({ flexDirection: "row", gap: 2, marginTop: 1, children: [
203        Text({ key: "file", bold: true, color: "cyan", wrap: "truncate-start", children: step.file }),
204        Text({ key: "add", bold: true, color: "green", children: `+${add}` }),
205        Text({ key: "del", bold: true, color: "red", children: `-${del}` }),
206      ] }),
207      Text({ key: "tool", dimColor: true, children: `${step.tool}${step.note ? " · " + step.note : ""}` }),
208      Box({ flexDirection: "column", marginTop: 1, children: diffRows }),
209      Box({ flexDirection: "row", gap: 2, marginTop: 1, children: [
210        Button({ key: "prev", label: "◀ Zurück", hotkey: "p", onPress: () => go(k - 1) }),
211        Button({ key: "next", label: "Weiter ▶", hotkey: "n", autoFocus: true, onPress: () => go(k + 1) }),
212        Button({ key: "close", label: "Schließen", hotkey: "c", onPress: close }),
213      ] }),
214    ],
215  });
216}
217
218export function register(on, options) {
219  // Slash command: /replay opens the pane.
220  on("session.start", async ($, e, next) => {
221    const r = await next(e);
222    try {
223      await $.command.register({ name: "replay", description: "Replay Theater: die Änderungen des letzten Turns Schritt für Schritt ansehen" });
224    } catch { /* the command is a convenience; the band still works */ }
225    return r;
226  });
227
228  on("command.run", { command: "replay" }, async ($, e) => {
229    const opened = await openReplay($);
230    return { text: opened ? `Replay Theater: ${plural(state.replay.length)}` : "Replay Theater: keine Änderungen im letzten Turn." };
231  });
232
233  // Record each edit, then let it run. Never blocks the call.
234  on("tool.call", async ($, e, next) => {
235    if (EDIT_TOOLS.has(e.tool)) {
236      try {
237        const steps = await stepsFor($, e);
238        state.pending.push(...steps);
239      } catch { /* recording must never stop the edit */ }
240    }
241    return next(e);
242  });
243
244  // A main-loop turn starts: clear the pending steps and snapshot git, so
245  // turn.complete can tell which files shell commands changed.
246  on("turn.start", async ($, e, next) => {
247    if (!e.agentId) {
248      state.pending = [];
249      state.snap = await gitSnapshot($);
250    }
251    return next(e);
252  });
253
254  // At the end of a main-loop turn, the pending edits (plus shell changes)
255  // become the replay.
256  on("turn.complete", async ($, e, next) => {
257    const r = await next(e);
258    if (e.agentId) return r;
259    try {
260      const before = state.snap;
261      state.snap = null;
262      if (before) {
263        const recorded = new Set(state.pending.map((s) => s.abs).filter(Boolean));
264        state.pending.push(...(await shellSteps($, before, await gitSnapshot($), recorded)));
265      }
266    } catch { /* shell detection is best effort */ }
267    if (!state.pending.length) return r;
268    state.replay = state.pending;
269    state.pending = [];
270    state.index = 0;
271    state.turns++;
272    $.ui.invalidate("ui.render");
273    return r;
274  });
275
276  // The band above the prompt: the hint and a button that opens the pane.
277  on("ui.render", { component: "AbovePrompt" }, ($, e, next) => {
278    if (state.isOpen && state.inBand) return replayView($, e, true);
279    if (!state.replay.length || state.isOpen) return next(e);
280    const { Box, Text, Button } = $.ui.resolve(e);
281    return Box({
282      flexDirection: "row", gap: 2, paddingX: 1,
283      children: [
284        Text({ key: "hint", color: "magenta", bold: true, children: hintText() }),
285        Button({ key: "open-replay", label: "Abspielen", hotkey: "r", onPress: () => openReplay($) }),
286      ],
287    });
288  });
289
290  // The pane.
291  on("ui.render", { component: "Pane" }, ($, e, next) => {
292    if (e.requestId !== PANE_ID) return next(e);
293    return replayView($, e, false);
294  });
295
296  // The person closed the pane (Escape): keep our flag in step.
297  on("ui.close", ($, e, next) => {
298    if (e.id === PANE_ID || e.requestId === PANE_ID) state.isOpen = false;
299    $.ui.invalidate("ui.render");
300    return next(e);
301  });
302}
303
hooks/diff.mjs 123 lines
1// Copyright 2026 Anthropic PBC
2// SPDX-License-Identifier: Apache-2.0
3// Modified by Verselo Systems SL, 2026: German UI, readability, fixes (see NOTICE.md)
4//
5// Pure diff helpers: the line diff for edit tools, and the parsers for git's
6// output that the shell-change detection uses. No `$` in here, so the tests
7// can call them directly.
8
9export const MAX_DIFF_LINES = 12;
10export const MAX_LCS_LINES = 400;
11
12export function splitLines(text) {
13  if (text === undefined || text === null || text === "") return [];
14  const lines = String(text).split("\n");
15  if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
16  return lines;
17}
18
19// A line diff. Small inputs get an LCS diff, so unchanged lines show as
20// context; large ones fall back to all-removed then all-added.
21export function diffLines(oldText, newText) {
22  const a = splitLines(oldText);
23  const b = splitLines(newText);
24  if (a.length > MAX_LCS_LINES || b.length > MAX_LCS_LINES) {
25    return [...a.map((t) => ({ op: "-", t })), ...b.map((t) => ({ op: "+", t }))];
26  }
27  const n = a.length, m = b.length;
28  const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
29  for (let i = n - 1; i >= 0; i--)
30    for (let j = m - 1; j >= 0; j--)
31      dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
32  const out = [];
33  let i = 0, j = 0;
34  while (i < n && j < m) {
35    if (a[i] === b[j]) { out.push({ op: " ", t: a[i] }); i++; j++; }
36    else if (dp[i + 1][j] >= dp[i][j + 1]) out.push({ op: "-", t: a[i++] });
37    else out.push({ op: "+", t: b[j++] });
38  }
39  while (i < n) out.push({ op: "-", t: a[i++] });
40  while (j < m) out.push({ op: "+", t: b[j++] });
41  return trimContext(out);
42}
43
44// Keep one line of context around each change, so a long file's Write shows
45// the changed lines, not the whole file. A skipped run becomes one "⋯" row.
46export function trimContext(lines) {
47  if (!lines.some((l) => l.op !== " " && l.op !== "~")) return lines.slice(0, MAX_DIFF_LINES);
48  const changed = (l) => l && l.op !== " " && l.op !== "~";
49  const keep = lines.map((l, k) => changed(l) || (l.op === " " && (changed(lines[k - 1]) || changed(lines[k + 1]))));
50  const out = [];
51  let skipped = false;
52  lines.forEach((l, k) => {
53    if (keep[k]) { if (skipped && out.length) out.push({ op: "~", t: "⋯" }); out.push(l); skipped = false; }
54    else skipped = true;
55  });
56  return out;
57}
58
59export function countChanges(diff) {
60  let add = 0, del = 0;
61  for (const l of diff) { if (l.op === "+") add++; else if (l.op === "-") del++; }
62  return { add, del };
63}
64
65// A unified diff (one file's `git diff` output) as {op,t} rows. Header lines
66// (diff --git, index, ---/+++, mode lines, @@) are dropped; a gap between two
67// hunks becomes one "⋯" row. Then the same context trim as the edit tools.
68export function parseUnifiedDiff(text) {
69  const out = [];
70  let inHunk = false;
71  for (const line of String(text ?? "").split("\n")) {
72    if (line.startsWith("@@")) {
73      if (out.length) out.push({ op: "~", t: "⋯" });
74      inHunk = true;
75      continue;
76    }
77    if (!inHunk) continue;
78    const c = line[0];
79    if (c === "+" || c === "-" || c === " ") out.push({ op: c, t: line.slice(1) });
80    else if (line === "") continue; // trailing newline of the output
81    else if (c === "\\") continue; // "\ No newline at end of file"
82    else inHunk = false; // the next file's "diff --git" header
83  }
84  return trimContext(out);
85}
86
87// `git status --porcelain -z --untracked-files=all` as Map(path -> "XY").
88// A rename or copy entry carries its source as one more NUL field; skip it.
89export function parseStatusZ(text) {
90  const map = new Map();
91  const parts = String(text ?? "").split("\0");
92  for (let k = 0; k < parts.length; k++) {
93    const p = parts[k];
94    if (p.length < 4) continue;
95    const xy = p.slice(0, 2);
96    map.set(p.slice(3), xy);
97    if (xy[0] === "R" || xy[0] === "C") k++;
98  }
99  return map;
100}
101
102// A whole `git diff` output split into Map(path -> that file's chunk). The
103// path comes from "diff --git a/P b/P"; quoted or renamed headers are skipped
104// (ponytail: such files are never told apart, so they never show as Shell).
105export function splitGitDiff(text) {
106  const map = new Map();
107  let path = null, buf = [];
108  const flush = () => { if (path !== null) map.set(path, buf.join("\n")); };
109  for (const line of String(text ?? "").split("\n")) {
110    if (line.startsWith("diff --git ")) {
111      flush();
112      buf = [];
113      const rest = line.slice("diff --git ".length);
114      const len = (rest.length - 5) / 2; // "a/P b/P"
115      const p = Number.isInteger(len) && len > 0 ? rest.slice(2, 2 + len) : null;
116      path = p !== null && rest === `a/${p} b/${p}` ? p : null;
117    }
118    buf.push(line);
119  }
120  flush();
121  return map;
122}
123