pi-microsandbox 0.2.0 → 0.3.0
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/README.md +58 -45
- package/SECURITY.md +12 -8
- package/docs/commands.md +18 -15
- package/docs/configuration.md +15 -8
- package/docs/development.md +14 -10
- package/docs/getting-started.md +16 -4
- package/docs/images.md +7 -6
- package/docs/safety.md +34 -22
- package/docs/storage.md +54 -55
- package/docs/troubleshooting.md +32 -11
- package/extensions/pi-msb/command.ts +12 -189
- package/extensions/pi-msb/config.ts +126 -38
- package/extensions/pi-msb/control.ts +42 -291
- package/extensions/pi-msb/index.ts +2 -2
- package/extensions/pi-msb/labels.ts +46 -231
- package/extensions/pi-msb/locks.ts +4 -6
- package/extensions/pi-msb/prune.ts +16 -12
- package/extensions/pi-msb/sandbox-manager.ts +39 -138
- package/extensions/pi-msb/tools.ts +13 -26
- package/extensions/pi-msb/transport.ts +0 -18
- package/extensions/pi-msb/types.ts +24 -108
- package/extensions/pi-msb/workspace.ts +126 -0
- package/native/flock/prebuilds/darwin-arm64/flock.node +0 -0
- package/package.json +1 -1
- package/extensions/pi-msb/git.ts +0 -256
- package/extensions/pi-msb/storage.ts +0 -332
|
@@ -12,8 +12,8 @@ import type {
|
|
|
12
12
|
|
|
13
13
|
// --------------------------------------------------------------- identity ----
|
|
14
14
|
|
|
15
|
-
export const STATE_SCHEMA_VERSION =
|
|
16
|
-
export const LOCKFILE_VERSION =
|
|
15
|
+
export const STATE_SCHEMA_VERSION = 2;
|
|
16
|
+
export const LOCKFILE_VERSION = 2;
|
|
17
17
|
|
|
18
18
|
export function resourceId(sessionId: string): string {
|
|
19
19
|
return createHash("sha256").update(sessionId).digest("hex").slice(0, 20);
|
|
@@ -24,14 +24,7 @@ export function displayId(sessionId: string): string {
|
|
|
24
24
|
export function sandboxNameFor(sessionId: string): string {
|
|
25
25
|
return `pi-msb-${resourceId(sessionId)}`;
|
|
26
26
|
}
|
|
27
|
-
export function volumeNameFor(sessionId: string): string {
|
|
28
|
-
return `pi-msb-vol-${resourceId(sessionId)}`;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
27
|
// ---------------------------------------------------------------- config ----
|
|
32
|
-
|
|
33
|
-
export type StorageMode = "git" | "direct" | "none";
|
|
34
|
-
export type ConfigStorageMode = "auto" | StorageMode;
|
|
35
28
|
export type NetworkMode = "default" | "open" | "allowlist" | "deny";
|
|
36
29
|
export type FallbackMode = "block" | "host";
|
|
37
30
|
export type BootstrapTools = "auto" | boolean;
|
|
@@ -73,11 +66,6 @@ export interface Config {
|
|
|
73
66
|
replace: boolean;
|
|
74
67
|
replaceTimeoutMs: number;
|
|
75
68
|
sandboxName: string | null;
|
|
76
|
-
mode: ConfigStorageMode;
|
|
77
|
-
cloneBranch: "current" | string;
|
|
78
|
-
cloneDepth: number | "unlimited";
|
|
79
|
-
shallowArchive: boolean;
|
|
80
|
-
volumeQuotaMiB: number;
|
|
81
69
|
network: NetworkConfig;
|
|
82
70
|
docker: DockerConfig;
|
|
83
71
|
secrets: SecretConfig[];
|
|
@@ -137,31 +125,21 @@ export const LABEL_KEYS = {
|
|
|
137
125
|
managed: "pi-msb.managed",
|
|
138
126
|
schema: "pi-msb.schema",
|
|
139
127
|
session: "pi-msb.session",
|
|
140
|
-
mode: "pi-msb.mode",
|
|
141
128
|
cwd: "pi-msb.cwd",
|
|
129
|
+
root: "pi-msb.root",
|
|
130
|
+
guestRoot: "pi-msb.guest-root",
|
|
142
131
|
pid: "pi-msb.pid",
|
|
143
|
-
volume: "pi-msb.volume",
|
|
144
132
|
image: "pi-msb.image",
|
|
145
133
|
keep: "pi-msb.keep",
|
|
146
|
-
seedBranch: "pi-msb.seed-branch",
|
|
147
|
-
seedSha: "pi-msb.seed-sha",
|
|
148
134
|
} as const;
|
|
149
135
|
|
|
150
136
|
export interface SandboxLabelInput {
|
|
151
137
|
sessionId: string;
|
|
152
|
-
mode: StorageMode;
|
|
153
138
|
cwd: string;
|
|
139
|
+
root: string;
|
|
140
|
+
guestRoot: string;
|
|
154
141
|
pid: number;
|
|
155
142
|
image: string;
|
|
156
|
-
volumeName?: string;
|
|
157
|
-
seedBranch?: string | null;
|
|
158
|
-
seedSha?: string | null;
|
|
159
|
-
}
|
|
160
|
-
export interface VolumeLabelInput {
|
|
161
|
-
sessionId: string;
|
|
162
|
-
cwd: string;
|
|
163
|
-
seedBranch?: string | null;
|
|
164
|
-
seedSha?: string | null;
|
|
165
143
|
}
|
|
166
144
|
export interface ManagedSandboxRecord {
|
|
167
145
|
name: string;
|
|
@@ -171,29 +149,18 @@ export interface ManagedSandboxRecord {
|
|
|
171
149
|
}
|
|
172
150
|
export interface ValidatedManagedSandbox extends ManagedSandboxRecord {
|
|
173
151
|
sessionId: string;
|
|
174
|
-
mode: StorageMode;
|
|
175
152
|
cwd: string;
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
export interface VolumeRecord {
|
|
179
|
-
name: string;
|
|
180
|
-
hostPath: string;
|
|
181
|
-
labels: Record<string, string>;
|
|
182
|
-
kind?: string;
|
|
183
|
-
usedBytes?: number;
|
|
184
|
-
createdAt?: number;
|
|
153
|
+
root: string;
|
|
154
|
+
guestRoot: string;
|
|
185
155
|
}
|
|
186
156
|
export interface PersistedSandboxState {
|
|
187
157
|
version: typeof STATE_SCHEMA_VERSION;
|
|
188
158
|
sessionId: string;
|
|
189
159
|
sandboxName: string;
|
|
190
|
-
mode: StorageMode;
|
|
191
160
|
cwd: string;
|
|
161
|
+
root: string;
|
|
162
|
+
guestRoot: string;
|
|
192
163
|
image: string;
|
|
193
|
-
volumeName?: string;
|
|
194
|
-
volumeHostPath?: string;
|
|
195
|
-
seedBranch?: string | null;
|
|
196
|
-
seedSha?: string | null;
|
|
197
164
|
enabled: boolean;
|
|
198
165
|
createdAt: number;
|
|
199
166
|
}
|
|
@@ -204,9 +171,8 @@ export interface LockInfo {
|
|
|
204
171
|
version: typeof LOCKFILE_VERSION;
|
|
205
172
|
sessionId: string;
|
|
206
173
|
sandboxName: string;
|
|
207
|
-
volumeName?: string;
|
|
208
|
-
mode: StorageMode;
|
|
209
174
|
cwd: string;
|
|
175
|
+
root: string;
|
|
210
176
|
pid: number;
|
|
211
177
|
createdAt: number;
|
|
212
178
|
}
|
|
@@ -218,52 +184,18 @@ export interface LocksPort {
|
|
|
218
184
|
tryAcquire(sessionId: string): Promise<LockHandle | null>;
|
|
219
185
|
}
|
|
220
186
|
|
|
221
|
-
//
|
|
187
|
+
// -------------------------------------------------------------- workspace ----
|
|
222
188
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
/** Canonical host
|
|
226
|
-
|
|
227
|
-
/**
|
|
228
|
-
|
|
229
|
-
/**
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
unborn: boolean;
|
|
234
|
-
isLinkedWorktree: boolean;
|
|
235
|
-
}
|
|
236
|
-
export interface GitSeedBundle {
|
|
237
|
-
hostPath: string;
|
|
238
|
-
branch: string | null;
|
|
239
|
-
headSha: string;
|
|
240
|
-
cleanup(): Promise<void>;
|
|
241
|
-
}
|
|
242
|
-
export interface GitVolumePlan {
|
|
243
|
-
kind: "git-volume";
|
|
244
|
-
sessionId: string;
|
|
245
|
-
volumeName: string;
|
|
246
|
-
volumeQuotaMiB: number;
|
|
247
|
-
repoRoot: string;
|
|
248
|
-
mountGuestPath: string;
|
|
249
|
-
workdir: string;
|
|
250
|
-
branch: string | null;
|
|
251
|
-
headSha: string | null;
|
|
252
|
-
unborn: boolean;
|
|
253
|
-
depth: number | "unlimited";
|
|
254
|
-
seedRequired: boolean;
|
|
255
|
-
}
|
|
256
|
-
export type StoragePlan =
|
|
257
|
-
| GitVolumePlan
|
|
258
|
-
| { kind: "direct-mount"; hostPath: string; guestPath: string; workdir: string }
|
|
259
|
-
| { kind: "none"; guestPath: string; workdir: string };
|
|
260
|
-
export interface PreparedStorage {
|
|
261
|
-
plan: StoragePlan;
|
|
262
|
-
volume?: VolumeRecord;
|
|
263
|
-
bundle?: GitSeedBundle | null;
|
|
264
|
-
createdVolume: boolean;
|
|
265
|
-
}
|
|
266
|
-
export interface SeedResult { headSha: string | null }
|
|
189
|
+
/** One path-preserving read/write bind used by every sandbox. */
|
|
190
|
+
export interface Workspace {
|
|
191
|
+
/** Canonical host directory supplied to the bind mount. */
|
|
192
|
+
hostRoot: string;
|
|
193
|
+
/** Lexical absolute path used as the guest mount point. */
|
|
194
|
+
guestRoot: string;
|
|
195
|
+
/** Original lexical absolute cwd retained as the sandbox workdir. */
|
|
196
|
+
cwd: string;
|
|
197
|
+
fromGit: boolean;
|
|
198
|
+
}
|
|
267
199
|
|
|
268
200
|
// ------------------------------------------------------------- transport ----
|
|
269
201
|
|
|
@@ -306,8 +238,6 @@ export interface SandboxTransport {
|
|
|
306
238
|
exists(path: string): Promise<boolean>;
|
|
307
239
|
stat(path: string): Promise<StatResult>;
|
|
308
240
|
list(path: string): Promise<FsEntry[]>;
|
|
309
|
-
copyFromHost(hostPath: string, guestPath: string): Promise<void>;
|
|
310
|
-
copyToHost(guestPath: string, hostPath: string): Promise<void>;
|
|
311
241
|
exec(command: string, args: string[], options?: ExecOptions): Promise<TransportExecResult>;
|
|
312
242
|
execStream(command: string, args: string[], options?: ExecStreamOptions): Promise<{ exitCode: number }>;
|
|
313
243
|
dispose(): Promise<void>;
|
|
@@ -382,14 +312,10 @@ export interface RuntimePreparation {
|
|
|
382
312
|
export interface SandboxInfo {
|
|
383
313
|
name: string;
|
|
384
314
|
displayId: string;
|
|
385
|
-
mode: StorageMode;
|
|
386
315
|
image: string;
|
|
387
316
|
pid: number;
|
|
388
317
|
cwd: string;
|
|
389
|
-
|
|
390
|
-
volumeHostPath?: string;
|
|
391
|
-
seedBranch?: string | null;
|
|
392
|
-
seedSha?: string | null;
|
|
318
|
+
root: string;
|
|
393
319
|
createdAt: number;
|
|
394
320
|
docker: DockerCapabilityStatus;
|
|
395
321
|
}
|
|
@@ -401,6 +327,7 @@ export interface RuntimeState {
|
|
|
401
327
|
export interface BootRequest {
|
|
402
328
|
sessionId: string;
|
|
403
329
|
cwd: string;
|
|
330
|
+
workspace: Workspace;
|
|
404
331
|
config: Config;
|
|
405
332
|
restored: PersistedSandboxState | null;
|
|
406
333
|
}
|
|
@@ -429,22 +356,11 @@ export type ExecFn = (
|
|
|
429
356
|
|
|
430
357
|
// ------------------------------------------------------- command/control ----
|
|
431
358
|
|
|
432
|
-
export interface ExportResult { source: string; destination: string }
|
|
433
359
|
export interface MsbControl {
|
|
434
360
|
getState(): RuntimeState;
|
|
435
361
|
setEnabled(enabled: boolean): Promise<void>;
|
|
436
362
|
reload(): Promise<void>;
|
|
437
363
|
pruneNow(): Promise<PruneReport>;
|
|
438
|
-
listVolumes(): Promise<VolumeRecord[]>;
|
|
439
|
-
describeVolume(name: string): Promise<{
|
|
440
|
-
volume: VolumeRecord;
|
|
441
|
-
branch?: string;
|
|
442
|
-
lastCommit?: string;
|
|
443
|
-
dirtyCount?: number;
|
|
444
|
-
mounted: boolean;
|
|
445
|
-
}>;
|
|
446
|
-
removeVolume(name: string): Promise<void>;
|
|
447
|
-
exportPaths(paths: string[], destination?: string): Promise<ExportResult[]>;
|
|
448
364
|
getLogs(tailLines?: number): Promise<string>;
|
|
449
365
|
getEffectiveConfig(): ResolvedConfig;
|
|
450
366
|
getEffectiveConfigToml(): string;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { realpath } from "node:fs/promises";
|
|
3
|
+
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
|
|
6
|
+
import type { ExecFn, Workspace } from "./types.ts";
|
|
7
|
+
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
|
|
10
|
+
interface ExecFileFailure {
|
|
11
|
+
code?: number | string;
|
|
12
|
+
killed?: boolean;
|
|
13
|
+
signal?: string | null;
|
|
14
|
+
stdout?: string | Buffer;
|
|
15
|
+
stderr?: string | Buffer;
|
|
16
|
+
message?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const defaultExec: ExecFn = async (command, args, options = {}) => {
|
|
20
|
+
try {
|
|
21
|
+
const result = await execFileAsync(command, args, {
|
|
22
|
+
cwd: options.cwd,
|
|
23
|
+
env: { ...process.env, ...options.env, LC_ALL: "C" },
|
|
24
|
+
timeout: options.timeout === undefined ? undefined : options.timeout * 1000,
|
|
25
|
+
maxBuffer: 1024 * 1024,
|
|
26
|
+
encoding: "utf8",
|
|
27
|
+
});
|
|
28
|
+
return { stdout: String(result.stdout), stderr: String(result.stderr), code: 0 };
|
|
29
|
+
} catch (error: unknown) {
|
|
30
|
+
const failure = error as ExecFileFailure;
|
|
31
|
+
return {
|
|
32
|
+
stdout: String(failure.stdout ?? ""),
|
|
33
|
+
stderr: String(failure.stderr ?? failure.message ?? ""),
|
|
34
|
+
code: typeof failure.code === "number" ? failure.code : 127,
|
|
35
|
+
killed: Boolean(failure.killed || failure.signal),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
async function lexicalRoot(cwd: string, canonicalRoot: string): Promise<string | null> {
|
|
41
|
+
let candidate = cwd;
|
|
42
|
+
while (true) {
|
|
43
|
+
let canonicalCandidate: string;
|
|
44
|
+
try {
|
|
45
|
+
canonicalCandidate = await realpath(candidate);
|
|
46
|
+
} catch (error) {
|
|
47
|
+
throw new Error(`workspace path cannot be canonicalized: ${candidate}`, { cause: error });
|
|
48
|
+
}
|
|
49
|
+
if (canonicalCandidate === canonicalRoot) return candidate;
|
|
50
|
+
const parent = dirname(candidate);
|
|
51
|
+
if (parent === candidate) return null;
|
|
52
|
+
candidate = parent;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isNotRepository(code: number, stderr: string): boolean {
|
|
57
|
+
return code === 128 && /(?:^|\n)fatal: (?:not a git (?:repository|work tree)|this operation must be run in a work tree)(?:\s|$)/i.test(stderr);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const PROTECTED_GUEST_PATHS = [
|
|
61
|
+
"/bin",
|
|
62
|
+
"/dev",
|
|
63
|
+
"/etc/ld.so.cache",
|
|
64
|
+
"/etc/ld.so.preload",
|
|
65
|
+
"/lib",
|
|
66
|
+
"/lib64",
|
|
67
|
+
"/proc",
|
|
68
|
+
"/sbin",
|
|
69
|
+
"/sys",
|
|
70
|
+
"/usr",
|
|
71
|
+
"/run",
|
|
72
|
+
"/var/run",
|
|
73
|
+
"/var/lib/docker",
|
|
74
|
+
] as const;
|
|
75
|
+
|
|
76
|
+
function containsPath(parent: string, child: string): boolean {
|
|
77
|
+
const value = relative(parent, child);
|
|
78
|
+
return value === "" || (value !== ".." && !value.startsWith(`..${sep}`) && !isAbsolute(value));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function assertSafeGuestRoot(root: string): void {
|
|
82
|
+
if (PROTECTED_GUEST_PATHS.some((path) => containsPath(root, path) || containsPath(path, root))) {
|
|
83
|
+
throw new Error(`workspace root cannot safely be mounted at the guest path: ${root}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Discover the single path-preserving project bind for a configure/boot cycle. */
|
|
88
|
+
export async function discoverWorkspace(cwd: string, exec: ExecFn = defaultExec): Promise<Workspace> {
|
|
89
|
+
const lexicalCwd = resolve(cwd);
|
|
90
|
+
let canonicalCwd: string;
|
|
91
|
+
try {
|
|
92
|
+
canonicalCwd = await realpath(lexicalCwd);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
throw new Error(`workspace cwd cannot be canonicalized: ${lexicalCwd}`, { cause: error });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const result = await exec("git", ["-C", lexicalCwd, "rev-parse", "--show-toplevel"]);
|
|
98
|
+
if (result.killed) throw new Error("Git workspace discovery was interrupted");
|
|
99
|
+
if (result.code !== 0) {
|
|
100
|
+
if (isNotRepository(result.code, result.stderr)) {
|
|
101
|
+
assertSafeGuestRoot(lexicalCwd);
|
|
102
|
+
return { hostRoot: canonicalCwd, guestRoot: lexicalCwd, cwd: lexicalCwd, fromGit: false };
|
|
103
|
+
}
|
|
104
|
+
const detail = result.stderr.trim() || `git exited with code ${result.code}`;
|
|
105
|
+
throw new Error(`Git workspace discovery failed: ${detail}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const output = result.stdout.replace(/\r?\n$/, "");
|
|
109
|
+
if (!output || output.includes("\0") || output.includes("\n") || output.includes("\r") || !isAbsolute(output)) {
|
|
110
|
+
throw new Error("Git workspace discovery returned an invalid repository root");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
let hostRoot: string;
|
|
114
|
+
try {
|
|
115
|
+
hostRoot = await realpath(output);
|
|
116
|
+
} catch (error) {
|
|
117
|
+
throw new Error(`Git workspace root cannot be canonicalized: ${output}`, { cause: error });
|
|
118
|
+
}
|
|
119
|
+
const guestRoot = await lexicalRoot(lexicalCwd, hostRoot);
|
|
120
|
+
if (guestRoot === null) {
|
|
121
|
+
throw new Error("Git workspace root cannot preserve the cwd guest path; unsupported symlink topology");
|
|
122
|
+
}
|
|
123
|
+
assertSafeGuestRoot(guestRoot);
|
|
124
|
+
|
|
125
|
+
return { hostRoot, guestRoot, cwd: lexicalCwd, fromGit: true };
|
|
126
|
+
}
|
|
Binary file
|
package/package.json
CHANGED
package/extensions/pi-msb/git.ts
DELETED
|
@@ -1,256 +0,0 @@
|
|
|
1
|
-
import { execFile } from "node:child_process";
|
|
2
|
-
import { chmod, mkdir, mkdtemp, realpath, rm } from "node:fs/promises";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
5
|
-
import { pathToFileURL } from "node:url";
|
|
6
|
-
import { promisify } from "node:util";
|
|
7
|
-
|
|
8
|
-
import type { ExecFn, GitRepoInfo, GitSeedBundle } from "./types.ts";
|
|
9
|
-
|
|
10
|
-
const execFileAsync = promisify(execFile);
|
|
11
|
-
const SEED_REF = "refs/pi-msb/seed";
|
|
12
|
-
|
|
13
|
-
interface ExecFileFailure {
|
|
14
|
-
code?: number | string;
|
|
15
|
-
killed?: boolean;
|
|
16
|
-
signal?: string | null;
|
|
17
|
-
stdout?: string | Buffer;
|
|
18
|
-
stderr?: string | Buffer;
|
|
19
|
-
message?: string;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** Execute a program without giving it a shell or interpolating its arguments. */
|
|
23
|
-
const defaultExec: ExecFn = async (command, args, options = {}) => {
|
|
24
|
-
try {
|
|
25
|
-
const result = await execFileAsync(command, args, {
|
|
26
|
-
cwd: options.cwd,
|
|
27
|
-
env: options.env,
|
|
28
|
-
timeout: options.timeout === undefined ? undefined : options.timeout * 1000,
|
|
29
|
-
maxBuffer: 16 * 1024 * 1024,
|
|
30
|
-
encoding: "utf8",
|
|
31
|
-
});
|
|
32
|
-
return {
|
|
33
|
-
stdout: String(result.stdout),
|
|
34
|
-
stderr: String(result.stderr),
|
|
35
|
-
code: 0,
|
|
36
|
-
};
|
|
37
|
-
} catch (error: unknown) {
|
|
38
|
-
const failure = error as ExecFileFailure;
|
|
39
|
-
const numericCode = typeof failure.code === "number" ? failure.code : 127;
|
|
40
|
-
return {
|
|
41
|
-
stdout: String(failure.stdout ?? ""),
|
|
42
|
-
stderr: String(failure.stderr ?? failure.message ?? ""),
|
|
43
|
-
code: numericCode,
|
|
44
|
-
killed: Boolean(failure.killed || failure.signal),
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
const emptyRepo = (hostCwd?: string): GitRepoInfo => ({
|
|
50
|
-
isGitRepo: false,
|
|
51
|
-
...(hostCwd ? { hostCwd } : {}),
|
|
52
|
-
repoRoot: null,
|
|
53
|
-
branch: null,
|
|
54
|
-
headSha: null,
|
|
55
|
-
unborn: false,
|
|
56
|
-
isLinkedWorktree: false,
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
function output(result: Awaited<ReturnType<ExecFn>>): string {
|
|
60
|
-
return result.stdout.trim();
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function failureMessage(context: string, result: Awaited<ReturnType<ExecFn>>): Error {
|
|
64
|
-
const detail = result.stderr.trim() || `git exited with code ${result.code}`;
|
|
65
|
-
return new Error(`${context}: ${detail}`);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
async function canonicalPath(value: string): Promise<string> {
|
|
69
|
-
try {
|
|
70
|
-
return await realpath(value);
|
|
71
|
-
} catch {
|
|
72
|
-
return resolve(value);
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function pathFromGitResult(value: string, base: string): string {
|
|
77
|
-
return isAbsolute(value) ? value : resolve(base, value);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
async function lexicalRepoRoot(requestedCwd: string, canonicalRepoRoot: string): Promise<string | null> {
|
|
81
|
-
let candidate = requestedCwd;
|
|
82
|
-
while (true) {
|
|
83
|
-
if (await canonicalPath(candidate) === canonicalRepoRoot) return candidate;
|
|
84
|
-
const parent = dirname(candidate);
|
|
85
|
-
if (parent === candidate) return null;
|
|
86
|
-
candidate = parent;
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Return the immutable source state of cwd. A directory outside Git is an ordinary
|
|
92
|
-
* result rather than an error, which lets storage mode `auto` remain a direct alias.
|
|
93
|
-
*/
|
|
94
|
-
export async function detectGitRepo(cwd: string, exec: ExecFn = defaultExec): Promise<GitRepoInfo> {
|
|
95
|
-
const requestedCwd = resolve(cwd);
|
|
96
|
-
const hostCwd = await canonicalPath(requestedCwd);
|
|
97
|
-
const topLevel = await exec("git", ["-C", requestedCwd, "rev-parse", "--show-toplevel"]);
|
|
98
|
-
if (topLevel.code !== 0 || topLevel.killed || !output(topLevel)) return emptyRepo(hostCwd);
|
|
99
|
-
|
|
100
|
-
const repoRoot = await canonicalPath(output(topLevel));
|
|
101
|
-
const guestRepoRoot = await lexicalRepoRoot(requestedCwd, repoRoot);
|
|
102
|
-
const branchResult = await exec("git", [
|
|
103
|
-
"-C",
|
|
104
|
-
repoRoot,
|
|
105
|
-
"symbolic-ref",
|
|
106
|
-
"--quiet",
|
|
107
|
-
"--short",
|
|
108
|
-
"HEAD",
|
|
109
|
-
]);
|
|
110
|
-
const branch = branchResult.code === 0 && !branchResult.killed && output(branchResult)
|
|
111
|
-
? output(branchResult)
|
|
112
|
-
: null;
|
|
113
|
-
|
|
114
|
-
const headResult = await exec("git", ["-C", repoRoot, "rev-parse", "--verify", "HEAD"]);
|
|
115
|
-
const headSha = headResult.code === 0 && !headResult.killed && output(headResult)
|
|
116
|
-
? output(headResult)
|
|
117
|
-
: null;
|
|
118
|
-
|
|
119
|
-
// In a linked worktree --git-dir points at .git/worktrees/<name>, while
|
|
120
|
-
// --git-common-dir points at the shared .git directory.
|
|
121
|
-
const gitDirResult = await exec("git", ["-C", repoRoot, "rev-parse", "--git-dir"]);
|
|
122
|
-
const commonDirResult = await exec("git", ["-C", repoRoot, "rev-parse", "--git-common-dir"]);
|
|
123
|
-
let isLinkedWorktree = false;
|
|
124
|
-
if (gitDirResult.code === 0 && commonDirResult.code === 0) {
|
|
125
|
-
const gitDir = await canonicalPath(pathFromGitResult(output(gitDirResult), repoRoot));
|
|
126
|
-
const commonDir = await canonicalPath(pathFromGitResult(output(commonDirResult), repoRoot));
|
|
127
|
-
isLinkedWorktree = gitDir !== commonDir;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
return {
|
|
131
|
-
isGitRepo: true,
|
|
132
|
-
hostCwd,
|
|
133
|
-
repoRoot,
|
|
134
|
-
guestRepoRoot,
|
|
135
|
-
branch,
|
|
136
|
-
headSha,
|
|
137
|
-
unborn: headSha === null && branch !== null,
|
|
138
|
-
isLinkedWorktree,
|
|
139
|
-
};
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
function bundleFailure(context: string, result: Awaited<ReturnType<ExecFn>>): never {
|
|
143
|
-
throw failureMessage(context, result);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
async function checkedGit(
|
|
147
|
-
exec: ExecFn,
|
|
148
|
-
args: string[],
|
|
149
|
-
context: string,
|
|
150
|
-
): Promise<Awaited<ReturnType<ExecFn>>> {
|
|
151
|
-
const result = await exec("git", args);
|
|
152
|
-
if (result.code !== 0 || result.killed) bundleFailure(context, result);
|
|
153
|
-
return result;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* Make a disposable bundle from committed objects only. The temporary bare clone
|
|
158
|
-
* deliberately has no worktree, so ignored, modified, and untracked files cannot
|
|
159
|
-
* enter the seed.
|
|
160
|
-
*/
|
|
161
|
-
export async function createSeedBundle(
|
|
162
|
-
info: GitRepoInfo,
|
|
163
|
-
options: {
|
|
164
|
-
branch: "current" | string;
|
|
165
|
-
depth: number | "unlimited";
|
|
166
|
-
tempRoot?: string;
|
|
167
|
-
exec?: ExecFn;
|
|
168
|
-
},
|
|
169
|
-
): Promise<GitSeedBundle | null> {
|
|
170
|
-
if (!info.isGitRepo || info.unborn || !info.repoRoot || !info.headSha) return null;
|
|
171
|
-
if (options.depth !== "unlimited" && (!Number.isInteger(options.depth) || options.depth < 1)) {
|
|
172
|
-
throw new Error(`invalid Git bundle depth: ${String(options.depth)}`);
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
const exec = options.exec ?? defaultExec;
|
|
176
|
-
const parent = resolve(options.tempRoot ?? tmpdir());
|
|
177
|
-
await mkdir(parent, { recursive: true, mode: 0o700 });
|
|
178
|
-
const tempDir = await mkdtemp(join(parent, "pi-msb-git-"));
|
|
179
|
-
await chmod(tempDir, 0o700);
|
|
180
|
-
let cleaned = false;
|
|
181
|
-
const cleanup = async (): Promise<void> => {
|
|
182
|
-
if (cleaned) return;
|
|
183
|
-
cleaned = true;
|
|
184
|
-
await rm(tempDir, { recursive: true, force: true });
|
|
185
|
-
};
|
|
186
|
-
|
|
187
|
-
try {
|
|
188
|
-
const bareName = "source.git";
|
|
189
|
-
const barePath = join(tempDir, bareName);
|
|
190
|
-
const bundlePath = join(tempDir, "seed.bundle");
|
|
191
|
-
const source = fileUrl(info.repoRoot);
|
|
192
|
-
const depthArgs = options.depth === "unlimited" ? [] : ["--depth", String(options.depth)];
|
|
193
|
-
|
|
194
|
-
// A file:// URL forces Git's local transport while keeping the source repo
|
|
195
|
-
// read-only. --bare means no checkout, filters, or worktree are involved.
|
|
196
|
-
await checkedGit(
|
|
197
|
-
exec,
|
|
198
|
-
["-C", tempDir, "clone", "--bare", "--no-tags", ...depthArgs, source, bareName],
|
|
199
|
-
"creating temporary bare clone",
|
|
200
|
-
);
|
|
201
|
-
|
|
202
|
-
const objectCheck = await exec("git", ["-C", barePath, "cat-file", "-e", `${info.headSha}^{commit}`]);
|
|
203
|
-
if (objectCheck.code !== 0 || objectCheck.killed) {
|
|
204
|
-
// A branch may have moved between detection and clone. Fetch the captured
|
|
205
|
-
// object explicitly; never fall back to whatever HEAD the clone obtained.
|
|
206
|
-
await checkedGit(
|
|
207
|
-
exec,
|
|
208
|
-
["-C", barePath, "fetch", "--no-tags", ...depthArgs, source, info.headSha],
|
|
209
|
-
`fetching captured HEAD ${info.headSha}`,
|
|
210
|
-
);
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
const verifiedObject = await exec("git", ["-C", barePath, "cat-file", "-e", `${info.headSha}^{commit}`]);
|
|
214
|
-
if (verifiedObject.code !== 0 || verifiedObject.killed) {
|
|
215
|
-
bundleFailure(`captured HEAD ${info.headSha} is not present in temporary clone`, verifiedObject);
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
await checkedGit(
|
|
219
|
-
exec,
|
|
220
|
-
["-C", barePath, "update-ref", SEED_REF, info.headSha],
|
|
221
|
-
"updating temporary seed ref",
|
|
222
|
-
);
|
|
223
|
-
await checkedGit(
|
|
224
|
-
exec,
|
|
225
|
-
["-C", barePath, "bundle", "create", bundlePath, SEED_REF],
|
|
226
|
-
"creating Git bundle",
|
|
227
|
-
);
|
|
228
|
-
await checkedGit(exec, ["-C", barePath, "bundle", "verify", bundlePath], "verifying Git bundle");
|
|
229
|
-
const heads = await checkedGit(
|
|
230
|
-
exec,
|
|
231
|
-
["-C", barePath, "bundle", "list-heads", bundlePath],
|
|
232
|
-
"listing Git bundle heads",
|
|
233
|
-
);
|
|
234
|
-
const expectedHead = heads.stdout
|
|
235
|
-
.split(/\r?\n/)
|
|
236
|
-
.map((line) => line.trim().split(/\s+/))
|
|
237
|
-
.find(([sha, ref]) => sha === info.headSha && ref === SEED_REF);
|
|
238
|
-
if (!expectedHead) {
|
|
239
|
-
throw new Error(`Git bundle did not contain captured HEAD ${info.headSha} at ${SEED_REF}`);
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
return {
|
|
243
|
-
hostPath: bundlePath,
|
|
244
|
-
branch: options.branch === "current" ? info.branch : options.branch,
|
|
245
|
-
headSha: info.headSha,
|
|
246
|
-
cleanup,
|
|
247
|
-
};
|
|
248
|
-
} catch (error: unknown) {
|
|
249
|
-
await cleanup().catch(() => undefined);
|
|
250
|
-
throw error;
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
export function fileUrl(path: string): string {
|
|
255
|
-
return pathToFileURL(path).href;
|
|
256
|
-
}
|