second-read 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/git.js ADDED
@@ -0,0 +1,531 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { accessSync, constants, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, unlinkSync, writeFileSync, } from "node:fs";
3
+ import { constants as os } from "node:os";
4
+ import { basename, dirname, join, resolve, sep } from "node:path";
5
+ import { createInterface } from "node:readline";
6
+ import { REPO_FILE } from "./config.js";
7
+ export const HOOKS = ["commit-msg", "pre-push"];
8
+ export const HOOK_MARKER = "# Added by second-read.";
9
+ // A hook that was already there is set aside under this suffix, and second-read's hook runs it after the check.
10
+ export const PREVIOUS = ".second-read-previous";
11
+ // Every hook git runs, from githooks(5), except push-to-checkout: when that one exists, git leaves the work tree
12
+ // update to it, so a stand-in that does nothing would leave the tree stale.
13
+ export const GIT_HOOKS = [
14
+ "applypatch-msg", "pre-applypatch", "post-applypatch", "pre-commit", "pre-merge-commit", "prepare-commit-msg",
15
+ "commit-msg", "post-commit", "pre-rebase", "post-checkout", "post-merge", "pre-push", "pre-receive", "update",
16
+ "proc-receive", "post-receive", "post-update", "reference-transaction", "pre-auto-gc", "post-rewrite",
17
+ "sendemail-validate", "fsmonitor-watchman", "p4-changelist", "p4-prepare-changelist", "p4-post-changelist",
18
+ "p4-pre-submit", "post-index-change",
19
+ ];
20
+ // Where the hooks go when the repository's own hooks folder is checked in, inside the git folder.
21
+ export const MANAGED = "second-read-hooks";
22
+ const RECORD = "second-read.previousHooksPath";
23
+ export class GitError extends Error {
24
+ }
25
+ export function git(args, cwd, env) {
26
+ const result = spawnSync("git", args, { cwd, env, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
27
+ if (result.error)
28
+ throw new GitError(`can't run git: ${result.error.message}`);
29
+ if (result.status !== 0)
30
+ throw new GitError(`git ${args[0]} failed: ${result.stderr.trim()}`);
31
+ return result.stdout;
32
+ }
33
+ // Where git looks for hooks, core.hooksPath included.
34
+ export function hooksDir(cwd, env) {
35
+ return resolve(cwd, git(["rev-parse", "--git-path", "hooks"], cwd, env).trim());
36
+ }
37
+ // A hooks folder in the work tree (core.hooksPath set to a checked-in folder) holds repository files, and an
38
+ // installed hook names paths on this machine. The git folder itself is the one place in the work tree that's fine.
39
+ export function hooksInWorkTree(dir, cwd, env) {
40
+ const top = spawnSync("git", ["rev-parse", "--show-toplevel"], { cwd, env, encoding: "utf8" });
41
+ if (top.status !== 0)
42
+ return false;
43
+ const gitDir = resolve(cwd, git(["rev-parse", "--git-common-dir"], cwd, env).trim());
44
+ const hooks = existingRealpath(dir);
45
+ return isInside(hooks, existingRealpath(top.stdout.trim())) && !isInside(hooks, existingRealpath(gitDir));
46
+ }
47
+ function isInside(path, folder) {
48
+ return path === folder || path.startsWith(folder.endsWith(sep) ? folder : folder + sep);
49
+ }
50
+ // The real path of the nearest folder that exists, with the rest of the path after it.
51
+ function existingRealpath(path) {
52
+ try {
53
+ return realpathSync(path);
54
+ }
55
+ catch {
56
+ const parent = dirname(path);
57
+ return parent === path ? path : join(existingRealpath(parent), basename(path));
58
+ }
59
+ }
60
+ export function shQuote(s) {
61
+ return `'${s.replaceAll("'", `'\\''`)}'`;
62
+ }
63
+ // `command` runs the CLI: the node binary and the script, as they were when the hook was installed.
64
+ export function hookScript(command) {
65
+ const cli = command[command.length - 1];
66
+ return `#!/bin/sh
67
+ ${HOOK_MARKER} \`second-read hooks uninstall\` removes it and puts back any hook it set aside.
68
+ if [ ! -f ${shQuote(cli)} ]; then
69
+ echo "second-read: the CLI this hook runs is gone; run \\"second-read hooks install\\" again" >&2
70
+ exit 2
71
+ fi
72
+ exec ${command.map(shQuote).join(" ")} hook "$0" "$@"
73
+ `;
74
+ }
75
+ export function isOurs(path, marker) {
76
+ try {
77
+ return readFileSync(path, "utf8").includes(marker);
78
+ }
79
+ catch {
80
+ return false;
81
+ }
82
+ }
83
+ export function installHooks(dir, command) {
84
+ const done = [];
85
+ for (const name of HOOKS) {
86
+ const path = join(dir, name);
87
+ const previous = path + PREVIOUS;
88
+ if (existsSync(path) && !isOurs(path, HOOK_MARKER)) {
89
+ if (existsSync(previous)) {
90
+ throw new GitError(`${path} can't be set aside, because ${previous} is already there; nothing was installed`);
91
+ }
92
+ }
93
+ }
94
+ for (const name of HOOKS) {
95
+ const path = join(dir, name);
96
+ if (isOurs(path, HOOK_MARKER)) {
97
+ writeFileSync(path, hookScript(command), { mode: 0o755 });
98
+ done.push(`${name}: already installed, refreshed`);
99
+ continue;
100
+ }
101
+ if (existsSync(path)) {
102
+ renameSync(path, path + PREVIOUS);
103
+ done.push(`${name}: installed; the hook that was there runs after the check`);
104
+ }
105
+ else
106
+ done.push(`${name}: installed`);
107
+ writeFileSync(path, hookScript(command), { mode: 0o755, flag: "wx" });
108
+ }
109
+ return done;
110
+ }
111
+ export function uninstallHooks(dir) {
112
+ const done = [];
113
+ for (const name of HOOKS) {
114
+ const path = join(dir, name);
115
+ const previous = path + PREVIOUS;
116
+ if (existsSync(path) && !isOurs(path, HOOK_MARKER)) {
117
+ done.push(`${name}: left as it is; it isn't second-read's`);
118
+ continue;
119
+ }
120
+ const ours = existsSync(path);
121
+ if (ours)
122
+ unlinkSync(path);
123
+ if (existsSync(previous)) {
124
+ renameSync(previous, path);
125
+ done.push(`${name}: removed, and the hook it set aside is back`);
126
+ }
127
+ else
128
+ done.push(ours ? `${name}: removed` : `${name}: not installed`);
129
+ }
130
+ return done;
131
+ }
132
+ // A stand-in for one of the repository's own hooks. git runs hooks from the top of the work tree (or the git
133
+ // folder, for the server-side ones), which is also where it resolves a relative core.hooksPath.
134
+ // It's plain sh, since some hooks run on every ref update.
135
+ export function forwarderScript(hooksPath, name) {
136
+ return `#!/bin/sh
137
+ ${HOOK_MARKER} It runs the repository's own ${name} hook, if there is one.
138
+ hook=${shQuote(`${hooksPath.replace(/\/+$/, "")}/${name}`)}
139
+ [ -x "$hook" ] || exit 0
140
+ exec "$hook" "$@"
141
+ `;
142
+ }
143
+ function configGet(args, cwd, env) {
144
+ const result = spawnSync("git", ["config", ...args], { cwd, env, encoding: "utf8" });
145
+ if (result.status === 0)
146
+ return result.stdout.replace(/\n$/, "");
147
+ if (result.status === 1)
148
+ return undefined;
149
+ throw new GitError(`git config failed: ${result.stderr.trim()}`);
150
+ }
151
+ export function managedDir(cwd, env) {
152
+ return join(resolve(cwd, git(["rev-parse", "--git-common-dir"], cwd, env).trim()), MANAGED);
153
+ }
154
+ // git runs these on every ref update and every index write, `git status` included, so a forwarder that starts sh
155
+ // each time slows rebases and editors down. They're forwarded only when the repository has them at install.
156
+ export const FREQUENT_HOOKS = ["reference-transaction", "post-index-change"];
157
+ // The repository's hooks folder the forwarders point at, kept so a later install can write them again.
158
+ const FORWARD_TO = "second-read.hooksPath";
159
+ // For a hooks folder in the work tree: second-read's hooks go in a folder inside the git folder, which this
160
+ // clone's core.hooksPath then points at. Each of the repository's hooks still runs, through a forwarder there.
161
+ export function installManaged(cwd, env, command) {
162
+ const dir = managedDir(cwd, env);
163
+ const hooksPath = configGet(["--path", "--get", "core.hooksPath"], cwd, env);
164
+ if (hooksPath === undefined)
165
+ throw new GitError("core.hooksPath isn't set, so there's nothing to forward to");
166
+ // A forwarder that pointed back into this folder would run itself forever.
167
+ if (existingRealpath(resolve(cwd, hooksPath)) === existingRealpath(dir)) {
168
+ throw new GitError(`core.hooksPath already points at ${dir}`);
169
+ }
170
+ const local = configGet(["--local", "--get", "core.hooksPath"], cwd, env);
171
+ const done = writeManaged(dir, hooksPath, cwd, env, command);
172
+ if (local === undefined)
173
+ spawnSync("git", ["config", "--local", "--unset-all", RECORD], { cwd, env });
174
+ else
175
+ git(["config", "--local", RECORD, local], cwd, env);
176
+ git(["config", "--local", FORWARD_TO, hooksPath], cwd, env);
177
+ git(["config", "--local", "core.hooksPath", dir], cwd, env);
178
+ return { dir, hooksPath, done };
179
+ }
180
+ // Whether `dir`, where git looks for hooks, is the folder installManaged wrote.
181
+ export function isManagedDir(dir, cwd, env) {
182
+ return existingRealpath(dir) === existingRealpath(managedDir(cwd, env));
183
+ }
184
+ // A second install writes the whole folder again, forwarding to the same hooks folder as before, so it picks up
185
+ // a frequent hook the repository has added since, and any hook a newer second-read knows.
186
+ export function refreshManaged(cwd, env, command) {
187
+ const dir = managedDir(cwd, env);
188
+ const hooksPath = configGet(["--local", "--get", FORWARD_TO], cwd, env);
189
+ if (hooksPath === undefined) {
190
+ throw new GitError(`${dir} doesn't say which hooks it forwards to; run "second-read hooks uninstall" first`);
191
+ }
192
+ return { dir, hooksPath, done: writeManaged(dir, hooksPath, cwd, env, command) };
193
+ }
194
+ function writeManaged(dir, hooksPath, cwd, env, command) {
195
+ const top = git(["rev-parse", "--show-toplevel"], cwd, env).trim();
196
+ const theirs = (name) => existsSync(resolve(top, hooksPath, name));
197
+ mkdirSync(dir, { recursive: true });
198
+ const skipped = [];
199
+ for (const name of GIT_HOOKS) {
200
+ const forwarder = forwarderScript(hooksPath, name);
201
+ if (HOOKS.includes(name)) {
202
+ writeFileSync(join(dir, name), hookScript(command), { mode: 0o755 });
203
+ writeFileSync(join(dir, name + PREVIOUS), forwarder, { mode: 0o755 });
204
+ }
205
+ else if (FREQUENT_HOOKS.includes(name) && !theirs(name)) {
206
+ rmSync(join(dir, name), { force: true });
207
+ skipped.push(name);
208
+ }
209
+ else
210
+ writeFileSync(join(dir, name), forwarder, { mode: 0o755 });
211
+ }
212
+ const done = HOOKS.map((name) => `${name}: installed; the repository's own ${name} runs after the check`);
213
+ done.push(`every other hook githooks(5) lists runs the repository's own, from ${hooksPath}, but push-to-checkout`);
214
+ if (skipped.length > 0) {
215
+ done.push(`${skipped.join(" and ")}: not forwarded, since the repository doesn't have ` +
216
+ `${skipped.length === 1 ? "it and git runs it" : "them and git runs them"} often; ` +
217
+ `run "second-read hooks install" again after adding one`);
218
+ }
219
+ if (theirs("push-to-checkout")) {
220
+ done.push("warning: the repository's push-to-checkout won't run while second-read's hooks are installed");
221
+ }
222
+ return done;
223
+ }
224
+ // Undoes installManaged, or returns undefined when it was never run here. core.hooksPath goes back to what this
225
+ // clone had set, or is unset so a value from the global config shows through, unless something else has set it
226
+ // since: the repository's own setup step, say.
227
+ export function uninstallManaged(cwd, env) {
228
+ const dir = managedDir(cwd, env);
229
+ if (!existsSync(dir))
230
+ return undefined;
231
+ const done = [];
232
+ const current = configGet(["--local", "--path", "--get", "core.hooksPath"], cwd, env);
233
+ if (current !== undefined && existingRealpath(resolve(cwd, current)) === existingRealpath(dir)) {
234
+ const previous = configGet(["--local", "--get", RECORD], cwd, env);
235
+ if (previous === undefined) {
236
+ git(["config", "--local", "--unset", "core.hooksPath"], cwd, env);
237
+ done.push("core.hooksPath: unset in this clone, as it was");
238
+ }
239
+ else {
240
+ git(["config", "--local", "core.hooksPath", previous], cwd, env);
241
+ done.push(`core.hooksPath: back to ${JSON.stringify(previous)}`);
242
+ }
243
+ }
244
+ else
245
+ done.push("core.hooksPath: left as it is; it no longer points at second-read's hooks");
246
+ spawnSync("git", ["config", "--local", "--remove-section", "second-read"], { cwd, env });
247
+ rmSync(dir, { recursive: true, force: true });
248
+ done.push(`removed ${dir}`);
249
+ return done;
250
+ }
251
+ // Runs the hook that was set aside at install, if there is one, with the same arguments and input.
252
+ export function runPrevious(hookPath, args, cwd, env, input) {
253
+ const previous = hookPath + PREVIOUS;
254
+ try {
255
+ accessSync(previous, constants.X_OK);
256
+ }
257
+ catch {
258
+ return 0;
259
+ }
260
+ const result = spawnSync(previous, args, {
261
+ cwd,
262
+ env,
263
+ stdio: [input === undefined ? "inherit" : "pipe", "inherit", "inherit"],
264
+ ...(input === undefined ? {} : { input }),
265
+ });
266
+ // A hook that exits without reading all its input isn't a failure; git doesn't treat it as one either.
267
+ const unread = result.error?.code === "EPIPE" && result.status !== null;
268
+ if (result.error && !unread)
269
+ throw new GitError(`can't run ${previous}: ${result.error.message}`);
270
+ return exitStatus(result);
271
+ }
272
+ export function exitStatus(result) {
273
+ if (result.status !== null)
274
+ return result.status;
275
+ const number = result.signal ? os.signals[result.signal] : undefined;
276
+ return number === undefined ? 1 : 128 + number;
277
+ }
278
+ // The text git will commit. With an editor, git drops comment lines, and with `git commit -v` everything below
279
+ // the scissors line, which is where the diff goes. Without an editor (`git commit -m`), it keeps every line.
280
+ export function commitMessage(raw, cwd, env) {
281
+ if (env.GIT_EDITOR === ":")
282
+ return raw;
283
+ let comment = "#";
284
+ let cleanup = "strip";
285
+ const config = spawnSync("git", ["config", "--get-regexp", "^(core\\.comment(char|string)|commit\\.cleanup)$"], { cwd, env, encoding: "utf8" });
286
+ if (config.status === 0) {
287
+ for (const line of config.stdout.split("\n")) {
288
+ const space = line.indexOf(" ");
289
+ if (space < 0)
290
+ continue;
291
+ const key = line.slice(0, space);
292
+ const value = line.slice(space + 1);
293
+ if (key === "commit.cleanup")
294
+ cleanup = value;
295
+ else
296
+ comment = value;
297
+ }
298
+ }
299
+ else if (config.status !== 1) {
300
+ throw new GitError(`git config failed: ${config.stderr.trim()}`);
301
+ }
302
+ if (cleanup === "verbatim" || cleanup === "whitespace")
303
+ return raw;
304
+ // With "auto", git picks a comment character that no line starts with, so no line can be told apart as one.
305
+ const auto = comment === "auto";
306
+ let lines = raw.split("\n");
307
+ // git cuts at the scissors line for cleanup=scissors or `-v`. A diff below it is the sign of `-v`.
308
+ const isScissors = (line) => /^\S+ -+ >8 -+$/.test(line) && (auto || line.startsWith(comment + " "));
309
+ const scissors = lines.findIndex(isScissors);
310
+ if (scissors >= 0) {
311
+ const below = lines.slice(scissors + 1);
312
+ if (cleanup === "scissors" || below.some((line) => line.startsWith("diff --git ")))
313
+ lines = lines.slice(0, scissors);
314
+ }
315
+ if (auto || cleanup === "scissors")
316
+ return lines.join("\n");
317
+ return lines.filter((line) => !line.startsWith(comment)).join("\n");
318
+ }
319
+ const ZERO = /^0+$/;
320
+ // Reads git's pre-push input: one "<local ref> <local sha> <remote ref> <remote sha>" line per ref being pushed.
321
+ // The commits to check are those reachable from what's pushed and not already on the remote.
322
+ export function pushRange(stdin, cwd, env) {
323
+ const include = [];
324
+ const remoteShas = [];
325
+ const refs = [];
326
+ for (const line of stdin.split("\n")) {
327
+ if (line.trim() === "")
328
+ continue;
329
+ const [, localSha, remoteRef, remoteSha] = line.trim().split(/\s+/);
330
+ if (!localSha || !remoteRef || !remoteSha) {
331
+ throw new GitError(`unexpected pre-push input: ${JSON.stringify(line)}`);
332
+ }
333
+ // A deleted branch pushes nothing, and deleting it is how a leaked name is taken back.
334
+ if (ZERO.test(localSha))
335
+ continue;
336
+ include.push(localSha);
337
+ refs.push(pushedRef(remoteRef));
338
+ if (!ZERO.test(remoteSha))
339
+ remoteShas.push(remoteSha);
340
+ }
341
+ if (include.length === 0)
342
+ return { include, exclude: [], tags: [], refs };
343
+ const types = objectTypes([...include, ...remoteShas], cwd, env);
344
+ // The remote's old tip, when it's here too. After a force push it may not be, and the remote's refs cover it.
345
+ const exclude = remoteShas.filter((sha) => types.get(sha) === "commit");
346
+ const tags = include.filter((sha) => types.get(sha) === "tag");
347
+ return { include, exclude, tags, refs };
348
+ }
349
+ function pushedRef(ref) {
350
+ if (ref.startsWith("refs/heads/"))
351
+ return { kind: "branch", name: ref.slice("refs/heads/".length) };
352
+ if (ref.startsWith("refs/tags/"))
353
+ return { kind: "tag", name: ref.slice("refs/tags/".length) };
354
+ return { kind: "ref", name: ref };
355
+ }
356
+ function objectTypes(shas, cwd, env) {
357
+ const known = spawnSync("git", ["cat-file", "--batch-check=%(objectname) %(objecttype)"], {
358
+ cwd,
359
+ env,
360
+ input: shas.join("\n") + "\n",
361
+ encoding: "utf8",
362
+ });
363
+ if (known.status !== 0)
364
+ throw new GitError(`git cat-file failed: ${known.stderr.trim()}`);
365
+ const types = new Map();
366
+ for (const line of known.stdout.split("\n")) {
367
+ const [sha, type] = line.split(" ");
368
+ if (sha && type)
369
+ types.set(sha, type);
370
+ }
371
+ return types;
372
+ }
373
+ // An annotated tag's name and message, without its signature. A tag of a tag is followed down to the end.
374
+ export function tagMessages(sha, cwd, env) {
375
+ const found = [];
376
+ let next = sha;
377
+ while (next !== undefined && found.length < 100) {
378
+ const raw = git(["cat-file", "tag", next], cwd, env);
379
+ const split = raw.indexOf("\n\n");
380
+ const headers = (split < 0 ? raw : raw.slice(0, split)).split("\n");
381
+ const header = (key) => headers.find((h) => h.startsWith(key + " "))?.slice(key.length + 1);
382
+ let message = split < 0 ? "" : raw.slice(split + 2);
383
+ const signature = message.search(/^-----BEGIN (?:PGP|SSH) SIGNATURE-----$/m);
384
+ if (signature >= 0)
385
+ message = message.slice(0, signature);
386
+ found.push({ kind: "tag", commit: header("tag") ?? next, text: message });
387
+ next = header("type") === "tag" ? header("object") : undefined;
388
+ }
389
+ return found;
390
+ }
391
+ // Streams each pushed commit's message and the lines it adds, one file at a time. A merge adds only the lines that
392
+ // are in none of its parents, as a conflict resolution's are; a line taken from one side came in with the side's
393
+ // own commits, which are read on their own.
394
+ export async function readPushed(range, remote, cwd, env, onText) {
395
+ if (range.include.length === 0)
396
+ return;
397
+ for (const tag of range.tags)
398
+ for (const pushed of tagMessages(tag, cwd, env))
399
+ onText(pushed);
400
+ const args = [
401
+ "-c", "core.quotePath=false",
402
+ "log", "--no-color", "--no-ext-diff", "--no-textconv", "--diff-merges=dense-combined", "-M", "-p", "-U0",
403
+ "--src-prefix=a/", "--dst-prefix=b/", "--format=format:%x01%h%n%B%x02",
404
+ ...range.include, "--not", ...range.exclude, `--remotes=${remote}`, "--",
405
+ ];
406
+ const child = spawn("git", args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
407
+ let stderr = "";
408
+ child.stderr.setEncoding("utf8").on("data", (chunk) => (stderr += chunk));
409
+ const exited = new Promise((done, fail) => {
410
+ child.on("error", fail);
411
+ child.on("close", done);
412
+ });
413
+ let commit = "";
414
+ let state = "none";
415
+ let message = [];
416
+ let file;
417
+ let added = [];
418
+ let lines = [];
419
+ let next = 0;
420
+ // A merge's combined diff starts each line with one column per parent, and its hunk header with one more "@".
421
+ let parents = 1;
422
+ const flushFile = () => {
423
+ if (file !== undefined && added.length > 0)
424
+ onText({ kind: "added", commit, file, text: added.join("\n"), lines });
425
+ file = undefined;
426
+ added = [];
427
+ lines = [];
428
+ };
429
+ for await (const line of createInterface({ input: child.stdout, crlfDelay: Infinity })) {
430
+ if (line.startsWith("\x01")) {
431
+ flushFile();
432
+ commit = line.slice(1);
433
+ message = [];
434
+ state = "message";
435
+ }
436
+ else if (state === "message") {
437
+ const end = line.indexOf("\x02");
438
+ if (end < 0) {
439
+ message.push(line);
440
+ continue;
441
+ }
442
+ if (end > 0)
443
+ message.push(line.slice(0, end));
444
+ onText({ kind: "message", commit, text: message.join("\n") });
445
+ state = "none";
446
+ }
447
+ else if (line.startsWith("diff --git ") || line.startsWith("diff --cc ") || line.startsWith("diff --combined ")) {
448
+ flushFile();
449
+ state = "header";
450
+ }
451
+ else if (state === "header") {
452
+ // "+++" names the file only here, before the first hunk. Inside a hunk, "+++x" is an added "++x".
453
+ if (line.startsWith("+++ ")) {
454
+ const name = line.slice(4);
455
+ // git ends the line with a tab when the name has a space in it.
456
+ file = name === "/dev/null" ? undefined : name.replace(/^b\//, "").replace(/\t$/, "");
457
+ }
458
+ else if (line.startsWith("@@")) {
459
+ state = "hunk";
460
+ next = hunkStart(line);
461
+ parents = hunkParents(line);
462
+ }
463
+ }
464
+ else if (state === "hunk") {
465
+ if (line.startsWith("@@")) {
466
+ next = hunkStart(line);
467
+ parents = hunkParents(line);
468
+ continue;
469
+ }
470
+ // "+" in every column: in none of the parents. A "-" anywhere is a parent's line the commit drops, and any
471
+ // other mix of "+" and " " a line one of the parents already has.
472
+ const columns = line.slice(0, parents);
473
+ if (columns.length < parents || /[^+ ]/.test(columns))
474
+ continue;
475
+ if (!columns.includes(" ")) {
476
+ added.push(line.slice(parents));
477
+ lines.push(next);
478
+ }
479
+ next++;
480
+ }
481
+ }
482
+ flushFile();
483
+ const status = await exited;
484
+ if (status !== 0)
485
+ throw new GitError(`git log failed: ${stderr.trim()}`);
486
+ }
487
+ // Every change the pushed commits make to a .second-read.toml, in any folder, since the nearest one applies.
488
+ // A merge counts when its version differs from every parent's, as a conflict resolution's does; one that takes a
489
+ // side's version unchanged doesn't, since that version came in with the side's own commits.
490
+ export function allowlistChanges(range, remote, cwd, env) {
491
+ const changes = [];
492
+ if (range.include.length === 0)
493
+ return changes;
494
+ const out = git([
495
+ "log", "--no-color", "--no-renames", "--diff-merges=dense-combined", "--raw", "--no-abbrev", "-z",
496
+ "--format=format:%x01%H", ...range.include, "--not", ...range.exclude, `--remotes=${remote}`, "--",
497
+ ], cwd, env);
498
+ // With -z: "\x01<commit>\n:<modes> <old> <new> <status>\0<path>\0", then one more ":…\0<path>\0" per file. A merge
499
+ // has "\0" after the commit, and one ":", one mode and one old blob per parent: "::<modes> <old> <old> <new> …".
500
+ let commit = "";
501
+ let raw;
502
+ for (let token of out.split("\0")) {
503
+ if (raw !== undefined) {
504
+ const parents = raw.length - raw.replace(/^:+/, "").length;
505
+ const blob = raw.slice(parents).split(" ")[2 * parents + 1] ?? "";
506
+ const lower = token.toLowerCase();
507
+ // macOS and Windows folders usually ignore case, so ".Second-Read.toml" is found there too.
508
+ if (lower === REPO_FILE || lower.endsWith("/" + REPO_FILE)) {
509
+ const deleted = ZERO.test(blob);
510
+ changes.push({ commit, file: token, deleted, id: deleted ? commit : blob });
511
+ }
512
+ raw = undefined;
513
+ continue;
514
+ }
515
+ if (token.startsWith("\x01")) {
516
+ const newline = token.indexOf("\n");
517
+ commit = token.slice(1, newline < 0 ? undefined : newline);
518
+ token = newline < 0 ? "" : token.slice(newline + 1);
519
+ }
520
+ if (token.startsWith(":"))
521
+ raw = token;
522
+ }
523
+ return changes;
524
+ }
525
+ function hunkStart(line) {
526
+ const match = /^@@+ (?:-\S+ )+\+(\d+)/.exec(line);
527
+ return match ? Number(match[1]) : 0;
528
+ }
529
+ function hunkParents(line) {
530
+ return Math.max(1, /^@+/.exec(line)[0].length - 1);
531
+ }