@solaqua/gji 0.12.0 → 0.12.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/dist/bootstrap-output.js +0 -1
- package/dist/dependency-bootstrap.d.ts +10 -17
- package/dist/dependency-bootstrap.js +343 -108
- package/dist/dir-clone.d.ts +1 -2
- package/dist/dir-clone.js +303 -167
- package/dist/gji-bundle.mjs +985 -799
- package/dist/hub.js +13 -1
- package/dist/safe-destination.js +10 -0
- package/dist/sync-directories.d.ts +0 -4
- package/dist/sync-directories.js +6 -26
- package/dist/worktree-bootstrap.js +0 -2
- package/dist/worktree-picker.js +13 -0
- package/man/man1/gji-back.1 +1 -1
- package/man/man1/gji-clean.1 +1 -1
- package/man/man1/gji-completion.1 +1 -1
- package/man/man1/gji-config.1 +1 -1
- package/man/man1/gji-doctor.1 +1 -1
- package/man/man1/gji-done.1 +1 -1
- package/man/man1/gji-go.1 +1 -1
- package/man/man1/gji-history.1 +1 -1
- package/man/man1/gji-init.1 +1 -1
- package/man/man1/gji-ls.1 +1 -1
- package/man/man1/gji-new.1 +1 -1
- package/man/man1/gji-open.1 +1 -1
- package/man/man1/gji-pr.1 +1 -1
- package/man/man1/gji-remove.1 +1 -1
- package/man/man1/gji-root.1 +1 -1
- package/man/man1/gji-run-hook.1 +1 -1
- package/man/man1/gji-status.1 +1 -1
- package/man/man1/gji-sync-files.1 +1 -1
- package/man/man1/gji-sync.1 +1 -1
- package/man/man1/gji-task.1 +1 -1
- package/man/man1/gji-undo.1 +1 -1
- package/man/man1/gji-warp.1 +1 -1
- package/man/man1/gji.1 +1 -1
- package/package.json +1 -1
- package/dist/clone-failure-store.d.ts +0 -18
- package/dist/clone-failure-store.js +0 -230
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { lstat, open, readdir, readFile, realpath, rm } from "node:fs/promises";
|
|
4
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep, } from "node:path";
|
|
4
5
|
import { promisify } from "node:util";
|
|
5
|
-
import { cloneFailureScope, defaultCloneFailureStore, } from "./clone-failure-store.js";
|
|
6
6
|
import { runCommand } from "./command-runner.js";
|
|
7
|
-
import { cloneDir, isCloneDestinationExistsError, isCloneInProgressError,
|
|
8
|
-
import { pathExists } from "./fs-utils.js";
|
|
7
|
+
import { cloneDir, isCloneDestinationExistsError, isCloneInProgressError, } from "./dir-clone.js";
|
|
8
|
+
import { isNotFoundError, pathExists } from "./fs-utils.js";
|
|
9
9
|
import { inspectDestination } from "./safe-destination.js";
|
|
10
10
|
const execFileAsync = promisify(execFile);
|
|
11
|
+
const UV_VALIDATION_MAX_ENTRIES = 10_000;
|
|
12
|
+
const UV_VALIDATION_MAX_FILE_BYTES = 1024 * 1024;
|
|
13
|
+
const UV_VALIDATION_MAX_TOTAL_BYTES = 16 * 1024 * 1024;
|
|
11
14
|
export async function prepareDependencyBootstrap(mode, context) {
|
|
12
15
|
if (mode === "off")
|
|
13
16
|
return { mode, targets: [] };
|
|
@@ -26,7 +29,7 @@ export async function prepareDependencyBootstrap(mode, context) {
|
|
|
26
29
|
for (const adapter of adapters) {
|
|
27
30
|
if (plannedRelativePaths.has(adapter.relativePath))
|
|
28
31
|
continue;
|
|
29
|
-
let
|
|
32
|
+
let firstDetected;
|
|
30
33
|
let selected;
|
|
31
34
|
for (const sourceRoot of sourceRoots) {
|
|
32
35
|
const target = await adapter.detect({
|
|
@@ -37,14 +40,14 @@ export async function prepareDependencyBootstrap(mode, context) {
|
|
|
37
40
|
if (!target)
|
|
38
41
|
continue;
|
|
39
42
|
const seedable = mode !== "install-only" && (await adapter.canSeed(target));
|
|
40
|
-
const candidate = {
|
|
41
|
-
|
|
43
|
+
const candidate = { target, seedable };
|
|
44
|
+
firstDetected ??= candidate;
|
|
42
45
|
if (mode === "install-only" || seedable) {
|
|
43
46
|
selected = candidate;
|
|
44
47
|
break;
|
|
45
48
|
}
|
|
46
49
|
}
|
|
47
|
-
const plannedTarget = selected ??
|
|
50
|
+
const plannedTarget = selected ?? firstDetected;
|
|
48
51
|
if (plannedTarget) {
|
|
49
52
|
targets.push(plannedTarget);
|
|
50
53
|
plannedRelativePaths.add(adapter.relativePath);
|
|
@@ -58,9 +61,9 @@ export async function detectDependencyBootstrapCandidate(context) {
|
|
|
58
61
|
if (!planned)
|
|
59
62
|
return null;
|
|
60
63
|
return {
|
|
61
|
-
adapter: planned.adapter
|
|
62
|
-
kind: planned.
|
|
63
|
-
lockfile: planned.
|
|
64
|
+
adapter: planned.target.adapter,
|
|
65
|
+
kind: planned.target.kind,
|
|
66
|
+
lockfile: adapterForTarget(planned.target).lockfile,
|
|
64
67
|
target: planned.target.relativePath,
|
|
65
68
|
repairCommand: planned.target.repairCommand,
|
|
66
69
|
seedable: planned.seedable,
|
|
@@ -69,10 +72,10 @@ export async function detectDependencyBootstrapCandidate(context) {
|
|
|
69
72
|
export function previewDependencyBootstrap(plan) {
|
|
70
73
|
return {
|
|
71
74
|
mode: plan.mode,
|
|
72
|
-
targets: plan.targets.map(({
|
|
73
|
-
adapter:
|
|
74
|
-
kind:
|
|
75
|
-
target:
|
|
75
|
+
targets: plan.targets.map(({ target, seedable }) => ({
|
|
76
|
+
adapter: target.adapter,
|
|
77
|
+
kind: target.kind,
|
|
78
|
+
target: target.relativePath,
|
|
76
79
|
repairCommand: target.repairCommand,
|
|
77
80
|
seedable,
|
|
78
81
|
strategy: bootstrapStrategy(plan.mode, target, seedable),
|
|
@@ -83,7 +86,6 @@ export async function executeDependencyBootstrap(plan, options) {
|
|
|
83
86
|
if (plan.mode === "off")
|
|
84
87
|
return { mode: plan.mode, ready: true, events: [] };
|
|
85
88
|
const events = [];
|
|
86
|
-
const failureStore = options.failureStore ?? defaultCloneFailureStore;
|
|
87
89
|
const cloneDirectory = options.cloneDirectory ?? cloneDir;
|
|
88
90
|
const execution = {
|
|
89
91
|
runCommand: options.runCommand ?? runCommand,
|
|
@@ -102,8 +104,9 @@ export async function executeDependencyBootstrap(plan, options) {
|
|
|
102
104
|
});
|
|
103
105
|
return { mode: plan.mode, ready: true, events };
|
|
104
106
|
}
|
|
105
|
-
for (const {
|
|
106
|
-
|
|
107
|
+
for (const { target, seedable } of plan.targets) {
|
|
108
|
+
const adapter = adapterForTarget(target);
|
|
109
|
+
await executeBootstrapTarget(adapter, target, seedable, plan.mode, cloneDirectory, execution, seededDirectories.has(target.relativePath), events, options.reporter);
|
|
107
110
|
}
|
|
108
111
|
return {
|
|
109
112
|
mode: plan.mode,
|
|
@@ -111,25 +114,19 @@ export async function executeDependencyBootstrap(plan, options) {
|
|
|
111
114
|
events,
|
|
112
115
|
};
|
|
113
116
|
}
|
|
114
|
-
async function executeBootstrapTarget(adapter, target, seedable, mode, cloneDirectory,
|
|
117
|
+
async function executeBootstrapTarget(adapter, target, seedable, mode, cloneDirectory, execution, seededBySyncDirs, events, reporter) {
|
|
115
118
|
const destinationInspection = await inspectDestination(target.worktreePath, target.targetPath);
|
|
116
119
|
if (destinationInspection.kind === "unsafe") {
|
|
117
120
|
recordBootstrapFailure(events, reporter, adapter, target, destinationInspection.reason, "destination-unsafe");
|
|
118
121
|
return;
|
|
119
122
|
}
|
|
120
123
|
if (mode === "install-only") {
|
|
121
|
-
await repairTarget(adapter, target, execution,
|
|
124
|
+
await repairTarget(adapter, target, execution, destinationInspection.kind === "exists" ? "preserve" : "clean", events, reporter);
|
|
122
125
|
return;
|
|
123
126
|
}
|
|
124
|
-
let
|
|
125
|
-
const failureScope = seedable
|
|
126
|
-
? await cloneFailureScope(adapter.seedPath(target), target.targetPath)
|
|
127
|
-
: undefined;
|
|
128
|
-
let ownership = target.existingBeforeBootstrap
|
|
129
|
-
? "preserve"
|
|
130
|
-
: "empty";
|
|
127
|
+
let input = destinationInspection.kind === "exists" ? "preserve" : "clean";
|
|
131
128
|
const destinationExistsNow = destinationInspection.kind === "exists";
|
|
132
|
-
if (target.
|
|
129
|
+
if (target.initialInput === "preserve" && destinationExistsNow) {
|
|
133
130
|
recordBootstrapEvent(events, reporter, {
|
|
134
131
|
adapter: adapter.name,
|
|
135
132
|
kind: adapter.kind,
|
|
@@ -141,8 +138,7 @@ async function executeBootstrapTarget(adapter, target, seedable, mode, cloneDire
|
|
|
141
138
|
}
|
|
142
139
|
else if (seededBySyncDirs && destinationExistsNow) {
|
|
143
140
|
if (seedable) {
|
|
144
|
-
|
|
145
|
-
ownership = "syncDirs";
|
|
141
|
+
input = "seed";
|
|
146
142
|
recordBootstrapEvent(events, reporter, {
|
|
147
143
|
adapter: adapter.name,
|
|
148
144
|
kind: adapter.kind,
|
|
@@ -153,19 +149,19 @@ async function executeBootstrapTarget(adapter, target, seedable, mode, cloneDire
|
|
|
153
149
|
});
|
|
154
150
|
}
|
|
155
151
|
else {
|
|
156
|
-
|
|
152
|
+
input = "preserve";
|
|
157
153
|
recordBootstrapEvent(events, reporter, {
|
|
158
154
|
adapter: adapter.name,
|
|
159
155
|
kind: adapter.kind,
|
|
160
156
|
reason: "generic-seed",
|
|
161
|
-
state: "
|
|
157
|
+
state: "repair-only",
|
|
162
158
|
target: target.relativePath,
|
|
163
159
|
message: "syncDirs created a generic target; this adapter uses repair without CoW",
|
|
164
160
|
});
|
|
165
161
|
}
|
|
166
162
|
}
|
|
167
163
|
else if (destinationExistsNow) {
|
|
168
|
-
|
|
164
|
+
input = "preserve";
|
|
169
165
|
recordBootstrapEvent(events, reporter, {
|
|
170
166
|
adapter: adapter.name,
|
|
171
167
|
kind: adapter.kind,
|
|
@@ -175,26 +171,13 @@ async function executeBootstrapTarget(adapter, target, seedable, mode, cloneDire
|
|
|
175
171
|
message: "target appeared during bootstrap; preserving it as the repair input",
|
|
176
172
|
});
|
|
177
173
|
}
|
|
178
|
-
else if (seedable &&
|
|
179
|
-
(await failureStore.isCached(repoRoot, target.relativePath, failureScope))) {
|
|
180
|
-
recordBootstrapEvent(events, reporter, {
|
|
181
|
-
adapter: adapter.name,
|
|
182
|
-
kind: adapter.kind,
|
|
183
|
-
reason: "cow-failure-cached",
|
|
184
|
-
state: "fallback",
|
|
185
|
-
target: target.relativePath,
|
|
186
|
-
message: "previous CoW failure is cached; repairing from an empty target",
|
|
187
|
-
});
|
|
188
|
-
}
|
|
189
174
|
else if (seedable) {
|
|
190
175
|
try {
|
|
191
176
|
await cloneDirectory(adapter.seedPath(target), target.targetPath, {
|
|
192
177
|
destinationRoot: target.worktreePath,
|
|
193
178
|
measureBytes: reporter.measureCloneSize,
|
|
194
179
|
});
|
|
195
|
-
|
|
196
|
-
seeded = true;
|
|
197
|
-
ownership = "adapter";
|
|
180
|
+
input = "seed";
|
|
198
181
|
recordBootstrapEvent(events, reporter, {
|
|
199
182
|
adapter: adapter.name,
|
|
200
183
|
kind: adapter.kind,
|
|
@@ -205,7 +188,7 @@ async function executeBootstrapTarget(adapter, target, seedable, mode, cloneDire
|
|
|
205
188
|
}
|
|
206
189
|
catch (error) {
|
|
207
190
|
if (isCloneDestinationExistsError(error)) {
|
|
208
|
-
|
|
191
|
+
input = "preserve";
|
|
209
192
|
recordBootstrapEvent(events, reporter, {
|
|
210
193
|
adapter: adapter.name,
|
|
211
194
|
kind: adapter.kind,
|
|
@@ -213,23 +196,27 @@ async function executeBootstrapTarget(adapter, target, seedable, mode, cloneDire
|
|
|
213
196
|
target: target.relativePath,
|
|
214
197
|
message: "target appeared during CoW seeding; preserving it as the repair input",
|
|
215
198
|
});
|
|
216
|
-
await repairTarget(adapter, target, execution,
|
|
199
|
+
await repairTarget(adapter, target, execution, input, events, reporter);
|
|
217
200
|
return;
|
|
218
201
|
}
|
|
219
202
|
if (isCloneInProgressError(error)) {
|
|
220
203
|
recordBootstrapFailure(events, reporter, adapter, target, `CoW seed is already in progress: ${toErrorMessage(error)}`, "clone-in-progress");
|
|
221
204
|
return;
|
|
222
205
|
}
|
|
223
|
-
|
|
224
|
-
|
|
206
|
+
const failedCloneInspection = await inspectDestination(target.worktreePath, target.targetPath);
|
|
207
|
+
if (failedCloneInspection.kind === "unsafe") {
|
|
208
|
+
recordBootstrapFailure(events, reporter, adapter, target, failedCloneInspection.reason, "destination-unsafe");
|
|
209
|
+
return;
|
|
225
210
|
}
|
|
211
|
+
input = failedCloneInspection.kind === "exists" ? "preserve" : "clean";
|
|
212
|
+
const repairInput = input === "preserve" ? "the preserved target" : "an empty target";
|
|
226
213
|
recordBootstrapEvent(events, reporter, {
|
|
227
214
|
adapter: adapter.name,
|
|
228
215
|
kind: adapter.kind,
|
|
229
|
-
reason: "cow-
|
|
230
|
-
state: "
|
|
216
|
+
reason: "cow-seed-failed",
|
|
217
|
+
state: "repair-only",
|
|
231
218
|
target: target.relativePath,
|
|
232
|
-
message: `CoW seed failed; repairing from
|
|
219
|
+
message: `CoW seed failed; repairing from ${repairInput} (${toErrorMessage(error)})`,
|
|
233
220
|
});
|
|
234
221
|
}
|
|
235
222
|
}
|
|
@@ -238,37 +225,38 @@ async function executeBootstrapTarget(adapter, target, seedable, mode, cloneDire
|
|
|
238
225
|
adapter: adapter.name,
|
|
239
226
|
kind: adapter.kind,
|
|
240
227
|
reason: "seed-unavailable",
|
|
241
|
-
state: "
|
|
228
|
+
state: "repair-only",
|
|
242
229
|
target: target.relativePath,
|
|
243
230
|
message: "CoW seed is unavailable; repairing from an empty target",
|
|
244
231
|
});
|
|
245
232
|
}
|
|
246
|
-
await repairTarget(adapter, target, execution,
|
|
233
|
+
await repairTarget(adapter, target, execution, input, events, reporter);
|
|
247
234
|
}
|
|
248
|
-
async function repairTarget(adapter, target, execution,
|
|
235
|
+
async function repairTarget(adapter, target, execution, input, events, reporter) {
|
|
249
236
|
const beforeRepairInspection = await inspectDestination(target.worktreePath, target.targetPath);
|
|
250
237
|
if (beforeRepairInspection.kind === "unsafe") {
|
|
251
238
|
recordBootstrapFailure(events, reporter, adapter, target, beforeRepairInspection.reason, "destination-unsafe");
|
|
252
239
|
return;
|
|
253
240
|
}
|
|
254
|
-
const
|
|
241
|
+
const effectiveInput = beforeRepairInspection.kind === "exists"
|
|
242
|
+
? input === "seed"
|
|
243
|
+
? "seed"
|
|
244
|
+
: "preserve"
|
|
245
|
+
: "clean";
|
|
255
246
|
try {
|
|
256
|
-
await adapter.repair(target, execution);
|
|
247
|
+
await adapter.repair(target, { ...execution, input: effectiveInput });
|
|
257
248
|
recordBootstrapEvent(events, reporter, {
|
|
258
249
|
adapter: adapter.name,
|
|
259
250
|
kind: adapter.kind,
|
|
260
251
|
state: target.repairState,
|
|
261
252
|
target: target.relativePath,
|
|
262
|
-
message:
|
|
263
|
-
? "reused and repaired"
|
|
264
|
-
: "installed or repaired from a clean target",
|
|
253
|
+
message: repairSuccessMessage(target, effectiveInput),
|
|
265
254
|
});
|
|
266
255
|
}
|
|
267
256
|
catch (firstError) {
|
|
268
257
|
const firstFailureContext = {
|
|
258
|
+
input: effectiveInput,
|
|
269
259
|
target,
|
|
270
|
-
ownership,
|
|
271
|
-
targetExistedBeforeRepair: presentBeforeRepair,
|
|
272
260
|
};
|
|
273
261
|
const cleanupError = await adapter
|
|
274
262
|
.cleanupAfterRepairFailure(firstFailureContext)
|
|
@@ -286,7 +274,7 @@ async function repairTarget(adapter, target, execution, seeded, ownership, event
|
|
|
286
274
|
adapter: adapter.name,
|
|
287
275
|
kind: adapter.kind,
|
|
288
276
|
reason: "seed-repair-failed",
|
|
289
|
-
state: "
|
|
277
|
+
state: "repair-only",
|
|
290
278
|
target: target.relativePath,
|
|
291
279
|
message: `seed repair failed; removed the seed and retrying clean (${toErrorMessage(firstError)})`,
|
|
292
280
|
});
|
|
@@ -295,24 +283,23 @@ async function repairTarget(adapter, target, execution, seeded, ownership, event
|
|
|
295
283
|
recordBootstrapFailure(events, reporter, adapter, target, beforeRetryInspection.reason, "destination-unsafe");
|
|
296
284
|
return;
|
|
297
285
|
}
|
|
298
|
-
const
|
|
286
|
+
const retryInput = beforeRetryInspection.kind === "exists" ? "preserve" : "clean";
|
|
299
287
|
try {
|
|
300
|
-
await adapter.repair(target, execution);
|
|
288
|
+
await adapter.repair(target, { ...execution, input: retryInput });
|
|
301
289
|
recordBootstrapEvent(events, reporter, {
|
|
302
290
|
adapter: adapter.name,
|
|
303
291
|
kind: adapter.kind,
|
|
304
292
|
reason: "repair-retry",
|
|
305
293
|
state: target.repairState,
|
|
306
294
|
target: target.relativePath,
|
|
307
|
-
message:
|
|
295
|
+
message: repairSuccessMessage(target, retryInput),
|
|
308
296
|
});
|
|
309
297
|
}
|
|
310
298
|
catch (secondError) {
|
|
311
299
|
const cleanupError = await adapter
|
|
312
300
|
.cleanupAfterRepairFailure({
|
|
301
|
+
input: retryInput,
|
|
313
302
|
target,
|
|
314
|
-
ownership,
|
|
315
|
-
targetExistedBeforeRepair: presentBeforeRetry,
|
|
316
303
|
})
|
|
317
304
|
.then(() => undefined)
|
|
318
305
|
.catch((error) => toErrorMessage(error));
|
|
@@ -320,6 +307,15 @@ async function repairTarget(adapter, target, execution, seeded, ownership, event
|
|
|
320
307
|
}
|
|
321
308
|
}
|
|
322
309
|
}
|
|
310
|
+
function repairSuccessMessage(target, input) {
|
|
311
|
+
if (input === "seed")
|
|
312
|
+
return "reused and repaired";
|
|
313
|
+
if (input === "preserve")
|
|
314
|
+
return "repaired the existing target";
|
|
315
|
+
return target.repairState === "installed"
|
|
316
|
+
? "installed from a clean target"
|
|
317
|
+
: "repaired from a clean target";
|
|
318
|
+
}
|
|
323
319
|
function recordBootstrapFailure(events, reporter, adapter, target, message, reason) {
|
|
324
320
|
recordBootstrapEvent(events, reporter, {
|
|
325
321
|
adapter: adapter.name,
|
|
@@ -344,6 +340,12 @@ function bootstrapStrategy(mode, target, seedable) {
|
|
|
344
340
|
}
|
|
345
341
|
return seedable ? "cow-then-repair" : "repair-only";
|
|
346
342
|
}
|
|
343
|
+
function adapterForTarget(target) {
|
|
344
|
+
const adapter = createBootstrapAdapters(defaultCheckUvRuntime).find((candidate) => candidate.name === target.adapter);
|
|
345
|
+
if (!adapter)
|
|
346
|
+
throw new Error(`unsupported bootstrap adapter: ${target.adapter}`);
|
|
347
|
+
return adapter;
|
|
348
|
+
}
|
|
347
349
|
function createBootstrapAdapters(checkUvRuntime, cargoBuildCommand) {
|
|
348
350
|
return [
|
|
349
351
|
new LockfileBootstrapAdapter({
|
|
@@ -353,8 +355,8 @@ function createBootstrapAdapters(checkUvRuntime, cargoBuildCommand) {
|
|
|
353
355
|
relativePath: "node_modules",
|
|
354
356
|
repairCommand: "pnpm install --frozen-lockfile",
|
|
355
357
|
shell: false,
|
|
356
|
-
beforeRepair: async (target) => {
|
|
357
|
-
if (
|
|
358
|
+
beforeRepair: async (target, context) => {
|
|
359
|
+
if (context.input === "seed") {
|
|
358
360
|
await rm(join(target.targetPath, ".modules.yaml"), {
|
|
359
361
|
force: true,
|
|
360
362
|
});
|
|
@@ -368,6 +370,13 @@ function createBootstrapAdapters(checkUvRuntime, cargoBuildCommand) {
|
|
|
368
370
|
relativePath: "node_modules",
|
|
369
371
|
repairCommand: "yarn install --immutable",
|
|
370
372
|
shell: false,
|
|
373
|
+
beforeRepair: async (target, context) => {
|
|
374
|
+
if (context.input === "seed") {
|
|
375
|
+
await rm(join(target.targetPath, ".yarn-state.yml"), {
|
|
376
|
+
force: true,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
},
|
|
371
380
|
}),
|
|
372
381
|
new LockfileBootstrapAdapter({
|
|
373
382
|
name: "npm",
|
|
@@ -377,7 +386,7 @@ function createBootstrapAdapters(checkUvRuntime, cargoBuildCommand) {
|
|
|
377
386
|
repairCommand: "npm ci",
|
|
378
387
|
shell: false,
|
|
379
388
|
seedPolicy: "never",
|
|
380
|
-
|
|
389
|
+
selectRepairCommand: (_target, input) => input === "preserve" ? "npm install" : "npm ci",
|
|
381
390
|
repairState: "installed",
|
|
382
391
|
}),
|
|
383
392
|
new LockfileBootstrapAdapter({
|
|
@@ -390,6 +399,11 @@ function createBootstrapAdapters(checkUvRuntime, cargoBuildCommand) {
|
|
|
390
399
|
commandOptions: () => ({
|
|
391
400
|
env: { BUNDLE_PATH: "vendor/bundle" },
|
|
392
401
|
}),
|
|
402
|
+
beforeRepair: async (target, context) => {
|
|
403
|
+
if (context.input === "seed") {
|
|
404
|
+
await removeBundlerExtensionMarkers(target.targetPath);
|
|
405
|
+
}
|
|
406
|
+
},
|
|
393
407
|
}),
|
|
394
408
|
new LockfileBootstrapAdapter({
|
|
395
409
|
name: "uv",
|
|
@@ -399,6 +413,8 @@ function createBootstrapAdapters(checkUvRuntime, cargoBuildCommand) {
|
|
|
399
413
|
repairCommand: "uv sync --locked",
|
|
400
414
|
shell: false,
|
|
401
415
|
canSeedOverride: checkUvRuntime,
|
|
416
|
+
beforeRepair: validateUvStructure,
|
|
417
|
+
afterRepair: validateUvRelocation,
|
|
402
418
|
}),
|
|
403
419
|
new LockfileBootstrapAdapter({
|
|
404
420
|
name: "cargo",
|
|
@@ -406,7 +422,7 @@ function createBootstrapAdapters(checkUvRuntime, cargoBuildCommand) {
|
|
|
406
422
|
lockfile: "Cargo.lock",
|
|
407
423
|
relativePath: "target",
|
|
408
424
|
repairCommand: cargoBuildCommand?.trim() || "cargo check",
|
|
409
|
-
shell: cargoBuildCommand
|
|
425
|
+
shell: Boolean(cargoBuildCommand),
|
|
410
426
|
}),
|
|
411
427
|
];
|
|
412
428
|
}
|
|
@@ -420,8 +436,9 @@ class LockfileBootstrapAdapter {
|
|
|
420
436
|
seedPolicy;
|
|
421
437
|
canSeedOverride;
|
|
422
438
|
beforeRepair;
|
|
439
|
+
afterRepair;
|
|
423
440
|
commandOptions;
|
|
424
|
-
|
|
441
|
+
selectRepairCommand;
|
|
425
442
|
repairState;
|
|
426
443
|
constructor(spec) {
|
|
427
444
|
this.name = spec.name;
|
|
@@ -433,8 +450,9 @@ class LockfileBootstrapAdapter {
|
|
|
433
450
|
this.seedPolicy = spec.seedPolicy ?? "always";
|
|
434
451
|
this.canSeedOverride = spec.canSeedOverride;
|
|
435
452
|
this.beforeRepair = spec.beforeRepair;
|
|
453
|
+
this.afterRepair = spec.afterRepair;
|
|
436
454
|
this.commandOptions = spec.commandOptions;
|
|
437
|
-
this.
|
|
455
|
+
this.selectRepairCommand = spec.selectRepairCommand;
|
|
438
456
|
this.repairState = spec.repairState ?? "repaired";
|
|
439
457
|
}
|
|
440
458
|
async detect(context) {
|
|
@@ -444,6 +462,7 @@ class LockfileBootstrapAdapter {
|
|
|
444
462
|
const sourcePath = join(context.sourceRoot, this.relativePath);
|
|
445
463
|
const targetPath = join(context.worktreePath, this.relativePath);
|
|
446
464
|
const destinationInspection = await inspectDestination(context.worktreePath, targetPath);
|
|
465
|
+
const initialInput = destinationInspection.kind === "exists" ? "preserve" : "clean";
|
|
447
466
|
const target = {
|
|
448
467
|
adapter: this.name,
|
|
449
468
|
kind: this.kind,
|
|
@@ -454,22 +473,26 @@ class LockfileBootstrapAdapter {
|
|
|
454
473
|
targetPath,
|
|
455
474
|
repairCommand: this.defaultRepairCommand,
|
|
456
475
|
repairState: this.repairState,
|
|
457
|
-
|
|
476
|
+
shell: this.shell,
|
|
477
|
+
initialInput,
|
|
458
478
|
};
|
|
459
479
|
return {
|
|
460
480
|
...target,
|
|
461
|
-
repairCommand: this.
|
|
481
|
+
repairCommand: this.selectRepairCommand?.(target, initialInput) ??
|
|
482
|
+
target.repairCommand,
|
|
462
483
|
};
|
|
463
484
|
}
|
|
464
485
|
seedPath(target) {
|
|
465
486
|
return target.sourcePath;
|
|
466
487
|
}
|
|
467
488
|
async repair(target, context) {
|
|
468
|
-
await this.beforeRepair?.(target);
|
|
469
|
-
|
|
489
|
+
await this.beforeRepair?.(target, context);
|
|
490
|
+
const repairCommand = this.selectRepairCommand?.(target, context.input) ?? target.repairCommand;
|
|
491
|
+
await context.runCommand(repairCommand, target.worktreePath, context.stderr, context.stdout, {
|
|
470
492
|
...this.commandOptions?.(target),
|
|
471
|
-
shell:
|
|
493
|
+
shell: target.shell,
|
|
472
494
|
});
|
|
495
|
+
await this.afterRepair?.(target, context);
|
|
473
496
|
}
|
|
474
497
|
async canSeed(target) {
|
|
475
498
|
if (this.seedPolicy === "never")
|
|
@@ -478,28 +501,13 @@ class LockfileBootstrapAdapter {
|
|
|
478
501
|
return false;
|
|
479
502
|
return (await this.canSeedOverride?.(target)) ?? true;
|
|
480
503
|
}
|
|
481
|
-
output(target) {
|
|
482
|
-
return {
|
|
483
|
-
adapter: this.name,
|
|
484
|
-
kind: this.kind,
|
|
485
|
-
target: target.relativePath,
|
|
486
|
-
repairCommand: target.repairCommand,
|
|
487
|
-
};
|
|
488
|
-
}
|
|
489
504
|
shouldRetryAfterRepairFailure(context) {
|
|
490
|
-
return context.
|
|
505
|
+
return context.input === "seed";
|
|
491
506
|
}
|
|
492
507
|
async cleanupAfterRepairFailure(context) {
|
|
493
|
-
if (context.
|
|
508
|
+
if (context.input !== "seed")
|
|
494
509
|
return;
|
|
495
|
-
}
|
|
496
|
-
if (context.ownership === "adapter" || context.ownership === "syncDirs") {
|
|
497
|
-
await rm(context.target.targetPath, { force: true, recursive: true });
|
|
498
|
-
return;
|
|
499
|
-
}
|
|
500
|
-
if (context.ownership === "empty" && !context.targetExistedBeforeRepair) {
|
|
501
|
-
await rm(context.target.targetPath, { force: true, recursive: true });
|
|
502
|
-
}
|
|
510
|
+
await rm(context.target.targetPath, { force: true, recursive: true });
|
|
503
511
|
}
|
|
504
512
|
}
|
|
505
513
|
async function safeSourceDirectory(target) {
|
|
@@ -518,24 +526,251 @@ async function safeSourceDirectory(target) {
|
|
|
518
526
|
async function defaultCheckUvRuntime(target) {
|
|
519
527
|
try {
|
|
520
528
|
const config = await readFile(join(target.sourcePath, "pyvenv.cfg"), "utf8");
|
|
521
|
-
const expected = config.match(/^version
|
|
529
|
+
const expected = config.match(/^version(?:_info)?\s*=\s*(\d+\.\d+)/mu)?.[1];
|
|
522
530
|
if (!expected)
|
|
523
531
|
return false;
|
|
532
|
+
const expectedImplementation = config
|
|
533
|
+
.match(/^implementation\s*=\s*([^\s#]+)/imu)?.[1]
|
|
534
|
+
?.toLowerCase();
|
|
524
535
|
const sourceInterpreter = join(target.sourcePath, process.platform === "win32" ? "Scripts/python.exe" : "bin/python");
|
|
525
|
-
const fingerprintScript = "import platform, sys; print(f'{sys.version_info.major}.{sys.version_info.minor}|{platform.machine()}|{sys.implementation.
|
|
526
|
-
const
|
|
527
|
-
|
|
528
|
-
|
|
536
|
+
const fingerprintScript = "import platform, sys; print(f'{sys.version_info.major}.{sys.version_info.minor}|{platform.machine()}|{sys.implementation.name}')";
|
|
537
|
+
const source = await execFileAsync(sourceInterpreter, [
|
|
538
|
+
"-c",
|
|
539
|
+
fingerprintScript,
|
|
529
540
|
]);
|
|
530
541
|
const sourceFingerprint = source.stdout.trim();
|
|
531
|
-
const
|
|
532
|
-
return (
|
|
533
|
-
|
|
542
|
+
const [sourceVersion, sourceMachine, sourceImplementation, extra] = sourceFingerprint.split("|");
|
|
543
|
+
return (extra === undefined &&
|
|
544
|
+
sourceVersion === expected &&
|
|
545
|
+
Boolean(sourceMachine) &&
|
|
546
|
+
Boolean(sourceImplementation) &&
|
|
547
|
+
(expectedImplementation === undefined ||
|
|
548
|
+
sourceImplementation.toLowerCase() === expectedImplementation));
|
|
534
549
|
}
|
|
535
550
|
catch {
|
|
536
551
|
return false;
|
|
537
552
|
}
|
|
538
553
|
}
|
|
554
|
+
function virtualEnvironmentPrefixes(text) {
|
|
555
|
+
const prefixes = [];
|
|
556
|
+
for (const line of text.split(/\r?\n/u)) {
|
|
557
|
+
if (!/\bVIRTUAL_ENV\b/u.test(line))
|
|
558
|
+
continue;
|
|
559
|
+
const quotedAssignment = line.match(/\bVIRTUAL_ENV(?:\s*=|\s+)\s*(?:"([^"]+)"|'([^']+)')/u);
|
|
560
|
+
const quotedPrefix = quotedAssignment?.[1] ?? quotedAssignment?.[2];
|
|
561
|
+
if (quotedPrefix && isAbsolute(quotedPrefix))
|
|
562
|
+
prefixes.push(quotedPrefix);
|
|
563
|
+
const unquoted = line.match(/\bVIRTUAL_ENV(?:\s*=|\s+)\s*([^\s"']+)/u)?.[1];
|
|
564
|
+
if (unquoted && isAbsolute(unquoted))
|
|
565
|
+
prefixes.push(unquoted);
|
|
566
|
+
}
|
|
567
|
+
return uniquePaths(prefixes);
|
|
568
|
+
}
|
|
569
|
+
function pythonPrefixFromShebang(text) {
|
|
570
|
+
const command = text
|
|
571
|
+
.match(/^#!\s*([^\r\n]+)/u)?.[1]
|
|
572
|
+
?.trim()
|
|
573
|
+
.split(/\s+/u)[0];
|
|
574
|
+
if (!command || !isAbsolute(command))
|
|
575
|
+
return undefined;
|
|
576
|
+
if (!/^python(?:\d+(?:\.\d+)?)?(?:\.exe)?$/iu.test(command.split(sep).at(-1) ?? "")) {
|
|
577
|
+
return undefined;
|
|
578
|
+
}
|
|
579
|
+
return dirname(dirname(command));
|
|
580
|
+
}
|
|
581
|
+
function pythonPrefixesFromText(text) {
|
|
582
|
+
const interpreters = [];
|
|
583
|
+
for (const match of text.matchAll(/["'](\/[^"']+)["']/gu)) {
|
|
584
|
+
if (match[1])
|
|
585
|
+
interpreters.push(match[1]);
|
|
586
|
+
}
|
|
587
|
+
for (const match of text.matchAll(/(?:^|\s)(\/[^\s"']+)/gmu)) {
|
|
588
|
+
if (match[1])
|
|
589
|
+
interpreters.push(match[1]);
|
|
590
|
+
}
|
|
591
|
+
return uniquePaths(interpreters.flatMap((interpreter) => {
|
|
592
|
+
const name = interpreter.split(sep).at(-1) ?? "";
|
|
593
|
+
return /^python(?:\d+(?:\.\d+)?)?(?:\.exe)?$/iu.test(name)
|
|
594
|
+
? [dirname(dirname(interpreter))]
|
|
595
|
+
: [];
|
|
596
|
+
}));
|
|
597
|
+
}
|
|
598
|
+
async function validateUvRelocation(target, context) {
|
|
599
|
+
if (context.input === "preserve")
|
|
600
|
+
return;
|
|
601
|
+
const scriptsDirectory = join(target.targetPath, process.platform === "win32" ? "Scripts" : "bin");
|
|
602
|
+
const interpreter = join(scriptsDirectory, process.platform === "win32" ? "python.exe" : "python");
|
|
603
|
+
const { stdout } = await execFileAsync(interpreter, [
|
|
604
|
+
"-c",
|
|
605
|
+
"import os, sys; print(os.path.realpath(sys.prefix))",
|
|
606
|
+
]);
|
|
607
|
+
const [actualPrefix, expectedPrefix] = await Promise.all([
|
|
608
|
+
realpath(stdout.trim()),
|
|
609
|
+
realpath(target.targetPath),
|
|
610
|
+
]);
|
|
611
|
+
if (actualPrefix !== expectedPrefix) {
|
|
612
|
+
throw new Error(`uv environment still points to its source prefix: ${actualPrefix}`);
|
|
613
|
+
}
|
|
614
|
+
const sourcePaths = uniquePaths([
|
|
615
|
+
target.sourcePath,
|
|
616
|
+
await realpath(target.sourcePath).catch(() => undefined),
|
|
617
|
+
]);
|
|
618
|
+
const entries = await readdir(scriptsDirectory, { withFileTypes: true });
|
|
619
|
+
assertSafeUvScriptEntries(entries);
|
|
620
|
+
const environmentName = basename(target.targetPath);
|
|
621
|
+
const acceptedPrefixes = new Set([
|
|
622
|
+
resolve(target.targetPath),
|
|
623
|
+
await realpath(target.targetPath),
|
|
624
|
+
]);
|
|
625
|
+
let validatedBytes = 0;
|
|
626
|
+
for (const path of [
|
|
627
|
+
join(target.targetPath, "pyvenv.cfg"),
|
|
628
|
+
...entries
|
|
629
|
+
.filter((entry) => entry.isFile())
|
|
630
|
+
.map((entry) => join(scriptsDirectory, entry.name)),
|
|
631
|
+
]) {
|
|
632
|
+
const validated = await readBoundedUvTextFile(path, UV_VALIDATION_MAX_TOTAL_BYTES - validatedBytes);
|
|
633
|
+
if (!validated)
|
|
634
|
+
continue;
|
|
635
|
+
validatedBytes += validated.bytes;
|
|
636
|
+
if (validated.text === undefined)
|
|
637
|
+
continue;
|
|
638
|
+
const { text } = validated;
|
|
639
|
+
if (sourcePaths.some((sourcePath) => text.includes(sourcePath))) {
|
|
640
|
+
throw new Error(`uv environment contains a stale source path: ${path}`);
|
|
641
|
+
}
|
|
642
|
+
const shebangPrefix = pythonPrefixFromShebang(text);
|
|
643
|
+
if (shebangPrefix &&
|
|
644
|
+
basename(shebangPrefix) === environmentName &&
|
|
645
|
+
!acceptedPrefixes.has(resolve(shebangPrefix))) {
|
|
646
|
+
throw new Error(`uv launcher points outside its environment: ${path}`);
|
|
647
|
+
}
|
|
648
|
+
if (pythonPrefixesFromText(text)
|
|
649
|
+
.filter((prefix) => basename(prefix) === environmentName)
|
|
650
|
+
.some((prefix) => !acceptedPrefixes.has(resolve(prefix)))) {
|
|
651
|
+
throw new Error(`uv launcher points outside its environment: ${path}`);
|
|
652
|
+
}
|
|
653
|
+
if (virtualEnvironmentPrefixes(text).some((prefix) => !acceptedPrefixes.has(resolve(prefix)))) {
|
|
654
|
+
throw new Error(`uv activation script points outside its environment: ${path}`);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
async function validateUvStructure(target, context) {
|
|
659
|
+
if (context.input === "preserve")
|
|
660
|
+
return;
|
|
661
|
+
const targetStats = await lstat(target.targetPath).catch(() => undefined);
|
|
662
|
+
if (!targetStats)
|
|
663
|
+
return;
|
|
664
|
+
if (!targetStats.isDirectory() || targetStats.isSymbolicLink()) {
|
|
665
|
+
throw new Error("uv environment must be a real directory");
|
|
666
|
+
}
|
|
667
|
+
const configStats = await lstat(join(target.targetPath, "pyvenv.cfg")).catch(() => undefined);
|
|
668
|
+
if (configStats && (!configStats.isFile() || configStats.isSymbolicLink())) {
|
|
669
|
+
throw new Error("uv pyvenv.cfg must be a regular file");
|
|
670
|
+
}
|
|
671
|
+
const scriptsDirectory = join(target.targetPath, process.platform === "win32" ? "Scripts" : "bin");
|
|
672
|
+
const scriptsStats = await lstat(scriptsDirectory).catch(() => undefined);
|
|
673
|
+
if (!scriptsStats)
|
|
674
|
+
return;
|
|
675
|
+
if (!scriptsStats.isDirectory() || scriptsStats.isSymbolicLink()) {
|
|
676
|
+
throw new Error("uv scripts path must be a real directory");
|
|
677
|
+
}
|
|
678
|
+
assertSafeUvScriptEntries(await readdir(scriptsDirectory, { withFileTypes: true }));
|
|
679
|
+
}
|
|
680
|
+
function assertSafeUvScriptEntries(entries) {
|
|
681
|
+
if (entries.length > UV_VALIDATION_MAX_ENTRIES) {
|
|
682
|
+
throw new Error("uv scripts directory exceeds the validation entry limit");
|
|
683
|
+
}
|
|
684
|
+
for (const entry of entries) {
|
|
685
|
+
if (entry.isSymbolicLink() && !isUvInterpreterName(entry.name)) {
|
|
686
|
+
throw new Error(`uv script must not be a symbolic link: ${entry.name}`);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
async function readBoundedUvTextFile(path, remainingBytes) {
|
|
691
|
+
let handle;
|
|
692
|
+
try {
|
|
693
|
+
handle = await open(path, constants.O_RDONLY |
|
|
694
|
+
(process.platform === "win32" ? 0 : constants.O_NOFOLLOW));
|
|
695
|
+
}
|
|
696
|
+
catch (error) {
|
|
697
|
+
if (isNotFoundError(error))
|
|
698
|
+
return undefined;
|
|
699
|
+
throw error;
|
|
700
|
+
}
|
|
701
|
+
try {
|
|
702
|
+
const stats = await handle.stat();
|
|
703
|
+
if (!stats.isFile())
|
|
704
|
+
return undefined;
|
|
705
|
+
if (stats.size > UV_VALIDATION_MAX_FILE_BYTES) {
|
|
706
|
+
throw new Error(`uv launcher exceeds the validation size limit: ${path}`);
|
|
707
|
+
}
|
|
708
|
+
if (stats.size > remainingBytes) {
|
|
709
|
+
throw new Error("uv launchers exceed the total validation size limit");
|
|
710
|
+
}
|
|
711
|
+
const readLimit = Math.min(UV_VALIDATION_MAX_FILE_BYTES, remainingBytes);
|
|
712
|
+
const contents = await readFilePrefix(handle, readLimit + 1);
|
|
713
|
+
if (contents.byteLength > UV_VALIDATION_MAX_FILE_BYTES) {
|
|
714
|
+
throw new Error(`uv launcher exceeds the validation size limit: ${path}`);
|
|
715
|
+
}
|
|
716
|
+
if (contents.byteLength > remainingBytes) {
|
|
717
|
+
throw new Error("uv launchers exceed the total validation size limit");
|
|
718
|
+
}
|
|
719
|
+
const text = contents.toString("utf8");
|
|
720
|
+
return Buffer.from(text, "utf8").equals(contents)
|
|
721
|
+
? { bytes: contents.byteLength, text }
|
|
722
|
+
: { bytes: contents.byteLength };
|
|
723
|
+
}
|
|
724
|
+
finally {
|
|
725
|
+
await handle.close();
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
async function readFilePrefix(handle, maxBytes) {
|
|
729
|
+
const contents = Buffer.allocUnsafe(maxBytes);
|
|
730
|
+
let offset = 0;
|
|
731
|
+
while (offset < contents.byteLength) {
|
|
732
|
+
const { bytesRead } = await handle.read(contents, offset, contents.byteLength - offset, offset);
|
|
733
|
+
if (bytesRead === 0)
|
|
734
|
+
break;
|
|
735
|
+
offset += bytesRead;
|
|
736
|
+
}
|
|
737
|
+
return contents.subarray(0, offset);
|
|
738
|
+
}
|
|
739
|
+
function isUvInterpreterName(name) {
|
|
740
|
+
return /^python(?:\d+(?:\.\d+)?)?(?:\.exe)?$/iu.test(name);
|
|
741
|
+
}
|
|
742
|
+
async function removeBundlerExtensionMarkers(root) {
|
|
743
|
+
const pending = [root];
|
|
744
|
+
while (pending.length > 0) {
|
|
745
|
+
const current = pending.pop();
|
|
746
|
+
if (!current)
|
|
747
|
+
continue;
|
|
748
|
+
let entries;
|
|
749
|
+
try {
|
|
750
|
+
entries = await readdir(current, { withFileTypes: true });
|
|
751
|
+
}
|
|
752
|
+
catch (error) {
|
|
753
|
+
if (isNotFoundError(error))
|
|
754
|
+
continue;
|
|
755
|
+
throw error;
|
|
756
|
+
}
|
|
757
|
+
for (const entry of entries) {
|
|
758
|
+
const path = join(current, entry.name);
|
|
759
|
+
if (entry.isDirectory())
|
|
760
|
+
pending.push(path);
|
|
761
|
+
else if (entry.isFile() && isBundlerExtensionMarker(root, path)) {
|
|
762
|
+
await rm(path, { force: true });
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
function isBundlerExtensionMarker(root, path) {
|
|
768
|
+
const segments = relative(root, path).split(sep);
|
|
769
|
+
return (segments.length >= 7 &&
|
|
770
|
+
segments[0] === "ruby" &&
|
|
771
|
+
segments[2] === "extensions" &&
|
|
772
|
+
segments.at(-1) === "gem.build_complete");
|
|
773
|
+
}
|
|
539
774
|
function uniquePaths(paths) {
|
|
540
775
|
return [...new Set(paths.filter((path) => Boolean(path)))];
|
|
541
776
|
}
|