1/**
2 * How a file's name travels in a unified patch, which is not as itself. A reader finds the name in
3 * a header by where it stops: `diff --git a/<old> b/<new>` splits on a space, `--- a/<name>` stops
4 * at a tab. So a name holding a tab or a newline reads back as the part of itself before that
5 * byte, or fabricates a header line of its own, and git's answer is to write such a name quoted
6 * with C-style escapes. Both halves live here so what one writes is what the other reads.
7 */
8
9/**
10 * What git escapes by name, as the character written and the escape written for it. Not every byte
11 * git would escape: `core.quotePath` also escapes anything outside ASCII, which is a setting for
12 * what a terminal can show rather than anything the format needs, and a patch here is read by a
13 * diff viewer.
14 */
15const ESCAPE_BY_CHARACTER = new Map([
16 ['"', '\\"'],
17 ["\\", "\\\\"],
18 ["\u0007", "\\a"],
19 ["\b", "\\b"],
20 ["\t", "\\t"],
21 ["\n", "\\n"],
22 ["\v", "\\v"],
23 ["\f", "\\f"],
24 ["\r", "\\r"],
25]);
26
27/** The escape written for a character, as the byte it stands for. */
28const CHARACTER_BY_ESCAPE: Record<string, number> = {
29 '"': 0x22,
30 "\\": 0x5c,
31 a: 0x07,
32 b: 0x08,
33 f: 0x0c,
34 n: 0x0a,
35 r: 0x0d,
36 t: 0x09,
37 v: 0x0b,
38};
39
40const QUOTE = '"';
41const DELETE_CHARACTER = 0x7f;
42const LOWEST_PRINTABLE = 0x20;
43const BACKSLASH = 0x5c;
44
45const utf8 = new TextEncoder();
46const fromUtf8 = new TextDecoder();
47
48/**
49 * A name as a patch header can carry it: itself where that is unambiguous, and git's quoted form
50 * where it is not, which is any name holding a quote, a backslash or a control character. A header
51 * side's `a/` or `b/` belongs inside the quoting, so pass it in along with the name: what git
52 * quotes is the whole token a reader takes off the line, side letter and all.
53 */
54export function quoteGitPatchPath(path: string): string {
55 let body = "";
56 let quoting = false;
57 for (const character of path) {
58 const escape = ESCAPE_BY_CHARACTER.get(character);
59 if (escape !== undefined) {
60 body += escape;
61 quoting = true;
62 continue;
63 }
64 const code = character.codePointAt(0) ?? 0;
65 // Every character git has no name for is written as the octal of its byte, and a control
66 // character is one byte in UTF-8, so the character's own code point is that byte.
67 if (code < LOWEST_PRINTABLE || code === DELETE_CHARACTER) {
68 body += `\\${code.toString(8).padStart(3, "0")}`;
69 quoting = true;
70 continue;
71 }
72 body += character;
73 }
74 return quoting ? `${QUOTE}${body}${QUOTE}` : path;
75}
76
77/**
78 * The escapes inside a quoted form undone, whether or not the quotes are still around them. A name
79 * holding no backslash is already itself, and an escape git would never write reads the way C
80 * reads it. The escapes are per byte, so a name in another alphabet arrives as a run of octal and
81 * only reads back as itself once those bytes are rejoined and decoded together.
82 */
83function unescapeBody(body: string): string {
84 if (!body.includes("\\")) return body;
85 const bytes: Array<number> = [];
86 // Anything left as itself is encoded a run at a time rather than a unit at a time, so a
87 // character outside the basic plane keeps its pair together rather than coming back as halves.
88 let literal = "";
89 const flush = () => {
90 if (literal.length === 0) return;
91 bytes.push(...utf8.encode(literal));
92 literal = "";
93 };
94 let at = 0;
95 while (at < body.length) {
96 const character = body.charAt(at);
97 if (character !== "\\") {
98 literal += character;
99 at += 1;
100 continue;
101 }
102 const escaped = body.charAt(at + 1);
103 if (escaped === "") {
104 flush();
105 bytes.push(BACKSLASH);
106 break;
107 }
108 const named = CHARACTER_BY_ESCAPE[escaped];
109 if (named !== undefined) {
110 flush();
111 bytes.push(named);
112 at += 2;
113 continue;
114 }
115 const octal = body.slice(at + 1, at + 4);
116 if (/^[0-7]{3}$/.test(octal)) {
117 flush();
118 bytes.push(Number.parseInt(octal, 8));
119 at += 4;
120 continue;
121 }
122 literal += escaped;
123 at += 2;
124 }
125 flush();
126 return fromUtf8.decode(new Uint8Array(bytes));
127}
128
129/**
130 * One header's name token as the name it stands for. The escapes are undone whether the quotes are
131 * still there or not, because patch parsers disagree about how much of the quoting they hand back:
132 * the one the clients read diffs with takes the quotes off the `diff --git` line's names and
133 * leaves them on a rename's.
134 */
135export function unquoteGitPatchPath(token: string): string {
136 if (token.length >= 2 && token.startsWith(QUOTE) && token.endsWith(QUOTE)) {
137 return unescapeBody(token.slice(1, -1));
138 }
139 return unescapeBody(token);
140}