@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 +88 -20
- package/dist/focus.js +17 -0
- package/dist/leftovers.js +271 -0
- package/dist/main.js +42 -26
- package/dist/playbook/cases/cloudflare-previews.md +1 -1
- package/dist/playbook/cases/monorepo.md +2 -2
- package/dist/playbook/cases/netlify-setup.md +3 -3
- package/dist/playbook/cases/preview-protection.md +3 -2
- package/dist/playbook/cases/server-previews.md +3 -3
- package/dist/playbook/cases/sign-in.md +8 -8
- package/dist/playbook/cases/vercel-setup.md +3 -3
- package/dist/playbook.js +19 -3
- package/dist/preflight.js +13 -5
- package/dist/tools.js +235 -36
- package/dist/ui/bridge.js +4 -0
- package/dist/ui/look.js +34 -20
- package/dist/ui/run-state.js +12 -1
- package/dist/ui/screens.js +12 -6
- package/package.json +1 -1
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:
|
|
1552
|
-
mkdir: async (dir) =>
|
|
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:
|
|
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
|
-
/**
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
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 =
|
|
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' &&
|
|
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
|
+
}
|