@impeccable/reviews 0.1.23 → 0.1.25

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/fence.js CHANGED
@@ -412,7 +412,7 @@ function namesTrackedFiles(paths, cwd) {
412
412
  const named = new Set(paths.map((a) => resolve(cwd, a)));
413
413
  return tracked !== null && tracked.split('\0').filter(Boolean).every((a) => named.has(resolve(cwd, a)));
414
414
  }
415
- function git(args, where, protectedBranches, held = false) {
415
+ function git(args, where, protectedBranches, held = false, ended = new Set()) {
416
416
  const words = gitArgs(args);
417
417
  if (!words)
418
418
  return deny('a git option before the command: run git with the command first');
@@ -500,6 +500,9 @@ function git(args, where, protectedBranches, held = false) {
500
500
  const branch = rest[2];
501
501
  if (branch.includes(':') || ['HEAD', '@'].includes(pushedBranch(branch)) || protectedBranches.includes(pushedBranch(branch)))
502
502
  return deny('a push to the default branch');
503
+ // The person's no ended the setup (#413): of a repository of one app, nothing is pushed; of an app of several, not its own test drive.
504
+ if (ended.has('') || [...ended].some((key) => pushedBranch(branch) === `impeccable/test-drive-${key}`))
505
+ return deny("a push after the person's no ended the setup: there is no preview to review it on");
503
506
  // A connected Vercel project builds every push, and calls a new project's first deployment Production, public at the project's own address.
504
507
  return held ? deny("a push before impeccable_vercel_connect settled the linked Vercel project's first deployment: run it first") : allow();
505
508
  }
@@ -899,7 +902,7 @@ function simpleCommand(seg, where, opts) {
899
902
  return deny('a cd that runs only sometimes: put it first, or after ;');
900
903
  return isFolder(realPath(resolve(where.cwd, args[0]))) ? allow() : deny('a cd into a folder that is not there');
901
904
  case 'git':
902
- return git(args, where, opts.protectedBranches, opts.hosts.has('vercel') && !(opts.vercelSettled && opts.vercelSettled.projectId === (linkedVercelProject(where.root)?.projectId ?? null)));
905
+ return git(args, where, opts.protectedBranches, opts.hosts.has('vercel') && !(opts.vercelSettled && opts.vercelSettled.projectId === (linkedVercelProject(where.root)?.projectId ?? null)), opts.ended);
903
906
  case 'gh':
904
907
  // gh reads the file outside the sandbox, after the fence read it: a command still running in the sandbox can swap it in between.
905
908
  if (args[0] === 'pr' && args[1] === 'create')
@@ -1202,7 +1205,7 @@ function unsyncedWorkflows(where, named, carried, synced) {
1202
1205
  }
1203
1206
  /** The fence's answer for one shell command run from the checkout's top folder. */
1204
1207
  export function classifyCommand(command, root, options = {}) {
1205
- const opts = { protectedBranches: options.protectedBranches ?? ['main', 'master'], scriptsChanged: options.scriptsChanged ?? false, hosts: options.hosts ?? new Set(), installs: options.installs ?? new Set(), teams: options.teams ?? new Map(), vercelSettled: options.vercelSettled ?? null, repository: options.repository };
1208
+ const opts = { protectedBranches: options.protectedBranches ?? ['main', 'master'], scriptsChanged: options.scriptsChanged ?? false, hosts: options.hosts ?? new Set(), installs: options.installs ?? new Set(), teams: options.teams ?? new Map(), vercelSettled: options.vercelSettled ?? null, ended: options.ended ?? new Set(), repository: options.repository };
1206
1209
  const segments = parseCommand(command);
1207
1210
  if (!segments) {
1208
1211
  // A here-document is how a file or a message gets written in one command: the write tool, -m and --body do that.
@@ -1532,7 +1535,7 @@ function classifyOpened(tool, opened, where) {
1532
1535
  * it searches and the files it reads context lines from; the matches themselves ripgrep reads, and guardedGrepTool takes those out.
1533
1536
  * ls's are the folder and each entry in it, so an entry that is a symlink out of the checkout is skipped, not listed.
1534
1537
  */
1535
- export function guardedFileOperations(root, scratch) {
1538
+ export function guardedFileOperations(root, scratch, journal) {
1536
1539
  const where = { root: realPath(root), cwd: root, scratch: scratch && realPath(scratch) };
1537
1540
  // Every operation judges its own path: the order pi calls them in is not relied on.
1538
1541
  const judged = (tool, path) => {
@@ -1541,6 +1544,13 @@ export function guardedFileOperations(root, scratch) {
1541
1544
  throw new Error(`Refused: ${decision.reason}`);
1542
1545
  return path;
1543
1546
  };
1547
+ /** A write the journal sees on both sides, what the file held taken before anything is awaited, so the run's end can take back what no commit carried (#421). */
1548
+ const written = async (tool, path, content) => {
1549
+ const real = realPath(judged(tool, path));
1550
+ journal?.writing(real);
1551
+ await writeFile(path, content, 'utf-8');
1552
+ journal?.wrote(real, content);
1553
+ };
1544
1554
  return {
1545
1555
  read: {
1546
1556
  readFile: async (path) => readFile(judged('read', path)),
@@ -1548,12 +1558,16 @@ export function guardedFileOperations(root, scratch) {
1548
1558
  detectImageMimeType: async (path) => detectSupportedImageMimeTypeFromFile(judged('read', path)),
1549
1559
  },
1550
1560
  write: {
1551
- writeFile: async (path, content) => writeFile(judged('write', path), content, 'utf-8'),
1552
- mkdir: async (dir) => void (await mkdir(judged('write', dir), { recursive: true })),
1561
+ writeFile: (path, content) => written('write', path, content),
1562
+ mkdir: async (dir) => {
1563
+ const real = realPath(judged('write', dir));
1564
+ journal?.making(real);
1565
+ await mkdir(dir, { recursive: true });
1566
+ },
1553
1567
  },
1554
1568
  edit: {
1555
1569
  readFile: async (path) => readFile(judged('edit', path)),
1556
- writeFile: async (path, content) => writeFile(judged('edit', path), content, 'utf-8'),
1570
+ writeFile: (path, content) => written('edit', path, content),
1557
1571
  access: async (path) => access(judged('edit', path), constants.R_OK | constants.W_OK),
1558
1572
  },
1559
1573
  grep: {
@@ -1628,6 +1642,22 @@ export function previewRetryState(log) {
1628
1642
  const setupBranch = (branch) => /^impeccable\/setup(?:-(?:[2-9]|[1-9][0-9]+))?$/.test(branch);
1629
1643
  /** The test drive's branch: impeccable/test-drive, an app's impeccable/test-drive-<app key> on a repository with several, each with -2, -3 where an earlier run took it. */
1630
1644
  const driveBranch = (branch) => /^impeccable\/test-drive(?:-[A-Za-z0-9][A-Za-z0-9._-]*)?$/.test(branch) && !branch.includes('..') && !branch.endsWith('.') && !branch.endsWith('.lock');
1645
+ /** Under --signed-in-test-drive, why a test drive's branch may not be pushed or opened yet: its app has no test account saved (#410). An app's branch is impeccable/test-drive-<app key>, with -2, -3 where an earlier run took it; a run that sets up one app of several uses impeccable/test-drive. */
1646
+ function signedInRefusal(state, branch) {
1647
+ const signedIn = state.signedIn;
1648
+ if (!signedIn)
1649
+ return;
1650
+ const rest = branch.slice('impeccable/test-drive'.length);
1651
+ const picked = signedIn.picked ?? [];
1652
+ const named = (key) => rest === `-${key}` || (rest.startsWith(`-${key}-`) && /^\d+$/.test(rest.slice(key.length + 2)));
1653
+ // An app's own branch, a numeric key's too, else the unnamed branch of a run that picked one app.
1654
+ const saved = !signedIn.apps ? signedIn.saved.has('')
1655
+ : picked.some((key) => named(key)) ? picked.some((key) => named(key) && signedIn.saved.has(key))
1656
+ : /^(?:-\d+)?$/.test(rest) && picked.length === 1 && signedIn.saved.has(picked[0]);
1657
+ if (saved)
1658
+ return;
1659
+ return deny(`the run was started with --signed-in-test-drive, and no test account is saved${signedIn.apps ? ' for this app' : ''}, so its test drive cannot change a page behind the sign-in. Save the accounts first, as the sign-in case says. Where the app has no sign-in of its own, or every account was skipped, push and open no test drive: say in the report that --signed-in-test-drive could not be honoured, and why`);
1660
+ }
1631
1661
  /** A GitHub repository as `<owner>/<name>`. */
1632
1662
  const REPOSITORY = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?\/[A-Za-z0-9_.-]{1,100}$/;
1633
1663
  /** `gh pr ready <the test drive's branch> [--repo <owner>/<name>]`, and nothing else (never `--undo`, a number or another branch): the branch and the repository named, or null. */
@@ -1725,9 +1755,16 @@ export function reservePreviewCommand(state, command, root, scratch) {
1725
1755
  const gitWords = words[0] === 'git' ? gitArgs(words.slice(1)) : [];
1726
1756
  if (!gitWords)
1727
1757
  return deny('a git option before the command');
1758
+ // A push or a pull request would go by on "Understanding your app" while the list shows only the reading (#416).
1759
+ if (state.reading?.() && (gitWords[0] === 'push' || (words[0] === 'gh' && words[1] === 'pr' && words[2] === 'create'))) {
1760
+ return deny('the task list shows no work past reading the repository yet, so the screen still says "Understanding your app". Call impeccable_progress first with Read the repository completed and the task this is for in progress (Test drive for the test drive), then run it again');
1761
+ }
1728
1762
  // The test drive is the run's other pull request (one an app on a repository with several): one branch, one pull request, a few pushes, none of the setup pull request's attempts.
1729
1763
  const drivePush = pushBranch(gitWords, driveBranch);
1730
1764
  if (drivePush) {
1765
+ const unsigned = signedInRefusal(state, drivePush);
1766
+ if (unsigned)
1767
+ return unsigned;
1731
1768
  const drive = driveOf(drivePush);
1732
1769
  if (!drive)
1733
1770
  return deny(state.drivesAllowed > 1 ? `one test drive for each of the ${state.drivesAllowed} apps this run sets up, each on one branch` : 'keep the test drive on one branch');
@@ -1761,6 +1798,9 @@ export function reservePreviewCommand(state, command, root, scratch) {
1761
1798
  || (!parsed.repository && realPath(where.cwd) !== where.root))
1762
1799
  return deny('name this repository explicitly when creating a pull request');
1763
1800
  if (parsed.head && driveBranch(parsed.head)) {
1801
+ const unsigned = signedInRefusal(state, parsed.head);
1802
+ if (unsigned)
1803
+ return unsigned;
1764
1804
  const drive = driveOf(parsed.head);
1765
1805
  if (!drive)
1766
1806
  return deny(state.drivesAllowed > 1 ? `one test drive for each of the ${state.drivesAllowed} apps this run sets up, each opened from the branch that was pushed` : 'open the test drive from the branch that was pushed');
@@ -2000,6 +2040,17 @@ export function settleVercelFirstDeployment(log, settlement) {
2000
2040
  export function vercelFirstDeploymentSettled(log) {
2001
2041
  return settledVercel.get(log) ?? null;
2002
2042
  }
2043
+ /** The apps whose setup the person's no ended (#413), by the run's log: '' for a repository of one app, which then pushes nothing; an app's key, whose own test drive is not pushed. */
2044
+ const endedSetups = new WeakMap();
2045
+ function setupsEnded(log) {
2046
+ let keys = endedSetups.get(log);
2047
+ if (!keys)
2048
+ endedSetups.set(log, (keys = new Set()));
2049
+ return keys;
2050
+ }
2051
+ export function endAppSetup(log, key) {
2052
+ setupsEnded(log).add(key);
2053
+ }
2003
2054
  /** Tool calls a run makes before the fence refuses more: a setup takes far fewer, and a run steered elsewhere stops there. */
2004
2055
  export const MAX_TOOL_CALLS = 400;
2005
2056
  /** What still runs past the cap, so the run can say where it got to and end. */
@@ -2098,14 +2149,29 @@ const SECRET_RULE = 'A secret never goes into a file, a setting, a commit, a pul
2098
2149
  export function fenceExtension(root, log, protectedBranches, scratch, scans = scanRecorder(log)) {
2099
2150
  const retry = previewRetryState(log);
2100
2151
  return (pi) => {
2101
- /** The first file the agent changed that the scripts and tools load, and the first of those but vite.config, which also stops the local preview check. */
2102
- let changed;
2103
- let previewChanged;
2104
- /** The scripts and tools stop once `path`, which they load, changed, and the local preview check too unless every name of the file is a vite.config. */
2105
- const stop = (path, names) => {
2106
- changed ??= path;
2107
- if (!names.every((p) => VITE_CONFIG.test(p)))
2108
- previewChanged ??= path;
2152
+ /**
2153
+ * Each file the agent changed that the scripts and tools load, in the order it first did, with the hash it held before
2154
+ * (#421): the scripts and tools stop while one differs from that, so a file the agent put back as it was is the
2155
+ * repository's own again. `preview` when a name of the file is not a vite.config, which stops the local preview check too.
2156
+ */
2157
+ const stops = new Map();
2158
+ /** The loaded files a write or edit allowed is still writing, by its call: changed until its result. */
2159
+ const writing = new Map();
2160
+ /** By the file itself, whichever spelling named it: the first spelling is the one the refusal names, and the first hash stands. */
2161
+ const stop = (path, names, before) => {
2162
+ const file = realPath(resolve(root, path));
2163
+ const stopped = stops.get(file) ?? { path, before: before ?? hashOf(file), preview: false };
2164
+ stopped.preview ||= !names.every((p) => VITE_CONFIG.test(p));
2165
+ stops.set(file, stopped);
2166
+ return file;
2167
+ };
2168
+ /** The first file the agent changed that the scripts and tools load and that differs from what it held, of those that stop the local preview check when `preview`. */
2169
+ const changedNow = (preview = false) => {
2170
+ for (const [file, { path, before, preview: stopsPreview }] of stops) {
2171
+ if ((!preview || stopsPreview) && ([...writing.values()].includes(file) || hashOf(file) !== before))
2172
+ return path;
2173
+ }
2174
+ return undefined;
2109
2175
  };
2110
2176
  /** The paths this run wrote or named, as git names them: what a commit may carry. */
2111
2177
  const touched = new Set();
@@ -2120,12 +2186,12 @@ export function fenceExtension(root, log, protectedBranches, scratch, scans = sc
2120
2186
  return { block: true, reason: `Refused: this run has made ${MAX_TOOL_CALLS} tool calls, its limit. Call impeccable_progress with every task's final status, then impeccable_report with what was done and what was left.` };
2121
2187
  }
2122
2188
  const input = event.input;
2123
- const scriptsChanged = changed ?? false;
2189
+ const scriptsChanged = changedNow() ?? false;
2124
2190
  let decision;
2125
2191
  let shown;
2126
2192
  if (event.toolName === 'bash') {
2127
2193
  shown = String(input.command ?? '');
2128
- decision = classifyCommand(shown, root, { protectedBranches, scriptsChanged, scratch, hosts: hostsAgreed(log), installs: installsAgreed(log), teams: teamsChosen(log), vercelSettled: vercelFirstDeploymentSettled(log), touched, synced, repository: retry.repository });
2194
+ decision = classifyCommand(shown, root, { protectedBranches, scriptsChanged, scratch, hosts: hostsAgreed(log), installs: installsAgreed(log), teams: teamsChosen(log), vercelSettled: vercelFirstDeploymentSettled(log), ended: setupsEnded(log), touched, synced, repository: retry.repository });
2129
2195
  if (decision.verdict === 'allow')
2130
2196
  decision = reservePreviewCommand(retry, shown, root, scratch);
2131
2197
  if (decision.verdict === 'allow') {
@@ -2135,7 +2201,8 @@ export function fenceExtension(root, log, protectedBranches, scratch, scans = sc
2135
2201
  watched.set(event.toolCallId, trustedFiles(root));
2136
2202
  }
2137
2203
  }
2138
- else if (event.toolName === 'impeccable_verify_preview' && previewChanged) {
2204
+ else if (event.toolName === 'impeccable_verify_preview' && changedNow(true)) {
2205
+ const previewChanged = changedNow(true);
2139
2206
  shown = `local preview after an edit of ${previewChanged}`;
2140
2207
  decision = deny(changedReason(previewChanged));
2141
2208
  }
@@ -2150,7 +2217,7 @@ export function fenceExtension(root, log, protectedBranches, scratch, scans = sc
2150
2217
  // The name written to and the file it resolves to: through a symlink, a vite.config may be another config.
2151
2218
  const loaded = [String(input.path), realPath(resolve(root, String(input.path)))].filter((p) => LOADED.test(p));
2152
2219
  if (loaded.length)
2153
- stop(String(input.path), loaded);
2220
+ writing.set(event.toolCallId, stop(String(input.path), loaded));
2154
2221
  }
2155
2222
  }
2156
2223
  else if (OUTPUT_TOOLS.has(event.toolName)) {
@@ -2204,6 +2271,7 @@ export function fenceExtension(root, log, protectedBranches, scratch, scans = sc
2204
2271
  }
2205
2272
  return undefined;
2206
2273
  }
2274
+ writing.delete(event.toolCallId);
2207
2275
  if (!['read', 'grep', 'bash'].includes(event.toolName))
2208
2276
  return undefined;
2209
2277
  const input = event.input;
@@ -2220,7 +2288,7 @@ export function fenceExtension(root, log, protectedBranches, scratch, scans = sc
2220
2288
  if (rewritten.length)
2221
2289
  log.write('a command changed files the scripts and tools load', rewritten.join(', '));
2222
2290
  for (const path of rewritten)
2223
- stop(path, [path]);
2291
+ stop(path, [path], before.get(path) ?? '');
2224
2292
  }
2225
2293
  const sources = event.toolName === 'bash' ? (parseCommand(String(input.command ?? ''))?.flatMap((seg) => seg.words) ?? []) : [input.path, input.glob].filter((s) => typeof s === 'string');
2226
2294
  const hits = scan('input', text(), sources);
package/dist/focus.js CHANGED
@@ -32,6 +32,23 @@ const hasApps = (repo) => Boolean(record(repo.items)?.[APPS.item]) || records(re
32
32
  export function severalApps(answer) {
33
33
  return reposOf(answer).some(hasApps);
34
34
  }
35
+ /** The state's sign-in item of `repo`, which it has only when the scan read a sign-in of the app's own. */
36
+ export const signInItem = (state, repo) => record(record(reposOf(state).find((r) => String(r.name).toLowerCase() === repo.toLowerCase())?.items)?.signin);
37
+ /**
38
+ * The apps of `repo` with a test account saved, as the sign-in item says (#410): '' on a repository of one app. On one
39
+ * with several, the item speaks for its first picked app with that app's roles, and the others ride along in `perApp`
40
+ * with their status alone, `done` once an account is saved.
41
+ */
42
+ export function accountsSaved(state, repo) {
43
+ const item = signInItem(state, repo);
44
+ if (!item)
45
+ return [];
46
+ const saved = Array.isArray(item.rolesSaved) && item.rolesSaved.length > 0;
47
+ if (!severalApps(state))
48
+ return saved ? [''] : [];
49
+ const keys = records(item.perApp).filter((app) => app.status === 'done').map((app) => app.app);
50
+ return [...new Set([...(saved ? [item.app] : []), ...keys])].filter((key) => typeof key === 'string' && key !== '');
51
+ }
35
52
  /**
36
53
  * Whether the state or the checkup shows a card of this setup `not-selected` on `repo`, a repository with several apps.
37
54
  * The command's token covers the apps there were when the person signed in, so this is an app the scan proposed later.
@@ -0,0 +1,271 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { existsSync, lstatSync, readFileSync, realpathSync, rmdirSync, unlinkSync, writeFileSync } from 'node:fs';
4
+ import { dirname, isAbsolute, relative, sep } from 'node:path';
5
+ import { gitEnvironment } from './sandbox.js';
6
+ const digest = (bytes) => createHash('sha256').update(bytes).digest('hex');
7
+ /** Up to three paths, then how many more. */
8
+ export const listPaths = (paths) => `${paths.slice(0, 3).join(', ')}${paths.length > 3 ? ` and ${paths.length - 3} more` : ''}`;
9
+ /** A pathspec git reads as the path itself, never a pattern. */
10
+ const literal = (path) => `:(literal)${path}`;
11
+ const them = (paths, one, more) => (paths.length === 1 ? one : more);
12
+ export function checkoutJournal(checkoutRoot) {
13
+ const root = realpathSync(checkoutRoot);
14
+ // As the person, outside the sandbox, as backToStartBranch runs it: no hooks, no fsmonitor.
15
+ const git = (args, input) => execFileSync('git', args, { cwd: root, encoding: 'utf8', input, stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'ignore'], timeout: 10_000, maxBuffer: 64 * 1024 * 1024, env: gitEnvironment() });
16
+ const untracked = () => {
17
+ try {
18
+ return new Set(git(['ls-files', '-z', '--others', '--exclude-standard']).split('\0').filter(Boolean));
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ };
24
+ /** A blob's bytes as git keeps them: cat-file runs no filter without --filters. */
25
+ const blob = (id) => execFileSync('git', ['cat-file', 'blob', id], { cwd: root, stdio: ['ignore', 'pipe', 'ignore'], timeout: 10_000, maxBuffer: 64 * 1024 * 1024, env: gitEnvironment() });
26
+ let autocrlf;
27
+ /**
28
+ * What git converts in `rel` as it stages it, read from its attributes and settings (check-attr and config run no filter):
29
+ * CRLF to LF (`lf`), unless the attributes say it is not text; and whether it has a working-tree-encoding.
30
+ */
31
+ const convert = (rel) => {
32
+ const listed = git(['check-attr', '-z', 'text', 'eol', 'crlf', 'working-tree-encoding', '--', rel]).split('\0');
33
+ const attr = new Map();
34
+ for (let i = 0; i + 2 < listed.length; i += 3)
35
+ attr.set(listed[i + 1], listed[i + 2]);
36
+ if (autocrlf === undefined) {
37
+ try {
38
+ autocrlf = git(['config', '--get', 'core.autocrlf']).trim().toLowerCase();
39
+ }
40
+ catch {
41
+ autocrlf = '';
42
+ }
43
+ }
44
+ const text = attr.get('text') ?? 'unspecified', crlf = attr.get('crlf') ?? 'unspecified';
45
+ const notText = text === 'unset' || (text === 'unspecified' && crlf === 'unset');
46
+ const isText = text === 'set' || text === 'auto' || crlf === 'set' || (attr.get('eol') ?? 'unspecified') !== 'unspecified';
47
+ return { lf: !notText && (isText || ['true', 'input'].includes(autocrlf)), encoding: !['unspecified', 'unset'].includes(attr.get('working-tree-encoding') ?? 'unspecified') };
48
+ };
49
+ /** HEAD's blob of `rel`; '' with none. */
50
+ const headBlob = (rel) => {
51
+ try {
52
+ return git(['rev-parse', '-q', '--verify', `HEAD:${rel}`]).trim();
53
+ }
54
+ catch {
55
+ return '';
56
+ }
57
+ };
58
+ /** The path as git names it from the checkout's top; null outside the checkout. */
59
+ const named = (path) => {
60
+ const rel = relative(root, path);
61
+ return !rel || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel) ? null : rel.split(sep).join('/');
62
+ };
63
+ const atStart = untracked();
64
+ const files = new Map();
65
+ const dirs = new Set();
66
+ const current = (path) => {
67
+ try {
68
+ const stat = lstatSync(path);
69
+ return stat.isFile() ? readFileSync(path) : undefined;
70
+ }
71
+ catch (error) {
72
+ return error.code === 'ENOENT' ? null : undefined;
73
+ }
74
+ };
75
+ const removeEmptyDirs = () => {
76
+ // Deepest first, so a folder emptied by the one below it goes too; one that is not empty stays.
77
+ for (const dir of [...dirs].sort((a, b) => b.length - a.length)) {
78
+ try {
79
+ rmdirSync(dir);
80
+ }
81
+ catch {
82
+ // Not empty, or already gone.
83
+ }
84
+ }
85
+ };
86
+ return {
87
+ writing(path) {
88
+ const rel = named(path);
89
+ if (files.has(path) || rel === null)
90
+ return;
91
+ const held = current(path);
92
+ try {
93
+ const entry = git(['ls-files', '-s', '-z', '--', literal(rel)]).split('\0').filter(Boolean);
94
+ // `<mode> <id> <stage>\t<path>`: a conflict's stages are left to the person.
95
+ const [mode, id, stage] = (entry[0] ?? '').split(/[ \t]/);
96
+ if (held === undefined || entry.length > 1 || (entry.length && stage !== '0'))
97
+ throw new Error('not a plain file');
98
+ files.set(path, { original: held, index: entry.length ? `${mode} ${id}` : '', head: headBlob(rel), all: new Set(), lf: new Set() });
99
+ }
100
+ catch {
101
+ files.set(path, { original: null, index: '', head: '', all: new Set(), lf: new Set(), other: true });
102
+ }
103
+ },
104
+ wrote(path, content) {
105
+ const entry = files.get(path);
106
+ if (!entry)
107
+ return;
108
+ entry.all.add((entry.last = digest(Buffer.from(content, 'utf8'))));
109
+ entry.lf.add(digest(Buffer.from(content.replace(/\r\n/g, '\n'), 'utf8')));
110
+ },
111
+ making(dir) {
112
+ for (let at = dir; named(at) !== null && !existsSync(at); at = dirname(at))
113
+ dirs.add(at);
114
+ },
115
+ undo() {
116
+ const all = [...files].filter(([, entry]) => !entry.other).map(([path, entry]) => ({ path, rel: named(path), entry, now: current(path) }));
117
+ // A write still going when the run was stopped: what it left is not known, so it is named.
118
+ const unfinished = all.filter(({ entry, now }) => entry.last === undefined && now !== undefined && !(now === null ? entry.original === null : entry.original?.equals(now))).map(({ rel }) => rel);
119
+ const mine = all.filter(({ entry }) => entry.last !== undefined);
120
+ const lines = (kept, failed) => [
121
+ ...(kept.length ? [`${listPaths(kept)} changed after the setup wrote ${them(kept, 'it, so it stays', 'them, so they stay')} in your checkout as ${them(kept, 'it is', 'they are')}.`] : []),
122
+ ...(unfinished.length ? [`The setup stopped while writing ${listPaths(unfinished)}, so ${them(unfinished, 'it stays', 'they stay')} in your checkout as ${them(unfinished, 'it is', 'they are')}.`] : []),
123
+ ...(failed.length ? [`The setup could not take back what it wrote to ${listPaths(failed)}: ${them(failed, 'it stays', 'they stay')} in your checkout.`] : []),
124
+ ];
125
+ if (!mine.length) {
126
+ removeEmptyDirs();
127
+ return lines([], []);
128
+ }
129
+ let tracked;
130
+ try {
131
+ tracked = new Set(git(['ls-files', '-z', '--cached', '--', ...mine.map(({ rel }) => literal(rel))]).split('\0').filter(Boolean));
132
+ }
133
+ catch {
134
+ return [`The setup could not check ${listPaths(mine.map(({ rel }) => rel))}, which it wrote, so ${them(mine, 'it is', 'they are')} as it left ${them(mine, 'it', 'them')}.`];
135
+ }
136
+ // git status may run a content filter outside the sandbox on a tracked file: those are left to the person, as backToStartBranch leaves the checkout.
137
+ const ownTracked = mine.filter(({ rel }) => tracked.has(rel));
138
+ let changedTracked = new Set();
139
+ let filtered = false;
140
+ if (ownTracked.length) {
141
+ try {
142
+ const listed = git(['check-attr', '--stdin', '-z', 'filter'], ownTracked.map(({ rel }) => `${rel}\0`).join('')).split('\0');
143
+ for (let i = 2; i < listed.length; i += 3)
144
+ if (!['unspecified', 'unset'].includes(listed[i]))
145
+ filtered = true;
146
+ if (!filtered) {
147
+ changedTracked = new Set(git(['status', '--porcelain=v1', '-z', '--untracked-files=no', '--no-renames', '--', ...ownTracked.map(({ rel }) => literal(rel))]).split('\0').filter(Boolean).map((line) => line.slice(3)));
148
+ }
149
+ }
150
+ catch {
151
+ filtered = true;
152
+ }
153
+ }
154
+ const kept = [];
155
+ const failed = [];
156
+ /** The index entries to set, `<mode> <id>\t<path>`, and the paths to take out of the index. */
157
+ const entries = [];
158
+ const removed = [];
159
+ const committed = [];
160
+ // Read before anything is taken back, which may put an attributes file back and change them.
161
+ const rules = new Map(mine.map(({ rel }) => {
162
+ try {
163
+ return [rel, convert(rel)];
164
+ }
165
+ catch {
166
+ return [rel, { lf: false, encoding: true }];
167
+ }
168
+ }));
169
+ /**
170
+ * `rel`'s index entry goes to `to` (`<mode> <id>`, '' for none: by default the one before the run's first write) where
171
+ * what is staged now is something the run wrote; what anyone else staged stays.
172
+ */
173
+ const stageBack = (rel, entry, to = entry.index) => {
174
+ try {
175
+ const [mode, id] = (git(['ls-files', '-s', '-z', '--', literal(rel)]).split('\0')[0] ?? '').split(/[ \t]/);
176
+ if (!id || `${mode} ${id}` === to)
177
+ return;
178
+ const staged = digest(blob(id));
179
+ // As git stages it where its line-ending rules turn CRLF into LF.
180
+ if (!entry.all.has(staged) && !(rules.get(rel).lf && entry.lf.has(staged)))
181
+ return;
182
+ if (to)
183
+ entries.push(`${to}\t${rel}`);
184
+ else
185
+ removed.push(rel);
186
+ }
187
+ catch {
188
+ // Left staged: a file still changed keeps the checkout on its branch, and backToStartBranch names it.
189
+ }
190
+ };
191
+ for (const { path, rel, entry, now } of mine) {
192
+ // A file a commit carries, as it is, is the branch's, and goes with it.
193
+ if (tracked.has(rel) && (filtered || !changedTracked.has(rel)))
194
+ continue;
195
+ // Gone, or no longer a plain file: nothing of the run's is left there to take back, but in the index where it was never there.
196
+ if (!now) {
197
+ if (now === null && entry.original === null)
198
+ stageBack(rel, entry);
199
+ continue;
200
+ }
201
+ // Changed since the run last wrote it, by a command or by the person: theirs now, unless it is back as it was.
202
+ if (digest(now) !== entry.last) {
203
+ if (entry.original?.equals(now))
204
+ stageBack(rel, entry);
205
+ else
206
+ kept.push(rel);
207
+ continue;
208
+ }
209
+ const head = tracked.has(rel) ? headBlob(rel) : entry.head;
210
+ // The run committed the file after its first write, so it is the branch's: back to HEAD's version, once every other file is back.
211
+ if (head !== entry.head) {
212
+ committed.push({ path, rel, entry, head });
213
+ continue;
214
+ }
215
+ try {
216
+ if (entry.original === null)
217
+ unlinkSync(path);
218
+ else
219
+ writeFileSync(path, entry.original);
220
+ stageBack(rel, entry);
221
+ }
222
+ catch (error) {
223
+ failed.push(`${rel} (${error.code ?? 'an error'})`);
224
+ }
225
+ }
226
+ for (const { path, rel, entry, head } of committed) {
227
+ try {
228
+ const [mode] = git(['ls-tree', '-z', 'HEAD', '--', rel]).split('\0').find((line) => line.endsWith(`\t${rel}`))?.split(' ') ?? [];
229
+ if (!mode || !head)
230
+ throw Object.assign(new Error('no entry in HEAD'), { code: 'ENOENT' });
231
+ // Kept in another encoding in the working tree than git keeps it, which only git's checkout converts: by the rules before the run's files were put back, or after.
232
+ let encoded = rules.get(rel).encoding;
233
+ try {
234
+ encoded ||= convert(rel).encoding;
235
+ }
236
+ catch {
237
+ encoded = true;
238
+ }
239
+ if (encoded)
240
+ throw Object.assign(new Error('working-tree-encoding'), { code: 'working-tree-encoding' });
241
+ // HEAD's bytes as git keeps them, which no filter runs on.
242
+ writeFileSync(path, blob(head));
243
+ stageBack(rel, entry, `${mode} ${head}`);
244
+ }
245
+ catch (error) {
246
+ failed.push(`${rel} (${error.code ?? 'an error'})`);
247
+ }
248
+ }
249
+ try {
250
+ // No filter runs: these set index entries alone.
251
+ if (entries.length)
252
+ git(['update-index', '-z', '--index-info'], entries.map((line) => `${line}\0`).join(''));
253
+ if (removed.length)
254
+ git(['update-index', '-z', '--force-remove', '--stdin'], removed.map((rel) => `${rel}\0`).join(''));
255
+ }
256
+ catch {
257
+ // A file still changed keeps the checkout on its branch, and backToStartBranch names it.
258
+ }
259
+ removeEmptyDirs();
260
+ return lines(kept, failed);
261
+ },
262
+ appeared() {
263
+ const now = untracked();
264
+ if (!atStart || !now)
265
+ return [];
266
+ const mine = new Set([...files.keys()].map(named));
267
+ const fresh = [...now].filter((path) => !atStart.has(path) && !mine.has(path));
268
+ return fresh.length ? [`${listPaths(fresh)} appeared in your checkout during the setup, from a command it ran or from you, so ${them(fresh, 'it stays', 'they stay')}.`] : [];
269
+ },
270
+ };
271
+ }