char-slop/berry

multipurpose self-modifiable agentic harness

git clone https://git.t4t.associates/char-slop/berry

Charlotte Somadd diff extension806cc3c

main
7.0 KiB181 linesraw
1// Git-format patches, as `git diff` and `jj diff --git` write them, read into files and their hunks.
2
3/** One file's change. */
4export type FileDiff = {
5  /** Where it is after the change; where it was, for a deletion. */
6  path: string;
7  /** Where it was before, when it was renamed or copied. */
8  from: string | null;
9  status: "added" | "deleted" | "modified" | "renamed" | "copied";
10  /** Its modes either side, as far as the patch says: such as `100755` once a script is executable. */
11  modes: { old: string | null; new: string | null };
12  binary: boolean;
13  hunks: Hunk[];
14};
15
16/**
17 * A stretch of the file: the line it starts at on either side, and its lines, each marked `" "`, `"-"` or `"+"`, or
18 * `"\\"` when the line before it has no newline.
19 */
20export type Hunk = { old: number; new: number; lines: string[] };
21
22const HUNK = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
23
24/** The files `patch` changes, in its order; whatever surrounds them, such as a commit's header, is skipped. */
25export function parsePatch(patch: string): FileDiff[] {
26  const lines = patch.split("\n");
27  const files: FileDiff[] = [];
28  let i = 0;
29  while (i < lines.length) {
30    const header = lines[i++];
31    if (!header.startsWith("diff --git ")) continue;
32    const [a, b] = headerPaths(header.slice("diff --git ".length));
33    let old: string | null = a;
34    let neu: string | null = b;
35    let status: FileDiff["status"] = "modified";
36    const modes: FileDiff["modes"] = { old: null, new: null };
37    let binary = false;
38    const hunks: Hunk[] = [];
39    for (; i < lines.length && !lines[i].startsWith("diff --git "); i++) {
40      const line = lines[i];
41      const hunk = line.match(HUNK);
42      if (hunk) {
43        let oldLeft = hunk[2] === undefined ? 1 : Number(hunk[2]);
44        let newLeft = hunk[4] === undefined ? 1 : Number(hunk[4]);
45        const body: string[] = [];
46        while (i + 1 < lines.length && (oldLeft > 0 || newLeft > 0 || lines[i + 1][0] === "\\")) {
47          const next = lines[++i];
48          // `diff.suppressBlankEmpty` writes an empty context line without its mark
49          const mark = next === "" ? " " : next[0];
50          if (mark === " ") oldLeft--, newLeft--;
51          else if (mark === "-") oldLeft--;
52          else if (mark === "+") newLeft--;
53          else if (mark !== "\\") {
54            i--;
55            break;
56          }
57          body.push(next === "" ? " " : next);
58        }
59        hunks.push({ old: Number(hunk[1]), new: Number(hunk[3]), lines: body });
60        continue;
61      }
62      const [, key, value] = line.match(
63        /^(new file mode|deleted file mode|old mode|new mode|rename from|rename to|copy from|copy to|index) (.*)$/,
64      ) ?? [];
65      if (line.startsWith("--- ")) old = sidePath(line.slice(4), "a/");
66      else if (line.startsWith("+++ ")) neu = sidePath(line.slice(4), "b/");
67      else if (key === "new file mode") (status = "added"), (modes.new = value), (old = null);
68      else if (key === "deleted file mode") (status = "deleted"), (modes.old = value), (neu = null);
69      else if (key === "old mode") modes.old = value;
70      else if (key === "new mode") modes.new = value;
71      else if (key === "rename from") (status = "renamed"), (old = unquote(value));
72      else if (key === "rename to") neu = unquote(value);
73      else if (key === "copy from") (status = "copied"), (old = unquote(value));
74      else if (key === "copy to") neu = unquote(value);
75      else if (key === "index") {
76        const mode = value.split(" ")[1];
77        if (mode) modes.old ??= mode, modes.new ??= mode;
78      } else if (line.startsWith("Binary files ") || line === "GIT binary patch") binary = true;
79    }
80    if (status === "modified" && old !== null && neu !== null && old !== neu) status = "renamed";
81    files.push({
82      path: neu ?? old ?? b,
83      from: status === "renamed" || status === "copied" ? old : null,
84      status,
85      modes,
86      binary,
87      hunks,
88    });
89  }
90  return files;
91}
92
93/** The two paths of `diff --git a/x b/y`, which are ambiguous unquoted; patches say them again where it matters. */
94function headerPaths(rest: string): [string, string] {
95  if (rest.startsWith('"')) {
96    const [a, end] = quoted(rest);
97    const after = rest.slice(end + 2);
98    return [strip(a, "a/"), strip(after.startsWith('"') ? quoted(after)[0] : after, "b/")];
99  }
100  if (rest.endsWith('"')) {
101    const at = rest.lastIndexOf(' "');
102    return [strip(rest.slice(0, at), "a/"), strip(quoted(rest.slice(at + 1))[0], "b/")];
103  }
104  // unless one was renamed or copied, which says so on its own lines, both sides are the same path
105  const half = (rest.length - 1) / 2;
106  if (Number.isInteger(half) && rest.slice(2, half) === rest.slice(half + 3)) {
107    return [rest.slice(2, half), rest.slice(half + 3)];
108  }
109  const at = rest.indexOf(" b/");
110  return [strip(rest.slice(0, at), "a/"), rest.slice(at + 3)];
111}
112
113/** A `---` or `+++` line's path, or `null` for `/dev/null`; git ends those with spaces in them with a tab. */
114function sidePath(text: string, prefix: string): string | null {
115  const path = unquote(text.replace(/\t$/, ""));
116  return path === "/dev/null" ? null : strip(path, prefix);
117}
118
119const strip = (path: string, prefix: string) =>
120  path.startsWith(prefix) ? path.slice(prefix.length) : path;
121
122const unquote = (text: string) => text.startsWith('"') ? quoted(text)[0] : text;
123
124const ESCAPES: Record<string, number> = {
125  a: 7,
126  b: 8,
127  t: 9,
128  n: 10,
129  v: 11,
130  f: 12,
131  r: 13,
132  '"': 34,
133  "\\": 92,
134};
135
136/** A C-quoted string at the start of `text`, as git quotes unusual paths, and the index of its closing quote. */
137function quoted(text: string): [string, number] {
138  const bytes: number[] = [];
139  const encoder = new TextEncoder();
140  let i = 1;
141  for (; i < text.length && text[i] !== '"'; i++) {
142    if (text[i] !== "\\") {
143      bytes.push(...encoder.encode(text[i]));
144      continue;
145    }
146    const octal = text.slice(i + 1, i + 4);
147    if (/^[0-7]{3}$/.test(octal)) {
148      bytes.push(parseInt(octal, 8));
149      i += 3;
150    } else {
151      i++;
152      bytes.push(ESCAPES[text[i]] ?? text.charCodeAt(i));
153    }
154  }
155  return [new TextDecoder().decode(new Uint8Array(bytes)), i];
156}
157
158/** `file` with only `context` unchanged lines around each change, for files too long to send whole. */
159export function narrow(file: FileDiff, context: number): FileDiff {
160  const hunks: Hunk[] = [];
161  for (const hunk of file.hunks) {
162    let oldLine = hunk.old;
163    let newLine = hunk.new;
164    const changed = hunk.lines.map((line) => line[0] === "-" || line[0] === "+");
165    // a missing newline belongs to the line before it
166    const near = hunk.lines.map((line, i) =>
167      line[0] === "\\" ||
168      changed.slice(Math.max(0, i - context), i + context + 1).some(Boolean)
169    );
170    let current: Hunk | null = null;
171    for (const [i, line] of hunk.lines.entries()) {
172      if (near[i]) {
173        current ??= hunks[hunks.push({ old: oldLine, new: newLine, lines: [] }) - 1];
174        current.lines.push(line);
175      } else current = null;
176      if (line[0] === " " || line[0] === "-") oldLine++;
177      if (line[0] === " " || line[0] === "+") newLine++;
178    }
179  }
180  return { ...file, hunks };
181}