carveout 0.1.0 → 0.1.2
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/AGENTS.md +21 -3
- package/NOTICE.md +2 -1
- package/README.md +274 -460
- package/dist/src/account.d.ts +37 -0
- package/dist/src/account.js +275 -0
- package/dist/src/api.d.ts +148 -0
- package/dist/src/api.js +120 -0
- package/dist/src/carve.d.ts +788 -0
- package/dist/src/carve.js +1721 -125
- package/dist/src/cli.d.ts +10 -0
- package/dist/src/cli.js +674 -47
- package/dist/src/command.d.ts +10 -0
- package/dist/src/core.d.ts +11 -0
- package/dist/src/cut.d.ts +78 -0
- package/dist/src/cut.js +25 -7
- package/dist/src/extract/compiler-config.d.ts +49 -0
- package/dist/src/extract/fixture.d.ts +103 -0
- package/dist/src/extract/hash.d.ts +6 -0
- package/dist/src/extract/package-registry.d.ts +47 -0
- package/dist/src/extract/package-types.d.ts +52 -0
- package/dist/src/extract/surface.d.ts +44 -0
- package/dist/src/extract/typescript.d.ts +180 -0
- package/dist/src/gate.d.ts +55 -0
- package/dist/src/gate.js +1 -0
- package/dist/src/graph.d.ts +142 -0
- package/dist/src/graph.js +4 -3
- package/dist/src/impact.d.ts +113 -0
- package/dist/src/imports.d.ts +67 -0
- package/dist/src/mergeback.d.ts +182 -0
- package/dist/src/mergeback.js +580 -35
- package/dist/src/platform.d.ts +69 -0
- package/dist/src/platform.js +184 -0
- package/dist/src/policy.d.ts +181 -0
- package/dist/src/policy.js +716 -230
- package/dist/src/project/build.d.ts +23 -0
- package/dist/src/project/catalog.d.ts +25 -0
- package/dist/src/project/project.d.ts +287 -0
- package/dist/src/project/project.js +479 -52
- package/dist/src/project/provenance.d.ts +73 -0
- package/dist/src/project/provenance.js +12 -0
- package/dist/src/project/runner.d.ts +19 -0
- package/dist/src/project/secrets.d.ts +64 -0
- package/dist/src/project/vitest-config.d.ts +70 -0
- package/dist/src/project/vitest-config.js +291 -0
- package/dist/src/pseudonyms/aliases.d.ts +107 -0
- package/dist/src/pseudonyms/aliases.js +394 -0
- package/dist/src/pseudonyms/draft.d.ts +64 -0
- package/dist/src/pseudonyms/draft.js +284 -0
- package/dist/src/pseudonyms/folder.d.ts +172 -0
- package/dist/src/pseudonyms/folder.js +527 -0
- package/dist/src/pseudonyms/index.d.ts +11 -0
- package/dist/src/pseudonyms/index.js +11 -0
- package/dist/src/pseudonyms/vocabulary.d.ts +113 -0
- package/dist/src/pseudonyms/vocabulary.js +522 -0
- package/dist/src/readiness.d.ts +71 -0
- package/dist/src/readiness.js +131 -0
- package/dist/src/receipt.d.ts +154 -0
- package/dist/src/receipt.js +296 -0
- package/dist/src/sandbox.d.ts +53 -0
- package/dist/src/sandbox.js +154 -0
- package/dist/src/slice/analysis.d.ts +75 -0
- package/dist/src/slice/externals.d.ts +51 -0
- package/dist/src/slice/graph.d.ts +69 -0
- package/dist/src/slice/manifest.d.ts +262 -0
- package/dist/src/slice/manifest.js +4 -3
- package/dist/src/slice/reach.d.ts +204 -0
- package/dist/src/slice/reach.js +11 -5
- package/dist/src/slice/report.d.ts +49 -0
- package/dist/src/slice/report.js +5 -2
- package/dist/src/specifiers.d.ts +48 -0
- package/dist/src/status.d.ts +46 -0
- package/dist/src/status.js +3 -2
- package/dist/src/test-integrity.d.ts +54 -0
- package/dist/src/test-integrity.js +812 -0
- package/package.json +24 -5
- package/skills/carveout/SKILL.md +517 -0
- package/skills/carveout-gate/SKILL.md +98 -0
- package/SKILL.md +0 -423
package/dist/src/mergeback.js
CHANGED
|
@@ -16,19 +16,37 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
18
18
|
import { createHash } from "node:crypto";
|
|
19
|
-
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
19
|
+
import { copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, symlinkSync, writeFileSync } from "node:fs";
|
|
20
20
|
import { copyFile, mkdir, rm, writeFile } from "node:fs/promises";
|
|
21
21
|
import { tmpdir } from "node:os";
|
|
22
22
|
import { dirname, join, resolve } from "node:path";
|
|
23
|
-
import {
|
|
23
|
+
import { checkRecordFiles, computeCut, cutSlice, patchProject, sliceSpecifierPackages, } from "./carve.js";
|
|
24
24
|
import { isTestPath, runtimePath, } from "./core.js";
|
|
25
25
|
import { symbolDiff } from "./status.js";
|
|
26
26
|
import { trustedRepo } from "./policy.js";
|
|
27
|
+
import { sandboxRun } from "./sandbox.js";
|
|
28
|
+
import { folderView, saveRecord, trustedCut } from "./receipt.js";
|
|
29
|
+
import { exactToFolder, fromFolder, looseLeaks, toFolder, wordsToFolder } from "./pseudonyms/index.js";
|
|
30
|
+
import { workspacePackages } from "./extract/compiler-config.js";
|
|
27
31
|
const compare = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
28
32
|
const sha256 = (data) => createHash("sha256").update(data).digest("hex");
|
|
29
33
|
function git(cwd, args) {
|
|
30
34
|
return execFileSync("git", ["-c", "user.name=carveout", "-c", "user.email=carveout@localhost", ...args], { cwd, stdio: ["ignore", "pipe", "pipe"], maxBuffer: 256 * 1024 * 1024 }).toString("utf8");
|
|
31
35
|
}
|
|
36
|
+
/** `theirs` merged into `ours` from `base`, as `git merge-file` does; conflicts when they overlap. */
|
|
37
|
+
function mergeText(scratch, ours, base, theirs) {
|
|
38
|
+
const at = mkdtempSync(join(scratch, "merge-"));
|
|
39
|
+
writeFileSync(join(at, "ours"), ours);
|
|
40
|
+
writeFileSync(join(at, "base"), base);
|
|
41
|
+
writeFileSync(join(at, "theirs"), theirs);
|
|
42
|
+
const result = spawnSync("git", ["merge-file", "-p", join(at, "ours"), join(at, "base"), join(at, "theirs")], {
|
|
43
|
+
encoding: "utf8",
|
|
44
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
45
|
+
});
|
|
46
|
+
if (result.status === null || result.status < 0)
|
|
47
|
+
throw new Error(`git merge-file failed: ${result.stderr}`);
|
|
48
|
+
return { text: result.stdout, conflicts: result.status > 0 };
|
|
49
|
+
}
|
|
32
50
|
/**
|
|
33
51
|
* Brings `patchText`, a patch for the repository at the cut's commit, into
|
|
34
52
|
* the folder. `stubs` also writes the stubs and mocks the change needs, and
|
|
@@ -46,8 +64,19 @@ function packageDirOf(repo, path) {
|
|
|
46
64
|
}
|
|
47
65
|
export async function applyPatch(dir, patchText, options = {}) {
|
|
48
66
|
const root = resolve(dir);
|
|
49
|
-
|
|
67
|
+
let trusted = trustedCut(root, options.receipt, options.noReceipt === true ? { noReceipt: true } : {});
|
|
68
|
+
// A public folder in pseudonyms takes the change in its own names: each
|
|
69
|
+
// file at its folder path, its text renamed by the receipt's table.
|
|
70
|
+
const record = trusted.record;
|
|
50
71
|
const source = trustedRepo(record, options.repo);
|
|
72
|
+
// The packages the repository knows, as patch reads them back.
|
|
73
|
+
const known = new Set([
|
|
74
|
+
...workspacePackages(source).keys(),
|
|
75
|
+
...record.dependencies.map((dependency) => dependency.name),
|
|
76
|
+
...record.manifest.requiredBuildInputs.packages.map((item) => item.name),
|
|
77
|
+
...sliceSpecifierPackages(record, source),
|
|
78
|
+
]);
|
|
79
|
+
const names = namesOf(trusted.receipt?.pseudonyms, ownPackagesOf(root), (name) => known.has(name));
|
|
51
80
|
checkRecordFiles(record, source);
|
|
52
81
|
const commit = record.manifest.sourceCommitSha;
|
|
53
82
|
if (commit === null)
|
|
@@ -57,14 +86,34 @@ export async function applyPatch(dir, patchText, options = {}) {
|
|
|
57
86
|
try {
|
|
58
87
|
git(scratch, ["clone", "-q", "--shared", "--no-checkout", source, repo]);
|
|
59
88
|
git(repo, ["checkout", "-q", "--detach", commit]);
|
|
89
|
+
// The slice files the folder holds edits in, by their folder paths: the
|
|
90
|
+
// change is merged into those, never written over them.
|
|
91
|
+
const view = trusted.receipt === null ? record : folderView(trusted.receipt);
|
|
92
|
+
// A file the folder deleted is an edit too: the change neither brings
|
|
93
|
+
// it back nor writes over its absence.
|
|
94
|
+
const deletedInFolder = new Set((view.copied ?? []).filter((file) => !existsSync(join(root, file.path))).map((file) => file.path));
|
|
95
|
+
const editedInFolder = new Set((view.copied ?? [])
|
|
96
|
+
.filter((file) => {
|
|
97
|
+
const at = join(root, file.path);
|
|
98
|
+
return !existsSync(at) || createHash("sha256").update(readFileSync(at)).digest("hex") !== file.sha256;
|
|
99
|
+
})
|
|
100
|
+
.map((file) => file.path));
|
|
60
101
|
const file = join(scratch, "change.patch");
|
|
61
102
|
writeFileSync(file, patchText);
|
|
62
103
|
try {
|
|
63
104
|
git(repo, ["apply", "--whitespace=nowarn", file]);
|
|
64
105
|
}
|
|
65
|
-
catch (
|
|
66
|
-
|
|
67
|
-
|
|
106
|
+
catch (plain) {
|
|
107
|
+
// A patch made against an earlier commit (a folder widened after the
|
|
108
|
+
// repository moved) merges by the blobs its index lines name.
|
|
109
|
+
try {
|
|
110
|
+
git(repo, ["apply", "--3way", "--whitespace=nowarn", file]);
|
|
111
|
+
git(repo, ["reset", "-q"]);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
const stderr = plain.stderr?.toString("utf8") ?? "";
|
|
115
|
+
throw new Error(`The patch does not apply at the cut's commit ${commit.slice(0, 12)}: ${stderr.trim()}`);
|
|
116
|
+
}
|
|
68
117
|
}
|
|
69
118
|
git(repo, ["add", "-A"]);
|
|
70
119
|
const touched = new Map();
|
|
@@ -90,11 +139,24 @@ export async function applyPatch(dir, patchText, options = {}) {
|
|
|
90
139
|
followWorkspaces: record.request.followWorkspaces === true,
|
|
91
140
|
copyPure: record.request.copyPure === true,
|
|
92
141
|
private: record.request.private ?? [],
|
|
93
|
-
|
|
142
|
+
// A copied folder the change deleted whole is copied no more: its
|
|
143
|
+
// files go as any deleted file does.
|
|
144
|
+
copy: (record.request.copy ?? []).filter((dir) => existsSync(join(repo, dir))),
|
|
94
145
|
public: record.request.public === true,
|
|
146
|
+
// The owner's acceptance tests stay unread in every later cut.
|
|
147
|
+
...(trusted.receipt?.unseen === undefined ? {} : { unseen: trusted.receipt.unseen }),
|
|
95
148
|
...(options.log === undefined ? {} : { log: options.log }),
|
|
96
149
|
};
|
|
97
150
|
const cuts = [await computeCut(request)];
|
|
151
|
+
// The text the cut wrote for each slice file, for the three-way merge:
|
|
152
|
+
// a cut of the repository at the cut's commit, before the change.
|
|
153
|
+
let baseCut = null;
|
|
154
|
+
if (editedInFolder.size > 0) {
|
|
155
|
+
const baseRepo = join(scratch, "base");
|
|
156
|
+
git(scratch, ["clone", "-q", "--shared", "--no-checkout", source, baseRepo]);
|
|
157
|
+
git(baseRepo, ["checkout", "-q", "--detach", commit]);
|
|
158
|
+
baseCut = await computeCut({ ...request, repo: baseRepo });
|
|
159
|
+
}
|
|
98
160
|
const reached = (cut) => new Set(cut.record.manifest.included.map((file) => file.path));
|
|
99
161
|
// What the patch adds that the entries do not reach is still the
|
|
100
162
|
// change: a module added past the depth, and the tests of it or of the
|
|
@@ -122,17 +184,43 @@ export async function applyPatch(dir, patchText, options = {}) {
|
|
|
122
184
|
maxFiles: record.request.budget.maxFiles + beyond.length,
|
|
123
185
|
},
|
|
124
186
|
}));
|
|
187
|
+
// A credential the change brings never reaches the folder, as a cut of
|
|
188
|
+
// the same commit would write nothing: refused before anything is.
|
|
189
|
+
const credentials = cuts.flatMap((cut) => cut.record.blockers.filter((blocker) => blocker.code === "secret_found" || blocker.code === "secret_file"));
|
|
190
|
+
if (credentials.length > 0)
|
|
191
|
+
throw new Error(`Nothing applied: the change would bring a credential into the folder.\n${[
|
|
192
|
+
...new Set(credentials.map((blocker) => ` ${blocker.code}${blocker.file === null ? "" : ` ${blocker.file}`}: ${blocker.detail}`)),
|
|
193
|
+
].join("\n")}`);
|
|
125
194
|
const included = new Set(record.manifest.included.map((file) => file.path));
|
|
126
195
|
const changed = [];
|
|
127
196
|
const added = [];
|
|
128
197
|
const deleted = [];
|
|
129
198
|
const outside = [];
|
|
199
|
+
const merged = [];
|
|
200
|
+
const conflicts = [];
|
|
201
|
+
const unmapped = [];
|
|
130
202
|
const grown = [];
|
|
131
203
|
const rewrites = [];
|
|
204
|
+
/** Folder paths the command wrote, for the leak scan. */
|
|
205
|
+
const wrote = [];
|
|
206
|
+
/** Each slice file written, by repository path, as the cut writes it (the folder's edits aside). */
|
|
207
|
+
const newly = new Map();
|
|
208
|
+
/** The import rewrites of the cut that wrote each file. */
|
|
209
|
+
const rewrittenBy = new Map();
|
|
132
210
|
for (const [path, status] of [...touched.entries()].sort(([a], [b]) => compare(a, b))) {
|
|
133
211
|
if (status === "D") {
|
|
134
212
|
if (included.has(path)) {
|
|
135
|
-
|
|
213
|
+
// Deleted on both sides: nothing to do.
|
|
214
|
+
if (deletedInFolder.has(names.path(path))) {
|
|
215
|
+
deleted.push(path);
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
// A file the folder edited is never deleted under it.
|
|
219
|
+
if (editedInFolder.has(names.path(path))) {
|
|
220
|
+
conflicts.push(path);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
await rm(join(root, names.path(path)), { force: true });
|
|
136
224
|
deleted.push(path);
|
|
137
225
|
}
|
|
138
226
|
else
|
|
@@ -151,38 +239,170 @@ export async function applyPatch(dir, patchText, options = {}) {
|
|
|
151
239
|
outside.push(path);
|
|
152
240
|
continue;
|
|
153
241
|
}
|
|
154
|
-
await mkdir(dirname(join(root, path)), { recursive: true });
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
242
|
+
await mkdir(dirname(join(root, names.path(path))), { recursive: true });
|
|
243
|
+
const folderPath = names.path(path);
|
|
244
|
+
const base = baseCut?.files.find((item) => item.path === path)?.text;
|
|
245
|
+
if (deletedInFolder.has(folderPath)) {
|
|
246
|
+
// The folder deleted what the change edits: left deleted, named.
|
|
247
|
+
conflicts.push(path);
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (editedInFolder.has(folderPath) && (text === null || base === undefined)) {
|
|
251
|
+
// The folder edited a file the change writes and there is no text
|
|
252
|
+
// at the cut's commit to merge from (one an earlier apply added, a
|
|
253
|
+
// binary): left as the folder has it, for a person.
|
|
254
|
+
conflicts.push(path);
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (text !== null && editedInFolder.has(folderPath) && base !== undefined) {
|
|
258
|
+
// The folder's edit, read back in the repository's names, with the
|
|
259
|
+
// change merged in; left alone where the two overlap.
|
|
260
|
+
const ours = names.back(folderPath, readFileSync(join(root, folderPath), "utf8"));
|
|
261
|
+
const result = mergeText(scratch, ours, base, text);
|
|
262
|
+
if (result.conflicts) {
|
|
263
|
+
conflicts.push(path);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
const folderText = names.exact(path, result.text);
|
|
267
|
+
if (folderText === null) {
|
|
268
|
+
unmapped.push(path);
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
await writeFile(join(root, folderPath), folderText);
|
|
272
|
+
merged.push(path);
|
|
273
|
+
}
|
|
274
|
+
else if (text === null)
|
|
275
|
+
await copyFile(join(repo, path), join(root, folderPath));
|
|
276
|
+
else {
|
|
277
|
+
const folderText = names.exact(path, text);
|
|
278
|
+
if (folderText === null) {
|
|
279
|
+
unmapped.push(path);
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
await writeFile(join(root, folderPath), folderText);
|
|
283
|
+
}
|
|
284
|
+
wrote.push(folderPath);
|
|
285
|
+
const clean = text === null
|
|
286
|
+
? (() => {
|
|
287
|
+
const bytes = readFileSync(join(repo, path));
|
|
288
|
+
return { folderPath, sha256: sha256(bytes), folderSha256: sha256(bytes) };
|
|
289
|
+
})()
|
|
290
|
+
: { folderPath, sha256: sha256(text), folderSha256: sha256(names.exact(path, text) ?? "") };
|
|
291
|
+
newly.set(path, clean);
|
|
292
|
+
// The imports the cut that wrote it rewrote, which a patch undoes.
|
|
293
|
+
rewrittenBy.set(path, cut.record.importRewrites.filter((rewrite) => rewrite.file === path));
|
|
294
|
+
options.written?.set(path, clean);
|
|
159
295
|
if (!included.has(path) && status === "M") {
|
|
160
296
|
// A repository file the slice did not hold: it joins the slice at
|
|
161
297
|
// the bytes of the cut's commit, so `patch` diffs it from there.
|
|
162
298
|
grown.push(includedAt(repo, path));
|
|
163
299
|
rewrites.push(...cut.record.importRewrites.filter((rewrite) => rewrite.file === path));
|
|
164
300
|
}
|
|
301
|
+
else if (!included.has(path) && status === "A")
|
|
302
|
+
// A file the change adds keeps the imports the cut rewrote in it on
|
|
303
|
+
// record, so a patch reads them back as the repository writes them.
|
|
304
|
+
rewrites.push(...cut.record.importRewrites.filter((rewrite) => rewrite.file === path));
|
|
165
305
|
(included.has(path) || status === "M" ? changed : added).push(path);
|
|
166
306
|
}
|
|
167
|
-
const stubs = await neededStubs(root, record, cuts, options.stubs === true)
|
|
168
|
-
|
|
169
|
-
|
|
307
|
+
const stubs = await neededStubs(root, record, cuts, options.stubs === true, names, (updated, written) => {
|
|
308
|
+
wrote.push(...Object.keys(written));
|
|
309
|
+
trusted = saveRecord(root, trusted, updated, written);
|
|
310
|
+
});
|
|
311
|
+
// A file the change added or the slice grew is the folder's copy from
|
|
312
|
+
// now on, at what the command wrote: a later apply sees the folder's
|
|
313
|
+
// edits to it, and merges into them or leaves them, never over them.
|
|
314
|
+
const copiedNow = new Set((trusted.record.copied ?? []).map((file) => file.path));
|
|
315
|
+
const fresh = trusted.record.copied === undefined ? [] : [...newly.entries()].filter(([path]) => !copiedNow.has(path));
|
|
316
|
+
// A file written takes the rewrites of the cut that wrote it, in place of
|
|
317
|
+
// the ones its earlier text had (an import the change added, rewritten).
|
|
318
|
+
const rewritesNow = [
|
|
319
|
+
...trusted.record.importRewrites.filter((rewrite) => !rewrittenBy.has(rewrite.file)),
|
|
320
|
+
...[...rewrittenBy.values()].flat(),
|
|
321
|
+
].sort((a, b) => compare(`${a.file}\n${a.from}`, `${b.file}\n${b.from}`));
|
|
322
|
+
const rewritesMoved = JSON.stringify(rewritesNow) !==
|
|
323
|
+
JSON.stringify([...trusted.record.importRewrites].sort((a, b) => compare(`${a.file}\n${a.from}`, `${b.file}\n${b.from}`)));
|
|
324
|
+
if (grown.length > 0 || fresh.length > 0 || rewritesMoved) {
|
|
325
|
+
const current = trusted.record;
|
|
170
326
|
const updated = {
|
|
171
327
|
...current,
|
|
172
328
|
manifest: {
|
|
173
329
|
...current.manifest,
|
|
174
330
|
included: [...current.manifest.included, ...grown].sort((a, b) => compare(a.path, b.path)),
|
|
175
331
|
},
|
|
176
|
-
importRewrites: [
|
|
332
|
+
importRewrites: [
|
|
333
|
+
...current.importRewrites.filter((rewrite) => !rewrittenBy.has(rewrite.file)),
|
|
334
|
+
...[...rewrittenBy.values()].flat(),
|
|
335
|
+
...rewrites.filter((rewrite) => !rewrittenBy.has(rewrite.file)),
|
|
336
|
+
].sort((a, b) => compare(`${a.file}\n${a.from}`, `${b.file}\n${b.from}`)),
|
|
337
|
+
...(current.copied === undefined
|
|
338
|
+
? {}
|
|
339
|
+
: {
|
|
340
|
+
copied: [...current.copied, ...fresh.map(([path, { sha256: hash }]) => ({ path, sha256: hash, applied: true }))].sort((a, b) => compare(a.path, b.path)),
|
|
341
|
+
}),
|
|
177
342
|
};
|
|
178
|
-
|
|
343
|
+
trusted = saveRecord(root, trusted, updated, {}, new Map(fresh.map(([, { folderPath, folderSha256 }]) => [folderPath, folderSha256])));
|
|
179
344
|
}
|
|
180
|
-
|
|
345
|
+
// The folder's own record names paths too: scanned with what was written.
|
|
346
|
+
return {
|
|
347
|
+
changed,
|
|
348
|
+
added,
|
|
349
|
+
deleted,
|
|
350
|
+
outside,
|
|
351
|
+
stubs,
|
|
352
|
+
merged,
|
|
353
|
+
conflicts,
|
|
354
|
+
leaks: leaksIn(root, wrote.length === 0 ? [] : [...wrote, "carveout.json"], trusted.receipt?.pseudonyms),
|
|
355
|
+
unmapped,
|
|
356
|
+
};
|
|
181
357
|
}
|
|
182
358
|
finally {
|
|
183
359
|
await rm(scratch, { recursive: true, force: true });
|
|
184
360
|
}
|
|
185
361
|
}
|
|
362
|
+
/** The folder's own mock packages, which go by the table's names. */
|
|
363
|
+
function ownPackagesOf(root) {
|
|
364
|
+
const own = new Set();
|
|
365
|
+
const mocks = join(root, "carveout", "packages");
|
|
366
|
+
if (existsSync(mocks))
|
|
367
|
+
for (const entry of readdirSync(mocks, { withFileTypes: true })) {
|
|
368
|
+
if (!entry.isDirectory())
|
|
369
|
+
continue;
|
|
370
|
+
if (entry.name.startsWith("@"))
|
|
371
|
+
for (const inner of readdirSync(join(mocks, entry.name), { withFileTypes: true }))
|
|
372
|
+
if (inner.isDirectory())
|
|
373
|
+
own.add(`${entry.name}/${inner.name}`);
|
|
374
|
+
else
|
|
375
|
+
own.add(entry.name);
|
|
376
|
+
}
|
|
377
|
+
return (name) => own.has(name);
|
|
378
|
+
}
|
|
379
|
+
/** The folder's names for a cut in pseudonyms (its table), or the repository's own. */
|
|
380
|
+
function namesOf(table, own = () => false, known = () => false) {
|
|
381
|
+
if (table === undefined)
|
|
382
|
+
return { path: (path) => path, text: (_path, text) => text, exact: (_path, text) => text, back: (_path, text) => text };
|
|
383
|
+
// The table's rules, then a placeholder for each term they did not reach:
|
|
384
|
+
// a file the repository added since the cut, or a line it wrote, spells
|
|
385
|
+
// a term where no rule of the cut's matches.
|
|
386
|
+
return {
|
|
387
|
+
path: (path) => exactToFolder(table, path, "", own, known)?.path ?? toFolder(table, path, "").path,
|
|
388
|
+
text: (path, text) => wordsToFolder(table, toFolder(table, path, text).text),
|
|
389
|
+
exact: (path, text) => exactToFolder(table, path, text, own, known)?.text ?? null,
|
|
390
|
+
back: (path, text) => fromFolder(table, path, text, own, known).text,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
/** The names a folder in pseudonyms must not hold, in the files a command wrote there. */
|
|
394
|
+
function leaksIn(root, paths, table) {
|
|
395
|
+
if (table === undefined || paths.length === 0)
|
|
396
|
+
return [];
|
|
397
|
+
const files = [...new Set(paths)].sort(compare).flatMap((path) => {
|
|
398
|
+
const at = join(root, path);
|
|
399
|
+
if (!existsSync(at))
|
|
400
|
+
return [];
|
|
401
|
+
const bytes = readFileSync(at);
|
|
402
|
+
return [{ path, text: bytes.includes(0) ? bytes.toString("latin1") : bytes.toString("utf8") }];
|
|
403
|
+
});
|
|
404
|
+
return looseLeaks(files, table.vocabulary, table.kept).map(({ term, files: found }) => ({ term, files: found }));
|
|
405
|
+
}
|
|
186
406
|
/** A file as the slice's record holds it, at the clone's HEAD (the cut's commit). */
|
|
187
407
|
function includedAt(repo, path) {
|
|
188
408
|
const bytes = execFileSync("git", ["show", `HEAD:${path}`], { cwd: repo, maxBuffer: 256 * 1024 * 1024 });
|
|
@@ -190,7 +410,7 @@ function includedAt(repo, path) {
|
|
|
190
410
|
return { path, blobId, sha256: sha256(bytes), mode, sizeBytes: bytes.length, operations: ["edit"] };
|
|
191
411
|
}
|
|
192
412
|
/** Stubs the cuts write that the folder's record says it does not hold. */
|
|
193
|
-
async function neededStubs(root, record, cuts, write) {
|
|
413
|
+
async function neededStubs(root, record, cuts, write, names, save) {
|
|
194
414
|
const generated = new Map(record.generated.map((file) => [file.path, file.sha256]));
|
|
195
415
|
const wanted = new Map();
|
|
196
416
|
for (const cut of cuts)
|
|
@@ -207,7 +427,8 @@ async function neededStubs(root, record, cuts, write) {
|
|
|
207
427
|
// The declaration names the difference; its runtime follows it.
|
|
208
428
|
if (!/\.d\.[cm]?ts$/.test(path))
|
|
209
429
|
continue;
|
|
210
|
-
const
|
|
430
|
+
const held = join(root, names.path(path));
|
|
431
|
+
const old = existsSync(held) ? names.back(names.path(path), readFileSync(held, "utf8")) : null;
|
|
211
432
|
found.push({
|
|
212
433
|
path,
|
|
213
434
|
detail: was === undefined || old === null
|
|
@@ -218,8 +439,8 @@ async function neededStubs(root, record, cuts, write) {
|
|
|
218
439
|
}
|
|
219
440
|
if (write && writes.length > 0) {
|
|
220
441
|
for (const { path, text } of writes) {
|
|
221
|
-
await mkdir(dirname(join(root, path)), { recursive: true });
|
|
222
|
-
await writeFile(join(root, path), text);
|
|
442
|
+
await mkdir(dirname(join(root, names.path(path))), { recursive: true });
|
|
443
|
+
await writeFile(join(root, names.path(path)), names.text(path, text));
|
|
223
444
|
}
|
|
224
445
|
const hashes = new Map(generated);
|
|
225
446
|
for (const { path, text } of writes)
|
|
@@ -230,10 +451,53 @@ async function neededStubs(root, record, cuts, write) {
|
|
|
230
451
|
.map(([path, hash]) => ({ path, sha256: hash }))
|
|
231
452
|
.sort((a, b) => compare(a.path, b.path)),
|
|
232
453
|
};
|
|
233
|
-
|
|
454
|
+
save(updated, Object.fromEntries(writes.map(({ path, text }) => [names.path(path), names.text(path, text)])));
|
|
234
455
|
}
|
|
235
456
|
return found;
|
|
236
457
|
}
|
|
458
|
+
/**
|
|
459
|
+
* The checkout's tracked and unignored files, copied to `to`: what a
|
|
460
|
+
* sandboxed verify mounts. A symbolic link is copied as a link, which in the
|
|
461
|
+
* container reaches nothing outside the copy.
|
|
462
|
+
*/
|
|
463
|
+
function copyCheckout(from, to) {
|
|
464
|
+
const listed = execFileSync("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
|
|
465
|
+
cwd: from,
|
|
466
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
467
|
+
}).toString("utf8");
|
|
468
|
+
for (const path of new Set(listed.split("\0"))) {
|
|
469
|
+
if (path === "")
|
|
470
|
+
continue;
|
|
471
|
+
const source = join(from, path);
|
|
472
|
+
let stat;
|
|
473
|
+
try {
|
|
474
|
+
stat = lstatSync(source);
|
|
475
|
+
}
|
|
476
|
+
catch {
|
|
477
|
+
continue; // Listed, deleted from the working tree.
|
|
478
|
+
}
|
|
479
|
+
const target = join(to, path);
|
|
480
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
481
|
+
if (stat.isSymbolicLink())
|
|
482
|
+
symlinkSync(readlinkSync(source), target);
|
|
483
|
+
else if (stat.isFile())
|
|
484
|
+
copyFileSync(source, target);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
/** The install a lockfile names, lifecycle scripts off; null with no package.json. */
|
|
488
|
+
export function installCommand(root) {
|
|
489
|
+
if (existsSync(join(root, "package-lock.json")) || existsSync(join(root, "npm-shrinkwrap.json")))
|
|
490
|
+
return "npm ci --ignore-scripts --no-audit --no-fund";
|
|
491
|
+
if (existsSync(join(root, "pnpm-lock.yaml")))
|
|
492
|
+
return "corepack pnpm install --frozen-lockfile --ignore-scripts";
|
|
493
|
+
if (existsSync(join(root, "yarn.lock")))
|
|
494
|
+
return existsSync(join(root, ".yarnrc.yml"))
|
|
495
|
+
? "corepack yarn install --immutable --mode=skip-build"
|
|
496
|
+
: "corepack yarn install --frozen-lockfile --ignore-scripts";
|
|
497
|
+
if (existsSync(join(root, "package.json")))
|
|
498
|
+
return "npm install --ignore-scripts --no-audit --no-fund";
|
|
499
|
+
return null;
|
|
500
|
+
}
|
|
237
501
|
/**
|
|
238
502
|
* The folder's edits merged into `into`, a checkout of the repository the
|
|
239
503
|
* caller chose: `git apply --3way` there, then `verify` as a shell command
|
|
@@ -246,6 +510,8 @@ export async function mergeProject(dir, options) {
|
|
|
246
510
|
const patch = await patchProject(dir, {
|
|
247
511
|
repo: options.repo ?? into,
|
|
248
512
|
...(options.allow === undefined ? {} : { allow: options.allow }),
|
|
513
|
+
...(options.receipt === undefined ? {} : { receipt: options.receipt }),
|
|
514
|
+
...(options.noReceipt === true ? { noReceipt: true } : {}),
|
|
249
515
|
});
|
|
250
516
|
const steps = [];
|
|
251
517
|
// Nothing is applied while the policy keeps a path out: the maintainer
|
|
@@ -295,17 +561,46 @@ export async function mergeProject(dir, options) {
|
|
|
295
561
|
if (value !== undefined)
|
|
296
562
|
env[name] = value;
|
|
297
563
|
}
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
564
|
+
if (options.sandbox !== undefined) {
|
|
565
|
+
// A copy of the checkout as the patch left it, so nothing of this
|
|
566
|
+
// machine but that copy is mounted: tracked and unignored files.
|
|
567
|
+
const work = join(scratch, "checkout");
|
|
568
|
+
copyCheckout(into, work);
|
|
569
|
+
const setupCommand = options.verifySetup ?? installCommand(work);
|
|
570
|
+
if (setupCommand !== null) {
|
|
571
|
+
const setup = await sandboxRun(options.sandbox, work, ["sh", "-c", setupCommand], { network: true, limitMs: 20 * 60_000 });
|
|
572
|
+
steps.push({
|
|
573
|
+
step: "setup",
|
|
574
|
+
ok: setup.status === 0,
|
|
575
|
+
output: `${setupCommand}\n${setup.stdout}${setup.stderr}`.slice(-20_000),
|
|
576
|
+
});
|
|
577
|
+
if (setup.status !== 0)
|
|
578
|
+
return { patch, steps, ok: false };
|
|
579
|
+
}
|
|
580
|
+
const passed = {};
|
|
581
|
+
for (const [key, value] of Object.entries(env))
|
|
582
|
+
if (key !== "HOME" && key !== "PATH")
|
|
583
|
+
passed[key] = value;
|
|
584
|
+
const verify = await sandboxRun(options.sandbox, work, ["sh", "-c", options.verify], { env: passed, network: false, limitMs: 30 * 60_000 });
|
|
585
|
+
steps.push({
|
|
586
|
+
step: "verify",
|
|
587
|
+
ok: verify.status === 0,
|
|
588
|
+
output: `${verify.stdout}${verify.stderr}`.slice(-20_000),
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
else {
|
|
592
|
+
const verify = spawnSync("sh", ["-c", options.verify], {
|
|
593
|
+
cwd: into,
|
|
594
|
+
encoding: "utf8",
|
|
595
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
596
|
+
env,
|
|
597
|
+
});
|
|
598
|
+
steps.push({
|
|
599
|
+
step: "verify",
|
|
600
|
+
ok: verify.status === 0,
|
|
601
|
+
output: `${verify.stdout ?? ""}${verify.stderr ?? ""}`.slice(-20_000),
|
|
602
|
+
});
|
|
603
|
+
}
|
|
309
604
|
}
|
|
310
605
|
}
|
|
311
606
|
finally {
|
|
@@ -313,3 +608,253 @@ export async function mergeProject(dir, options) {
|
|
|
313
608
|
}
|
|
314
609
|
return { patch, steps, ok: steps.every((step) => step.ok) };
|
|
315
610
|
}
|
|
611
|
+
/**
|
|
612
|
+
* A folder cut again, wider, with the contributor's work in it: a change
|
|
613
|
+
* that turns out to need a module the slice cut. The work so far is taken
|
|
614
|
+
* as a patch (`patchProject`: none of its code runs, and a file the policy
|
|
615
|
+
* holds stops the widening), the cut is made again with the entries added
|
|
616
|
+
* and every other bound as it was, its pseudonyms keep the placeholders the
|
|
617
|
+
* folder had where they fit, and the work is applied to the new folder in
|
|
618
|
+
* its names. The old folder is left as it was.
|
|
619
|
+
*/
|
|
620
|
+
export async function widenProject(dir, options) {
|
|
621
|
+
const root = resolve(dir);
|
|
622
|
+
const trusted = trustedCut(root, options.receipt, options.noReceipt === true ? { noReceipt: true } : {});
|
|
623
|
+
const repo = trustedRepo(trusted.record, options.repo);
|
|
624
|
+
const patch = await patchProject(root, {
|
|
625
|
+
repo,
|
|
626
|
+
...(options.receipt === undefined ? {} : { receipt: options.receipt }),
|
|
627
|
+
...(options.noReceipt === true ? { noReceipt: true } : {}),
|
|
628
|
+
...(options.allow === undefined ? {} : { allow: options.allow }),
|
|
629
|
+
});
|
|
630
|
+
if (patch.refused.length > 0)
|
|
631
|
+
throw new Error(`The folder's work holds what the hand-off policy keeps out, which a wider folder would carry on:\n${patch.refused
|
|
632
|
+
.map((refusal) => ` ${refusal.path}: ${refusal.reason}`)
|
|
633
|
+
.join("\n")}\nRead those files, and pass --allow for the ones you accept.`);
|
|
634
|
+
const request = trusted.record.request;
|
|
635
|
+
const out = resolve(options.out ?? `${root}-wide`);
|
|
636
|
+
const cut = await cutSlice({
|
|
637
|
+
repo,
|
|
638
|
+
out,
|
|
639
|
+
entryPoints: [...new Set([...request.entryPoints, ...options.entries])],
|
|
640
|
+
budget: {
|
|
641
|
+
maxFiles: options.maxFiles ?? request.budget.maxFiles,
|
|
642
|
+
maxDepth: options.maxDepth ?? request.budget.maxDepth,
|
|
643
|
+
},
|
|
644
|
+
choices: request.choices,
|
|
645
|
+
offline: request.offline,
|
|
646
|
+
widenLiterals: request.widenLiterals,
|
|
647
|
+
tests: request.tests === true,
|
|
648
|
+
...(request.live === undefined ? {} : { live: request.live }),
|
|
649
|
+
wholePackage: request.wholePackage === true,
|
|
650
|
+
followWorkspaces: request.followWorkspaces === true,
|
|
651
|
+
copyPure: request.copyPure === true,
|
|
652
|
+
private: request.private ?? [],
|
|
653
|
+
copy: request.copy ?? [],
|
|
654
|
+
public: request.public === true,
|
|
655
|
+
...(request.task === undefined ? {} : { task: request.task }),
|
|
656
|
+
...(trusted.receipt?.pseudonyms === undefined ? {} : { earlierPseudonyms: trusted.receipt.pseudonyms }),
|
|
657
|
+
...(trusted.receipt?.unseen === undefined ? {} : { unseen: trusted.receipt.unseen }),
|
|
658
|
+
...(trusted.receipt === null ? { receipt: false } : {}),
|
|
659
|
+
...(options.log === undefined ? {} : { log: options.log }),
|
|
660
|
+
});
|
|
661
|
+
const applied = patch.diff === ""
|
|
662
|
+
? null
|
|
663
|
+
: await applyPatch(out, patch.diff, {
|
|
664
|
+
repo,
|
|
665
|
+
...(cut.receipt === null ? { noReceipt: true } : { receipt: cut.receipt }),
|
|
666
|
+
...(options.log === undefined ? {} : { log: options.log }),
|
|
667
|
+
});
|
|
668
|
+
return { out, cut, patch, applied };
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* The repository's commits since the cut, brought into the folder: the
|
|
672
|
+
* change from the cut's commit to the checkout's HEAD in the packages the
|
|
673
|
+
* slice holds files of and in the modules it stubs, applied as `apply`
|
|
674
|
+
* applies a patch (the folder's own edits merged, an overlap left for a
|
|
675
|
+
* person, the stubs the change needs written). For a job long enough that
|
|
676
|
+
* the repository moves under it. Null when nothing it reads has moved.
|
|
677
|
+
*/
|
|
678
|
+
export async function refreshProject(dir, options = {}) {
|
|
679
|
+
const root = resolve(dir);
|
|
680
|
+
const trusted = trustedCut(root, options.receipt, options.noReceipt === true ? { noReceipt: true } : {});
|
|
681
|
+
const record = trusted.record;
|
|
682
|
+
const repo = trustedRepo(record, options.repo);
|
|
683
|
+
const from = record.manifest.sourceCommitSha;
|
|
684
|
+
if (from === null)
|
|
685
|
+
throw new Error("The cut was not made in a Git checkout: there is no commit to refresh from.");
|
|
686
|
+
const to = git(repo, ["rev-parse", "HEAD"]).trim();
|
|
687
|
+
const packages = [...new Set(record.manifest.included.map((file) => packageDirOf(repo, file.path)))];
|
|
688
|
+
const stubbed = [...new Set(record.manifest.cuts.outbound.map((edge) => edge.to))];
|
|
689
|
+
// And the copied folders' files, which may sit outside those packages.
|
|
690
|
+
const copiedFolders = (record.extras ?? []).map((file) => file.path);
|
|
691
|
+
const paths = [...new Set([...packages.map((dir) => (dir === "" ? "." : dir)), ...stubbed, ...copiedFolders])].sort(compare);
|
|
692
|
+
const resolved = [...new Set(options.resolved ?? [])];
|
|
693
|
+
// Only the slice's own files and the copied folders': a path outside
|
|
694
|
+
// would join the record, and the next patch would delete it.
|
|
695
|
+
const sliceFiles = new Set([...record.manifest.included.map((file) => file.path), ...copiedFolders]);
|
|
696
|
+
const outsideSlice = resolved.filter((path) => !sliceFiles.has(path));
|
|
697
|
+
if (outsideSlice.length > 0)
|
|
698
|
+
throw new Error(`--resolved takes the slice's own files and its copied folders'; these are not: ${outsideSlice.join(", ")}.`);
|
|
699
|
+
const change = git(repo, ["diff", "--binary", "--full-index", from, to, "--", ...paths, ...resolved.map((path) => `:(exclude)${path}`)]);
|
|
700
|
+
if (change.trim() === "" && resolved.length === 0)
|
|
701
|
+
return null;
|
|
702
|
+
const written = new Map();
|
|
703
|
+
const applied = change.trim() === ""
|
|
704
|
+
? { changed: [], added: [], deleted: [], outside: [], stubs: [], merged: [], conflicts: [], leaks: [], unmapped: [] }
|
|
705
|
+
: await applyPatch(root, change, {
|
|
706
|
+
stubs: true,
|
|
707
|
+
repo,
|
|
708
|
+
written,
|
|
709
|
+
...(options.receipt === undefined ? {} : { receipt: options.receipt }),
|
|
710
|
+
...(options.noReceipt === true ? { noReceipt: true } : {}),
|
|
711
|
+
...(options.log === undefined ? {} : { log: options.log }),
|
|
712
|
+
});
|
|
713
|
+
// A file resolved by hand moves to the new base as the folder holds it:
|
|
714
|
+
// the repository's bytes there are its base, the folder's text an edit.
|
|
715
|
+
// One whose text leaves out some of the repository's change is named.
|
|
716
|
+
const table = trusted.receipt?.pseudonyms;
|
|
717
|
+
const droppedUpstream = [];
|
|
718
|
+
const overlapResolved = [];
|
|
719
|
+
const mergeScratch = resolved.length === 0 ? null : mkdtempSync(join(tmpdir(), "carveout-resolved-"));
|
|
720
|
+
for (const path of resolved) {
|
|
721
|
+
try {
|
|
722
|
+
const baseCommit = record.bases?.[path] ?? from;
|
|
723
|
+
const show = (commit) => execFileSync("git", ["show", `${commit}:${path}`], { cwd: repo, maxBuffer: 256 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] }).toString("utf8");
|
|
724
|
+
const folderPathOf = table === undefined ? path : (exactToFolder(table, path, "")?.path ?? toFolder(table, path, "").path);
|
|
725
|
+
const folderText = readFileSync(join(root, folderPathOf), "utf8");
|
|
726
|
+
const ours = table === undefined ? folderText : fromFolder(table, folderPathOf, folderText).text;
|
|
727
|
+
const merged = mergeText(mergeScratch ?? tmpdir(), ours, show(baseCommit), show(to));
|
|
728
|
+
// Clean, a merge shows what the folder's text leaves out; over the
|
|
729
|
+
// same lines it cannot, and the person's merge stands, named.
|
|
730
|
+
if (merged.conflicts)
|
|
731
|
+
overlapResolved.push(path);
|
|
732
|
+
else if (merged.text !== ours)
|
|
733
|
+
droppedUpstream.push(path);
|
|
734
|
+
}
|
|
735
|
+
catch {
|
|
736
|
+
// A file the commit deleted, or the folder did: nothing to compare.
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
if (mergeScratch !== null)
|
|
740
|
+
await rm(mergeScratch, { recursive: true, force: true });
|
|
741
|
+
/** Copied files the new commit deleted: no longer the repository's copies. */
|
|
742
|
+
const copiedGone = [];
|
|
743
|
+
for (const path of resolved) {
|
|
744
|
+
let bytes;
|
|
745
|
+
try {
|
|
746
|
+
bytes = execFileSync("git", ["show", `${to}:${path}`], { cwd: repo, maxBuffer: 256 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] });
|
|
747
|
+
}
|
|
748
|
+
catch {
|
|
749
|
+
// A copied file the repository deleted, taken as resolved: what the
|
|
750
|
+
// folder holds of it is the folder's own, a new file to a patch.
|
|
751
|
+
if (copiedFolders.includes(path))
|
|
752
|
+
copiedGone.push(path);
|
|
753
|
+
continue;
|
|
754
|
+
}
|
|
755
|
+
const folderPath = table === undefined ? path : (exactToFolder(table, path, "")?.path ?? toFolder(table, path, "").path);
|
|
756
|
+
// The folder's record holds hashes in the folder's names, never the
|
|
757
|
+
// repository's (which would confirm a guess at a pseudonym).
|
|
758
|
+
const text = bytes.toString("utf8");
|
|
759
|
+
const folderText = table === undefined ? text : (exactToFolder(table, path, text)?.text ?? toFolder(table, path, text).text);
|
|
760
|
+
written.set(path, { folderPath, sha256: sha256(bytes), folderSha256: sha256(folderText) });
|
|
761
|
+
}
|
|
762
|
+
// The repository holds these commits: the folder's base moves to them, so
|
|
763
|
+
// a patch of it carries the contributor's work alone, not the commits it
|
|
764
|
+
// was brought (and `git apply` takes it at the new HEAD). Each file
|
|
765
|
+
// written moves on its own (patch reads its base from the checkout, whose
|
|
766
|
+
// bytes it has); the commit moves only when nothing was left at the old
|
|
767
|
+
// base, an overlap or a file that would not map back to the folder's
|
|
768
|
+
// names.
|
|
769
|
+
// And only when every slice file the commits changed was written or
|
|
770
|
+
// deleted: one the cut no longer reaches, or a copied folder's, keeps the
|
|
771
|
+
// bytes of the old base, which the new commit does not hold.
|
|
772
|
+
const changedNames = git(repo, ["diff", "--name-only", from, to, "--", ...paths]).split("\n").filter((name) => name !== "");
|
|
773
|
+
// A copied file the folder never edited takes the repository's bytes as
|
|
774
|
+
// they are: nothing of the folder's to merge. In pseudonyms, where the
|
|
775
|
+
// bytes would need renaming, it is left to the person, named.
|
|
776
|
+
const copiedTaken = [];
|
|
777
|
+
if (table === undefined) {
|
|
778
|
+
const copiedHashes = new Map((trusted.record.extras ?? []).map((file) => [file.path, file.sha256]));
|
|
779
|
+
for (const name of changedNames) {
|
|
780
|
+
const original = copiedHashes.get(name);
|
|
781
|
+
const at = join(root, name);
|
|
782
|
+
if (original === undefined || written.has(name) || !existsSync(at) || sha256(readFileSync(at)) !== original)
|
|
783
|
+
continue;
|
|
784
|
+
let bytes;
|
|
785
|
+
try {
|
|
786
|
+
bytes = execFileSync("git", ["show", `${to}:${name}`], { cwd: repo, maxBuffer: 256 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] });
|
|
787
|
+
}
|
|
788
|
+
catch {
|
|
789
|
+
// Deleted upstream, and never edited here: deleted here too.
|
|
790
|
+
await rm(at, { force: true });
|
|
791
|
+
copiedGone.push(name);
|
|
792
|
+
copiedTaken.push(name);
|
|
793
|
+
continue;
|
|
794
|
+
}
|
|
795
|
+
writeFileSync(at, bytes);
|
|
796
|
+
written.set(name, { folderPath: name, sha256: sha256(bytes), folderSha256: sha256(bytes) });
|
|
797
|
+
copiedTaken.push(name);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
const held = new Set([
|
|
801
|
+
...trusted.record.manifest.included.map((file) => file.path),
|
|
802
|
+
...(trusted.record.extras ?? []).map((file) => file.path),
|
|
803
|
+
]);
|
|
804
|
+
const left = changedNames.filter((name) => held.has(name) && !written.has(name) && !applied.deleted.includes(name) && !copiedGone.includes(name));
|
|
805
|
+
// A copied folder's file the repository changed is never written over
|
|
806
|
+
// what the folder holds: named, to bring by hand and pass as resolved.
|
|
807
|
+
const copiedSet = new Set(copiedFolders);
|
|
808
|
+
const copiedLeft = left.filter((name) => copiedSet.has(name));
|
|
809
|
+
const whole = applied.conflicts.length === 0 && applied.unmapped.length === 0 && left.length === 0;
|
|
810
|
+
if (written.size > 0 || whole || copiedGone.length > 0) {
|
|
811
|
+
const now = trustedCut(root, options.receipt, options.noReceipt === true ? { noReceipt: true } : {});
|
|
812
|
+
const gone = new Set(applied.deleted);
|
|
813
|
+
const known = new Set(now.record.manifest.included.map((file) => file.path));
|
|
814
|
+
const atTo = (path) => {
|
|
815
|
+
const bytes = execFileSync("git", ["show", `${to}:${path}`], { cwd: repo, maxBuffer: 256 * 1024 * 1024 });
|
|
816
|
+
const [mode = "100644", , blobId = ""] = git(repo, ["ls-tree", to, "--", path]).trim().split(/\s+/);
|
|
817
|
+
return { path, blobId, sha256: sha256(bytes), mode, sizeBytes: bytes.length, operations: ["edit"] };
|
|
818
|
+
};
|
|
819
|
+
// A copied folder's file stays one, at the new base's bytes: patch
|
|
820
|
+
// reads its base by the hash `extras` keeps.
|
|
821
|
+
const extrasPaths = new Set((now.record.extras ?? []).map((file) => file.path));
|
|
822
|
+
const included = [
|
|
823
|
+
...now.record.manifest.included.filter((file) => !gone.has(file.path)).map((file) => (written.has(file.path) ? { ...file, ...atTo(file.path) } : file)),
|
|
824
|
+
...[...written.keys()].filter((path) => !known.has(path) && !extrasPaths.has(path)).map(atTo),
|
|
825
|
+
].sort((a, b) => compare(a.path, b.path));
|
|
826
|
+
const extras = now.record.extras
|
|
827
|
+
?.filter((file) => !gone.has(file.path) && !copiedGone.includes(file.path))
|
|
828
|
+
.map((file) => {
|
|
829
|
+
const moved = written.get(file.path);
|
|
830
|
+
return moved === undefined ? file : { path: file.path, sha256: moved.sha256 };
|
|
831
|
+
});
|
|
832
|
+
// A file refresh wrote is the repository's at the new base; any other
|
|
833
|
+
// keeps what it was, a patch's mark included.
|
|
834
|
+
const copiedNow = new Map((now.record.copied ?? []).filter((file) => !gone.has(file.path)).map((file) => [file.path, file]));
|
|
835
|
+
for (const [path, { sha256: hash }] of written)
|
|
836
|
+
copiedNow.set(path, { path, sha256: hash });
|
|
837
|
+
// Each file written moved to the new commit; the record's commit too
|
|
838
|
+
// when whole, when no file is left behind it.
|
|
839
|
+
const bases = whole
|
|
840
|
+
? undefined
|
|
841
|
+
: { ...(now.record.bases ?? {}), ...Object.fromEntries([...written.keys()].map((path) => [path, to])) };
|
|
842
|
+
const updated = {
|
|
843
|
+
...now.record,
|
|
844
|
+
manifest: { ...now.record.manifest, ...(whole ? { sourceCommitSha: to } : {}), included },
|
|
845
|
+
...(extras === undefined ? {} : { extras }),
|
|
846
|
+
...(bases === undefined ? { bases: undefined } : { bases }),
|
|
847
|
+
...(now.record.copied === undefined ? {} : { copied: [...copiedNow.values()].sort((a, b) => compare(a.path, b.path)) }),
|
|
848
|
+
};
|
|
849
|
+
saveRecord(root, now, updated, {}, new Map([...written.values()].map(({ folderPath, folderSha256 }) => [folderPath, folderSha256])));
|
|
850
|
+
}
|
|
851
|
+
return {
|
|
852
|
+
from,
|
|
853
|
+
to,
|
|
854
|
+
applied,
|
|
855
|
+
...(droppedUpstream.length === 0 ? {} : { droppedUpstream }),
|
|
856
|
+
...(overlapResolved.length === 0 ? {} : { overlapResolved }),
|
|
857
|
+
...(copiedLeft.length === 0 ? {} : { copiedLeft }),
|
|
858
|
+
...(copiedTaken.length === 0 ? {} : { copiedTaken }),
|
|
859
|
+
};
|
|
860
|
+
}
|