1394 lines
45 KiB
TypeScript
1394 lines
45 KiB
TypeScript
import { execFile, spawn, type ChildProcess } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import { constants } from "node:fs";
|
|
import {
|
|
lstat,
|
|
mkdir,
|
|
open,
|
|
readFile,
|
|
readlink,
|
|
realpath,
|
|
rm,
|
|
} from "node:fs/promises";
|
|
import * as path from "node:path";
|
|
import { promisify } from "node:util";
|
|
import { inflateSync } from "node:zlib";
|
|
import type {
|
|
SourceChangeEvidence,
|
|
WorkflowWorkspace,
|
|
} from "./domain.ts";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
export interface CommandResult {
|
|
code: number;
|
|
stdout: string;
|
|
stderr: string;
|
|
}
|
|
|
|
export interface CommandRunOptions {
|
|
env?: NodeJS.ProcessEnv;
|
|
timeoutMs?: number;
|
|
}
|
|
|
|
export interface CommandRunner {
|
|
run(
|
|
cwd: string,
|
|
executable: string,
|
|
args: string[],
|
|
options?: CommandRunOptions,
|
|
): Promise<CommandResult>;
|
|
}
|
|
|
|
export class WorkspaceSafetyError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = "WorkspaceSafetyError";
|
|
}
|
|
}
|
|
|
|
export interface GitStateSnapshot {
|
|
root: string;
|
|
branch: string;
|
|
head: string;
|
|
commonDirectory: string;
|
|
gitDirectory: string;
|
|
worktreeListDigest: string;
|
|
worktreeMetadataDigest: string;
|
|
status: string;
|
|
changedPaths: string[];
|
|
pathStatuses: Record<string, string>;
|
|
pathDigests: Record<string, string>;
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function pngCrc32(contents: Uint8Array) {
|
|
let crc = 0xffffffff;
|
|
for (const byte of contents) {
|
|
crc ^= byte;
|
|
for (let bit = 0; bit < 8; bit++) {
|
|
crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
|
|
}
|
|
}
|
|
return (crc ^ 0xffffffff) >>> 0;
|
|
}
|
|
|
|
function paethPredictor(left: number, above: number, upperLeft: number) {
|
|
const estimate = left + above - upperLeft;
|
|
const leftDistance = Math.abs(estimate - left);
|
|
const aboveDistance = Math.abs(estimate - above);
|
|
const upperLeftDistance = Math.abs(estimate - upperLeft);
|
|
if (leftDistance <= aboveDistance && leftDistance <= upperLeftDistance) return left;
|
|
return aboveDistance <= upperLeftDistance ? above : upperLeft;
|
|
}
|
|
|
|
function parsePngVisual(contents: Buffer) {
|
|
const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
if (
|
|
contents.byteLength < signature.byteLength ||
|
|
!contents.subarray(0, signature.byteLength).equals(signature)
|
|
) {
|
|
throw new Error("invalid PNG signature");
|
|
}
|
|
let offset = signature.byteLength;
|
|
let width = 0;
|
|
let height = 0;
|
|
let bitDepth = 0;
|
|
let colorType = -1;
|
|
let palette = Buffer.alloc(0);
|
|
const compressed: Buffer[] = [];
|
|
let sawHeader = false;
|
|
let sawEnd = false;
|
|
while (offset < contents.byteLength) {
|
|
if (offset + 12 > contents.byteLength) throw new Error("truncated PNG chunk");
|
|
const length = contents.readUInt32BE(offset);
|
|
const end = offset + 12 + length;
|
|
if (end > contents.byteLength) throw new Error("truncated PNG data");
|
|
const type = contents.subarray(offset + 4, offset + 8);
|
|
const data = contents.subarray(offset + 8, offset + 8 + length);
|
|
const expectedCrc = contents.readUInt32BE(offset + 8 + length);
|
|
if (pngCrc32(Buffer.concat([type, data])) !== expectedCrc) {
|
|
throw new Error("invalid PNG checksum");
|
|
}
|
|
const name = type.toString("ascii");
|
|
if (name === "IHDR") {
|
|
if (sawHeader || length !== 13 || offset !== signature.byteLength) {
|
|
throw new Error("invalid PNG header");
|
|
}
|
|
sawHeader = true;
|
|
width = data.readUInt32BE(0);
|
|
height = data.readUInt32BE(4);
|
|
bitDepth = data[8] ?? 0;
|
|
colorType = data[9] ?? -1;
|
|
if (
|
|
width < 1 ||
|
|
height < 1 ||
|
|
width > 16_384 ||
|
|
height > 16_384 ||
|
|
data[10] !== 0 ||
|
|
data[11] !== 0 ||
|
|
data[12] !== 0
|
|
) {
|
|
throw new Error("unsupported PNG header");
|
|
}
|
|
} else if (name === "IDAT") {
|
|
compressed.push(Buffer.from(data));
|
|
} else if (name === "PLTE" || name === "tRNS") {
|
|
palette = Buffer.concat([palette, Buffer.from(type), Buffer.from(data)]);
|
|
} else if (name === "IEND") {
|
|
if (length !== 0) throw new Error("invalid PNG end chunk");
|
|
sawEnd = true;
|
|
offset = end;
|
|
break;
|
|
}
|
|
offset = end;
|
|
}
|
|
if (!sawHeader || !sawEnd || compressed.length === 0 || offset !== contents.byteLength) {
|
|
throw new Error("incomplete PNG image");
|
|
}
|
|
const channels = new Map([
|
|
[0, 1],
|
|
[2, 3],
|
|
[3, 1],
|
|
[4, 2],
|
|
[6, 4],
|
|
]).get(colorType);
|
|
if (!channels || ![1, 2, 4, 8, 16].includes(bitDepth)) {
|
|
throw new Error("unsupported PNG color format");
|
|
}
|
|
const rowBytes = Math.ceil((width * channels * bitDepth) / 8);
|
|
const bytesPerPixel = Math.max(1, Math.ceil((channels * bitDepth) / 8));
|
|
const filtered = inflateSync(Buffer.concat(compressed), {
|
|
maxOutputLength: (rowBytes + 1) * height,
|
|
});
|
|
if (filtered.byteLength !== (rowBytes + 1) * height) {
|
|
throw new Error("invalid PNG scanline length");
|
|
}
|
|
const pixels = Buffer.alloc(rowBytes * height);
|
|
for (let row = 0; row < height; row++) {
|
|
const sourceOffset = row * (rowBytes + 1);
|
|
const targetOffset = row * rowBytes;
|
|
const filter = filtered[sourceOffset];
|
|
if (filter === undefined || filter > 4) throw new Error("invalid PNG filter");
|
|
for (let column = 0; column < rowBytes; column++) {
|
|
const encoded = filtered[sourceOffset + 1 + column] ?? 0;
|
|
const left = column >= bytesPerPixel ? (pixels[targetOffset + column - bytesPerPixel] ?? 0) : 0;
|
|
const above = row > 0 ? (pixels[targetOffset - rowBytes + column] ?? 0) : 0;
|
|
const upperLeft =
|
|
row > 0 && column >= bytesPerPixel
|
|
? (pixels[targetOffset - rowBytes + column - bytesPerPixel] ?? 0)
|
|
: 0;
|
|
const predictor =
|
|
filter === 0
|
|
? 0
|
|
: filter === 1
|
|
? left
|
|
: filter === 2
|
|
? above
|
|
: filter === 3
|
|
? Math.floor((left + above) / 2)
|
|
: paethPredictor(left, above, upperLeft);
|
|
pixels[targetOffset + column] = (encoded + predictor) & 0xff;
|
|
}
|
|
}
|
|
return {
|
|
width,
|
|
height,
|
|
digest: createHash("sha256")
|
|
.update(`${width}:${height}:${bitDepth}:${colorType}\n`)
|
|
.update(palette)
|
|
.update(pixels)
|
|
.digest("hex"),
|
|
};
|
|
}
|
|
|
|
function isWithin(root: string, candidate: string) {
|
|
return candidate === root || candidate.startsWith(`${root}${path.sep}`);
|
|
}
|
|
|
|
async function nearestExisting(candidate: string, floor: string) {
|
|
let current = candidate;
|
|
while (isWithin(floor, current)) {
|
|
try {
|
|
await lstat(current);
|
|
return current;
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
}
|
|
if (current === floor) break;
|
|
current = path.dirname(current);
|
|
}
|
|
throw new Error(`No existing ancestor for ${candidate} is inside ${floor}`);
|
|
}
|
|
|
|
export class MutationScope {
|
|
readonly root: string;
|
|
readonly ownedRoots: string[];
|
|
|
|
private constructor(root: string, ownedRoots: string[]) {
|
|
this.root = root;
|
|
this.ownedRoots = ownedRoots;
|
|
}
|
|
|
|
static async create(worktreeRoot: string, ownedPaths: string[]) {
|
|
if (ownedPaths.length === 0) throw new Error("Mutation ownership cannot be empty");
|
|
const root = await realpath(path.resolve(worktreeRoot));
|
|
const ownedRoots = ownedPaths.map((ownedPath) => {
|
|
if (!ownedPath || path.isAbsolute(ownedPath)) {
|
|
throw new Error(`Owned path must be relative: ${ownedPath}`);
|
|
}
|
|
const resolved = path.resolve(root, ownedPath);
|
|
if (!isWithin(root, resolved)) {
|
|
throw new Error(`Owned path escapes the worktree: ${ownedPath}`);
|
|
}
|
|
return resolved;
|
|
});
|
|
return new MutationScope(root, [...new Set(ownedRoots)]);
|
|
}
|
|
|
|
async remove(targetPath: string) {
|
|
const target = await this.assertWritePath(targetPath);
|
|
await rm(target, { recursive: true, force: true });
|
|
}
|
|
|
|
owns(candidate: string) {
|
|
const absolute = path.isAbsolute(candidate)
|
|
? path.resolve(candidate)
|
|
: path.resolve(this.root, candidate);
|
|
return (
|
|
isWithin(this.root, absolute) &&
|
|
this.ownedRoots.some((ownedRoot) => isWithin(ownedRoot, absolute))
|
|
);
|
|
}
|
|
|
|
async assertWritePath(candidate: string) {
|
|
const absolute = path.isAbsolute(candidate)
|
|
? path.resolve(candidate)
|
|
: path.resolve(this.root, candidate);
|
|
if (!this.owns(absolute)) {
|
|
throw new Error(`Write path is outside Assignment ownership: ${candidate}`);
|
|
}
|
|
const ancestor = await nearestExisting(absolute, this.root);
|
|
const canonicalAncestor = await realpath(ancestor);
|
|
if (!isWithin(this.root, canonicalAncestor)) {
|
|
throw new Error(`Write path resolves outside the worktree: ${candidate}`);
|
|
}
|
|
let current = this.root;
|
|
const relative = path.relative(this.root, absolute);
|
|
for (const segment of relative.split(path.sep).filter(Boolean)) {
|
|
current = path.join(current, segment);
|
|
try {
|
|
const details = await lstat(current);
|
|
if (details.isSymbolicLink()) {
|
|
throw new Error(`Write path traverses a symbolic link: ${candidate}`);
|
|
}
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") break;
|
|
throw error;
|
|
}
|
|
}
|
|
return absolute;
|
|
}
|
|
|
|
async writeFile(targetPath: string, content: string) {
|
|
const target = await this.assertWritePath(targetPath);
|
|
const handle = await open(
|
|
target,
|
|
constants.O_CREAT | constants.O_WRONLY | constants.O_NOFOLLOW,
|
|
0o666,
|
|
);
|
|
try {
|
|
const descriptorPath = await realpath(`/proc/self/fd/${handle.fd}`).catch(
|
|
() => realpath(target),
|
|
);
|
|
if (!this.owns(descriptorPath)) {
|
|
throw new WorkspaceSafetyError(
|
|
`Opened write target escapes worktree ownership: ${targetPath}`,
|
|
);
|
|
}
|
|
await handle.truncate(0);
|
|
await handle.writeFile(content, "utf8");
|
|
} finally {
|
|
await handle.close();
|
|
}
|
|
}
|
|
|
|
async mkdir(targetPath: string) {
|
|
const target = await this.assertWritePath(targetPath);
|
|
const relative = path.relative(this.root, target);
|
|
let current = this.root;
|
|
for (const segment of relative.split(path.sep).filter(Boolean)) {
|
|
current = path.join(current, segment);
|
|
await mkdir(current).catch((error: NodeJS.ErrnoException) => {
|
|
if (error.code !== "EEXIST") throw error;
|
|
});
|
|
const canonical = await realpath(current);
|
|
if (this.owns(current) && !this.owns(canonical)) {
|
|
throw new WorkspaceSafetyError(
|
|
`Created directory escapes worktree ownership: ${targetPath}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async function pathDigest(root: string, relativePath: string) {
|
|
const target = path.resolve(root, relativePath);
|
|
try {
|
|
const details = await lstat(target);
|
|
if (details.isSymbolicLink()) {
|
|
return `symlink:${await readlink(target)}`;
|
|
}
|
|
if (details.isDirectory()) return "directory";
|
|
const content = await readFile(target);
|
|
return `file:${createHash("sha256").update(content).digest("hex")}`;
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") return "missing";
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function changedPathStatuses(status: string) {
|
|
const entries = status.split("\0");
|
|
const statuses: Record<string, string> = {};
|
|
for (let index = 0; index < entries.length; index++) {
|
|
const entry = entries[index];
|
|
if (!entry) continue;
|
|
const code = entry.slice(0, 2);
|
|
const file = entry.slice(3);
|
|
if (file) statuses[file] = code;
|
|
if (code.includes("R") || code.includes("C")) {
|
|
const renamed = entries[++index];
|
|
if (renamed) statuses[renamed] = code;
|
|
}
|
|
}
|
|
return statuses;
|
|
}
|
|
|
|
export class GitStateProbe {
|
|
private readonly runner: CommandRunner;
|
|
|
|
constructor(runner: CommandRunner = new ExecFileCommandRunner()) {
|
|
this.runner = runner;
|
|
}
|
|
|
|
async captureRepository(worktreeRoot: string) {
|
|
const root = await realpath(path.resolve(worktreeRoot));
|
|
const listing = await this.git(root, ["worktree", "list", "--porcelain"]);
|
|
const roots = listing
|
|
.split("\n")
|
|
.filter((line) => line.startsWith("worktree "))
|
|
.map((line) => line.slice("worktree ".length).trim());
|
|
const snapshots = new Map<string, GitStateSnapshot>();
|
|
for (const candidate of roots) {
|
|
try {
|
|
const snapshot = await this.capture(candidate);
|
|
snapshots.set(snapshot.root, snapshot);
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
}
|
|
}
|
|
return snapshots;
|
|
}
|
|
|
|
async capture(worktreeRoot: string): Promise<GitStateSnapshot> {
|
|
const root = await realpath(path.resolve(worktreeRoot));
|
|
const [branch, head, common, gitDirectory, worktreeList, status] = await Promise.all([
|
|
this.git(root, ["branch", "--show-current"]),
|
|
this.git(root, ["rev-parse", "HEAD"]),
|
|
this.git(root, ["rev-parse", "--git-common-dir"]),
|
|
this.git(root, ["rev-parse", "--git-dir"]),
|
|
this.git(root, ["worktree", "list", "--porcelain"]),
|
|
this.git(root, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]),
|
|
]);
|
|
const commonDirectory = path.resolve(root, common.trim());
|
|
const resolvedGitDirectory = path.resolve(root, gitDirectory.trim());
|
|
const metadataHash = createHash("sha256");
|
|
for (const metadataPath of [
|
|
path.join(commonDirectory, "config"),
|
|
path.join(resolvedGitDirectory, "gitdir"),
|
|
path.join(resolvedGitDirectory, "commondir"),
|
|
path.join(resolvedGitDirectory, "HEAD"),
|
|
path.join(resolvedGitDirectory, "locked"),
|
|
path.join(resolvedGitDirectory, "config.worktree"),
|
|
]) {
|
|
metadataHash.update(metadataPath);
|
|
try {
|
|
metadataHash.update(await readFile(metadataPath));
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
metadataHash.update("missing");
|
|
}
|
|
}
|
|
const pathStatuses = changedPathStatuses(status);
|
|
const paths = Object.keys(pathStatuses).sort();
|
|
const pathDigests = Object.fromEntries(
|
|
await Promise.all(
|
|
paths.map(async (changed) => [changed, await pathDigest(root, changed)] as const),
|
|
),
|
|
);
|
|
return {
|
|
root,
|
|
branch: branch.trim(),
|
|
head: head.trim(),
|
|
commonDirectory,
|
|
gitDirectory: resolvedGitDirectory,
|
|
worktreeListDigest: createHash("sha256")
|
|
.update(worktreeList)
|
|
.digest("hex"),
|
|
worktreeMetadataDigest: metadataHash.digest("hex"),
|
|
status,
|
|
changedPaths: paths,
|
|
pathStatuses,
|
|
pathDigests,
|
|
};
|
|
}
|
|
|
|
changedSince(before: GitStateSnapshot, after: GitStateSnapshot) {
|
|
const paths = [...new Set([...before.changedPaths, ...after.changedPaths])];
|
|
return paths.filter(
|
|
(changed) =>
|
|
before.pathStatuses[changed] !== after.pathStatuses[changed] ||
|
|
before.pathDigests[changed] !== after.pathDigests[changed],
|
|
);
|
|
}
|
|
|
|
assertIdentity(before: GitStateSnapshot, after: GitStateSnapshot) {
|
|
if (
|
|
before.root !== after.root ||
|
|
before.branch !== after.branch ||
|
|
before.head !== after.head ||
|
|
before.commonDirectory !== after.commonDirectory ||
|
|
before.gitDirectory !== after.gitDirectory ||
|
|
before.worktreeListDigest !== after.worktreeListDigest ||
|
|
before.worktreeMetadataDigest !== after.worktreeMetadataDigest
|
|
) {
|
|
throw new WorkspaceSafetyError(
|
|
"Git branch, HEAD, or worktree identity changed during mutation",
|
|
);
|
|
}
|
|
}
|
|
|
|
private async git(cwd: string, args: string[]) {
|
|
const result = await this.runner.run(cwd, "git", args, {
|
|
timeoutMs: INTERNAL_GIT_TIMEOUT_MS,
|
|
});
|
|
if (result.code !== 0) {
|
|
throw new Error(`Git state probe failed: ${result.stderr.trim()}`);
|
|
}
|
|
return result.stdout;
|
|
}
|
|
}
|
|
|
|
const SHELL_EXECUTABLES = new Set([
|
|
"bash",
|
|
"cmd",
|
|
"cmd.exe",
|
|
"fish",
|
|
"powershell",
|
|
"pwsh",
|
|
"sh",
|
|
"zsh",
|
|
]);
|
|
|
|
const COMMAND_WRAPPERS = new Set(["env", "find", "npx", "xargs"]);
|
|
const ENVIRONMENT_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
const INLINE_EVALUATION_FLAGS = new Map<string, Set<string>>([
|
|
["node", new Set(["-e", "--eval", "-p", "--print"])],
|
|
["python", new Set(["-c"])],
|
|
["python3", new Set(["-c"])],
|
|
["ruby", new Set(["-e"])],
|
|
["perl", new Set(["-e", "-E"])],
|
|
]);
|
|
const PACKAGE_EXEC_SUBCOMMANDS = new Map<string, Set<string>>([
|
|
["npm", new Set(["exec", "x"])],
|
|
["pnpm", new Set(["dlx", "exec"])],
|
|
["yarn", new Set(["dlx", "exec"])],
|
|
["bun", new Set(["x"])],
|
|
]);
|
|
|
|
export const INTERNAL_GIT_TIMEOUT_MS = 30_000;
|
|
const DEFAULT_HOST_COMMAND_TIMEOUT_MS = 3 * 60 * 1_000;
|
|
|
|
export class ApprovedHostCommandExecutor {
|
|
private readonly runner: CommandRunner;
|
|
private readonly probe: GitStateProbe;
|
|
private readonly timeoutMs: number;
|
|
|
|
constructor(
|
|
runner: CommandRunner = new ExecFileCommandRunner(),
|
|
probe: GitStateProbe = new GitStateProbe(runner),
|
|
timeoutMs = DEFAULT_HOST_COMMAND_TIMEOUT_MS,
|
|
) {
|
|
this.runner = runner;
|
|
this.probe = probe;
|
|
this.timeoutMs = timeoutMs;
|
|
}
|
|
|
|
async run(options: {
|
|
worktreeRoot: string;
|
|
ownedPaths: string[];
|
|
executable: string;
|
|
args: string[];
|
|
}) {
|
|
if (!options.executable || options.args.some((argument) => argument.includes("\0"))) {
|
|
throw new Error("Host commands require a fixed executable and argument array");
|
|
}
|
|
let executable = options.executable;
|
|
let args = [...options.args];
|
|
const commandEnv: NodeJS.ProcessEnv = {
|
|
...process.env,
|
|
PYTHONDONTWRITEBYTECODE: "1",
|
|
};
|
|
if (
|
|
path.basename(executable).toLowerCase() === "env" &&
|
|
args[0] === "PI_OFFLINE=1" &&
|
|
args[1] &&
|
|
!ENVIRONMENT_ASSIGNMENT.test(args[1])
|
|
) {
|
|
commandEnv.PI_OFFLINE = "1";
|
|
executable = args[1];
|
|
args = args.slice(2);
|
|
}
|
|
const executableName = path.basename(executable).toLowerCase();
|
|
if (
|
|
SHELL_EXECUTABLES.has(executableName) ||
|
|
COMMAND_WRAPPERS.has(executableName)
|
|
) {
|
|
throw new Error("Host commands cannot invoke a command wrapper or shell");
|
|
}
|
|
const evaluationFlags = INLINE_EVALUATION_FLAGS.get(executableName);
|
|
if (evaluationFlags && args.some((argument) => evaluationFlags.has(argument))) {
|
|
throw new Error("Host commands cannot execute inline interpreter code");
|
|
}
|
|
const packageExecCommands = PACKAGE_EXEC_SUBCOMMANDS.get(executableName);
|
|
if (packageExecCommands?.has(args[0] ?? "")) {
|
|
throw new Error("Host commands cannot invoke package-manager executable wrappers");
|
|
}
|
|
if (executableName === "git") {
|
|
throw new Error(
|
|
"Git host commands are not allowed; workspace Git operations are managed internally",
|
|
);
|
|
}
|
|
const root = await realpath(path.resolve(options.worktreeRoot));
|
|
const scope =
|
|
options.ownedPaths.length > 0
|
|
? await MutationScope.create(root, options.ownedPaths)
|
|
: undefined;
|
|
const beforeRepository = await this.probe.captureRepository(root);
|
|
const before = beforeRepository.get(root) ?? (await this.probe.capture(root));
|
|
const result = await this.runner.run(root, executable, args, {
|
|
env: commandEnv,
|
|
timeoutMs: this.timeoutMs,
|
|
});
|
|
try {
|
|
const afterRepository = await this.probe.captureRepository(root);
|
|
const after = afterRepository.get(root) ?? (await this.probe.capture(root));
|
|
this.probe.assertIdentity(before, after);
|
|
for (const [repositoryRoot, repositoryBefore] of beforeRepository) {
|
|
if (repositoryRoot === root) continue;
|
|
const repositoryAfter = afterRepository.get(repositoryRoot);
|
|
if (!repositoryAfter) {
|
|
throw new WorkspaceSafetyError(
|
|
`Host command removed repository worktree ${repositoryRoot}`,
|
|
);
|
|
}
|
|
this.probe.assertIdentity(repositoryBefore, repositoryAfter);
|
|
const touched = this.probe.changedSince(repositoryBefore, repositoryAfter);
|
|
if (touched.length > 0) {
|
|
throw new WorkspaceSafetyError(
|
|
`Host command changed another repository worktree: ${repositoryRoot}`,
|
|
);
|
|
}
|
|
}
|
|
const touchedPaths = this.probe.changedSince(before, after);
|
|
const outOfScope = scope
|
|
? touchedPaths.filter((changed) => !scope.owns(changed))
|
|
: touchedPaths;
|
|
if (outOfScope.length > 0) {
|
|
throw new WorkspaceSafetyError(
|
|
`Host command changed paths outside ownership: ${outOfScope.join(", ")}`,
|
|
);
|
|
}
|
|
return { result, before, after };
|
|
} catch (error) {
|
|
if (error instanceof WorkspaceSafetyError) throw error;
|
|
throw new WorkspaceSafetyError(
|
|
`Unable to verify Git safety after host command: ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
export class FilesystemPrototypeValidator {
|
|
async validateCandidate(options: {
|
|
worktreeRoot: string;
|
|
assignment: { ownedPaths: string[] };
|
|
output: { path: string; route: string; browserEvidence: string[] };
|
|
}) {
|
|
const scope = await MutationScope.create(
|
|
options.worktreeRoot,
|
|
options.assignment.ownedPaths,
|
|
);
|
|
const candidatePath = await scope.assertWritePath(options.output.path);
|
|
const candidate = await lstat(candidatePath);
|
|
if (candidate.isSymbolicLink() || (!candidate.isDirectory() && !candidate.isFile())) {
|
|
throw new WorkspaceSafetyError("Prototype candidate path is not a real file or directory");
|
|
}
|
|
const widths: number[] = [];
|
|
const artifactFiles: string[] = [];
|
|
const screenshotDigests: string[] = [];
|
|
for (const evidencePath of options.output.browserEvidence) {
|
|
const absoluteEvidence = await scope.assertWritePath(evidencePath);
|
|
const evidence = await lstat(absoluteEvidence);
|
|
if (
|
|
evidence.isSymbolicLink() ||
|
|
!evidence.isFile() ||
|
|
evidence.size === 0 ||
|
|
evidence.size > 64 * 1024
|
|
) {
|
|
throw new WorkspaceSafetyError(
|
|
`Prototype browser evidence is missing or invalid: ${evidencePath}`,
|
|
);
|
|
}
|
|
let report: unknown;
|
|
try {
|
|
report = JSON.parse(await readFile(absoluteEvidence, "utf8"));
|
|
} catch {
|
|
throw new WorkspaceSafetyError(
|
|
`Prototype browser evidence must be a JSON validation report: ${evidencePath}`,
|
|
);
|
|
}
|
|
if (
|
|
!isRecord(report) ||
|
|
report.route !== options.output.route ||
|
|
report.result !== "passed" ||
|
|
!isRecord(report.viewport) ||
|
|
!Number.isSafeInteger(report.viewport.width) ||
|
|
!Number.isSafeInteger(report.viewport.height) ||
|
|
typeof report.screenshotPath !== "string"
|
|
) {
|
|
throw new WorkspaceSafetyError(
|
|
`Prototype browser evidence report is invalid: ${evidencePath}`,
|
|
);
|
|
}
|
|
const screenshotPath = await scope.assertWritePath(report.screenshotPath);
|
|
const screenshot = await lstat(screenshotPath);
|
|
if (
|
|
screenshot.isSymbolicLink() ||
|
|
!screenshot.isFile() ||
|
|
screenshot.size === 0
|
|
) {
|
|
throw new WorkspaceSafetyError(
|
|
`Prototype browser screenshot is invalid: ${report.screenshotPath}`,
|
|
);
|
|
}
|
|
const screenshotContents = await readFile(screenshotPath);
|
|
let visual: ReturnType<typeof parsePngVisual>;
|
|
try {
|
|
visual = parsePngVisual(screenshotContents);
|
|
} catch (error) {
|
|
throw new WorkspaceSafetyError(
|
|
`Prototype browser screenshot is not a decodable PNG: ${report.screenshotPath} (${error instanceof Error ? error.message : String(error)})`,
|
|
);
|
|
}
|
|
if (
|
|
visual.width !== report.viewport.width ||
|
|
visual.height !== report.viewport.height
|
|
) {
|
|
throw new WorkspaceSafetyError(
|
|
`Prototype screenshot dimensions do not match its viewport: ${report.screenshotPath}`,
|
|
);
|
|
}
|
|
artifactFiles.push(evidencePath, report.screenshotPath);
|
|
screenshotDigests.push(visual.digest);
|
|
widths.push(report.viewport.width);
|
|
}
|
|
if (!widths.some((width) => width <= 480) || !widths.some((width) => width >= 1024)) {
|
|
throw new WorkspaceSafetyError(
|
|
"Prototype browser evidence must include passed mobile and desktop viewports",
|
|
);
|
|
}
|
|
return {
|
|
artifactFiles: [...new Set(artifactFiles)],
|
|
visualDigest: createHash("sha256")
|
|
.update(screenshotDigests.sort().join("\n"))
|
|
.digest("hex"),
|
|
};
|
|
}
|
|
|
|
async validateCleanup(options: {
|
|
worktreeRoot: string;
|
|
candidates: Array<{ path: string }>;
|
|
}) {
|
|
const root = await realpath(path.resolve(options.worktreeRoot));
|
|
for (const candidate of options.candidates) {
|
|
const target = path.resolve(root, candidate.path);
|
|
if (!isWithin(root, target)) {
|
|
throw new WorkspaceSafetyError("Prototype cleanup candidate escapes worktree");
|
|
}
|
|
try {
|
|
await lstat(target);
|
|
throw new WorkspaceSafetyError(
|
|
`Prototype-only path remains after cleanup: ${candidate.path}`,
|
|
);
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export class GitDiffProvider {
|
|
private readonly runner: CommandRunner;
|
|
|
|
constructor(runner: CommandRunner = new ExecFileCommandRunner()) {
|
|
this.runner = runner;
|
|
}
|
|
|
|
async read(worktreeRoot: string, baseHead = "HEAD") {
|
|
const root = await realpath(path.resolve(worktreeRoot));
|
|
const diff = await this.runner.run(
|
|
root,
|
|
"git",
|
|
["diff", "--binary", "--no-ext-diff", baseHead, "--"],
|
|
{ timeoutMs: INTERNAL_GIT_TIMEOUT_MS },
|
|
);
|
|
if (diff.code !== 0) {
|
|
throw new Error(`Unable to read review diff: ${diff.stderr.trim()}`);
|
|
}
|
|
const untracked = await this.runner.run(
|
|
root,
|
|
"git",
|
|
["ls-files", "--others", "--exclude-standard", "-z"],
|
|
{ timeoutMs: INTERNAL_GIT_TIMEOUT_MS },
|
|
);
|
|
if (untracked.code !== 0) {
|
|
throw new Error(`Unable to list untracked review files: ${untracked.stderr.trim()}`);
|
|
}
|
|
const additions: string[] = [];
|
|
for (const relative of untracked.stdout.split("\0").filter(Boolean)) {
|
|
const target = path.resolve(root, relative);
|
|
if (!isWithin(root, target)) {
|
|
throw new WorkspaceSafetyError(`Untracked path escapes worktree: ${relative}`);
|
|
}
|
|
const details = await lstat(target);
|
|
if (details.isSymbolicLink()) {
|
|
additions.push(`## Untracked symlink ${relative}\n${await readlink(target)}`);
|
|
} else if (details.isFile()) {
|
|
additions.push(
|
|
`## Untracked file ${relative}\n${(await readFile(target)).toString("utf8")}`,
|
|
);
|
|
}
|
|
}
|
|
const completeDiff = `${diff.stdout}\n${additions.join("\n\n")}`;
|
|
if (Buffer.byteLength(completeDiff, "utf8") > 4 * 1024 * 1024) {
|
|
throw new WorkspaceSafetyError(
|
|
"Review diff exceeds the 4 MiB audited limit and cannot be reviewed completely",
|
|
);
|
|
}
|
|
return completeDiff;
|
|
}
|
|
}
|
|
|
|
export interface WorkspacePlan {
|
|
sourceRoot: string;
|
|
worktreeRoot: string;
|
|
worktreePath: string;
|
|
branch: string;
|
|
baseHead: string;
|
|
branchExists: boolean;
|
|
sourceChanges: SourceChangeEvidence[];
|
|
sourceChangesOmitted: number;
|
|
dirtySourceAllowed: boolean;
|
|
}
|
|
|
|
export interface PrepareWorkspaceOptions {
|
|
sourceRoot: string;
|
|
configuredRoot: string;
|
|
featureName: string;
|
|
runId: string;
|
|
allowDirtySource?: boolean;
|
|
}
|
|
|
|
export class WorkspaceValidationError extends Error {
|
|
readonly code:
|
|
| "not-repository"
|
|
| "dirty-source"
|
|
| "unmerged-source"
|
|
| "unignored-root"
|
|
| "existing-worktree-path"
|
|
| "git-failed";
|
|
readonly sourceChanges: SourceChangeEvidence[];
|
|
readonly sourceChangesOmitted: number;
|
|
|
|
constructor(
|
|
code: WorkspaceValidationError["code"],
|
|
message: string,
|
|
evidence: {
|
|
sourceChanges?: SourceChangeEvidence[];
|
|
sourceChangesOmitted?: number;
|
|
} = {},
|
|
) {
|
|
super(message);
|
|
this.name = "WorkspaceValidationError";
|
|
this.code = code;
|
|
this.sourceChanges = evidence.sourceChanges ?? [];
|
|
this.sourceChangesOmitted = evidence.sourceChangesOmitted ?? 0;
|
|
}
|
|
}
|
|
|
|
const MAX_SOURCE_CHANGE_EVIDENCE = 20;
|
|
|
|
function sourceChangeEvidence(status: string) {
|
|
const entries = Object.entries(changedPathStatuses(status)).sort(([left], [right]) =>
|
|
left.localeCompare(right),
|
|
);
|
|
const sourceChanges = entries
|
|
.slice(0, MAX_SOURCE_CHANGE_EVIDENCE)
|
|
.map(([changedPath, changedStatus]) => ({
|
|
path: changedPath.replace(/[\u0000-\u001f\u007f-\u009f]/g, "?"),
|
|
status: changedStatus,
|
|
}));
|
|
return {
|
|
sourceChanges,
|
|
sourceChangesOmitted: Math.max(0, entries.length - sourceChanges.length),
|
|
hasUnmerged: entries.some(([, changedStatus]) =>
|
|
["DD", "AU", "UD", "UA", "DU", "AA", "UU"].includes(changedStatus),
|
|
),
|
|
};
|
|
}
|
|
|
|
const PROCESS_TERMINATION_GRACE_MS = 100;
|
|
const PROCESS_TERMINATION_POLL_MS = 10;
|
|
const PROCESS_TERMINATION_TIMEOUT_MS = 1_000;
|
|
|
|
function wait(milliseconds: number) {
|
|
return new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
|
|
}
|
|
|
|
function processGroupExists(processId: number) {
|
|
try {
|
|
process.kill(-processId, 0);
|
|
return true;
|
|
} catch (error) {
|
|
return (error as NodeJS.ErrnoException).code !== "ESRCH";
|
|
}
|
|
}
|
|
|
|
function signalProcessGroup(processId: number, signal: NodeJS.Signals) {
|
|
try {
|
|
process.kill(-processId, signal);
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error;
|
|
}
|
|
}
|
|
|
|
async function forceKillAndReap(child: ChildProcess) {
|
|
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
if (process.platform !== "win32" && child.pid !== undefined) {
|
|
signalProcessGroup(child.pid, "SIGKILL");
|
|
}
|
|
child.kill("SIGKILL");
|
|
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
await new Promise<void>((resolve) => {
|
|
const timer = setTimeout(resolve, PROCESS_TERMINATION_GRACE_MS);
|
|
child.once("close", () => {
|
|
clearTimeout(timer);
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
async function terminateProcessTree(processId: number) {
|
|
if (process.platform === "win32") {
|
|
await execFileAsync(
|
|
"taskkill",
|
|
["/pid", String(processId), "/T", "/F"],
|
|
{ timeout: PROCESS_TERMINATION_TIMEOUT_MS },
|
|
);
|
|
return;
|
|
}
|
|
signalProcessGroup(processId, "SIGTERM");
|
|
await wait(PROCESS_TERMINATION_GRACE_MS);
|
|
signalProcessGroup(processId, "SIGKILL");
|
|
const deadline = Date.now() + PROCESS_TERMINATION_TIMEOUT_MS;
|
|
while (processGroupExists(processId)) {
|
|
if (Date.now() >= deadline) {
|
|
throw new Error(`process group ${processId} remained alive after SIGKILL`);
|
|
}
|
|
await wait(PROCESS_TERMINATION_POLL_MS);
|
|
}
|
|
}
|
|
|
|
const COMMAND_OUTPUT_LIMIT_BYTES = 4 * 1024 * 1024;
|
|
|
|
export class ExecFileCommandRunner implements CommandRunner {
|
|
private readonly terminate: (processId: number) => Promise<void>;
|
|
|
|
constructor(options: {
|
|
terminateProcessTree?: (processId: number) => Promise<void>;
|
|
} = {}) {
|
|
this.terminate = options.terminateProcessTree ?? terminateProcessTree;
|
|
}
|
|
|
|
async run(
|
|
cwd: string,
|
|
executable: string,
|
|
args: string[],
|
|
options: CommandRunOptions = {},
|
|
) {
|
|
return new Promise<CommandResult>((resolve) => {
|
|
const stdoutChunks: Buffer[] = [];
|
|
const stderrChunks: Buffer[] = [];
|
|
let stdoutBytes = 0;
|
|
let stderrBytes = 0;
|
|
let spawnError: Error | undefined;
|
|
let timedOut = false;
|
|
let outputExceeded = false;
|
|
let termination: Promise<void> | undefined;
|
|
let terminationFinished = false;
|
|
let terminationFailure: string | undefined;
|
|
let timeout: NodeJS.Timeout | undefined;
|
|
let terminationDeadline: NodeJS.Timeout | undefined;
|
|
let terminationDeadlineReached = false;
|
|
let settled = false;
|
|
const child = spawn(executable, args, {
|
|
cwd,
|
|
detached: process.platform !== "win32",
|
|
env: options.env,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
const terminate = () => {
|
|
if (!termination && child.pid !== undefined) {
|
|
termination = this.terminate(child.pid);
|
|
void termination.then(
|
|
() => { terminationFinished = true; },
|
|
(failure: unknown) => {
|
|
terminationFinished = true;
|
|
terminationFailure = failure instanceof Error
|
|
? failure.message
|
|
: String(failure);
|
|
},
|
|
);
|
|
terminationDeadline = setTimeout(() => {
|
|
if (settled) return;
|
|
terminationDeadlineReached = true;
|
|
const terminationError =
|
|
terminationFailure ??
|
|
(terminationFinished
|
|
? "process did not exit after process tree termination"
|
|
: "process tree termination did not finish within its deadline");
|
|
void forceKillAndReap(child).then(
|
|
() => {
|
|
if (settled) return;
|
|
settled = true;
|
|
resolve({
|
|
code: 125,
|
|
stdout: Buffer.concat(stdoutChunks).toString("utf8"),
|
|
stderr: `Host command termination failed: ${terminationError}`,
|
|
});
|
|
},
|
|
(failure: unknown) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
const detail = failure instanceof Error
|
|
? failure.message
|
|
: String(failure);
|
|
resolve({
|
|
code: 125,
|
|
stdout: Buffer.concat(stdoutChunks).toString("utf8"),
|
|
stderr: `Host command termination failed: ${terminationError}; final force-kill failed: ${detail}`,
|
|
});
|
|
},
|
|
);
|
|
}, PROCESS_TERMINATION_TIMEOUT_MS + PROCESS_TERMINATION_GRACE_MS + 100);
|
|
}
|
|
};
|
|
const capture = (chunks: Buffer[], chunk: Buffer, stream: "stdout" | "stderr") => {
|
|
const capturedBytes = stream === "stdout" ? stdoutBytes : stderrBytes;
|
|
const remaining = COMMAND_OUTPUT_LIMIT_BYTES - capturedBytes;
|
|
if (remaining > 0) chunks.push(chunk.subarray(0, remaining));
|
|
if (stream === "stdout") stdoutBytes += chunk.length;
|
|
else stderrBytes += chunk.length;
|
|
if (chunk.length > remaining && !outputExceeded) {
|
|
outputExceeded = true;
|
|
terminate();
|
|
}
|
|
};
|
|
child.stdout.on("data", (chunk: Buffer) => capture(stdoutChunks, chunk, "stdout"));
|
|
child.stderr.on("data", (chunk: Buffer) => capture(stderrChunks, chunk, "stderr"));
|
|
child.on("error", (error) => {
|
|
spawnError = error;
|
|
});
|
|
child.on("close", async (code) => {
|
|
if (settled) return;
|
|
if (timeout !== undefined) clearTimeout(timeout);
|
|
let terminationError: string | undefined;
|
|
try {
|
|
await termination;
|
|
} catch (failure) {
|
|
terminationError = failure instanceof Error ? failure.message : String(failure);
|
|
}
|
|
if (settled || terminationDeadlineReached) return;
|
|
settled = true;
|
|
if (terminationDeadline !== undefined) clearTimeout(terminationDeadline);
|
|
const stdout = Buffer.concat(stdoutChunks).toString("utf8");
|
|
const stderr = Buffer.concat(stderrChunks).toString("utf8");
|
|
if (timedOut) {
|
|
const detail = [stderr, terminationError].filter(Boolean).join("\n");
|
|
resolve({
|
|
code: terminationError ? 125 : 124,
|
|
stdout,
|
|
stderr: `Host command timed out after ${options.timeoutMs}ms${detail ? `\n${detail}` : ""}`,
|
|
});
|
|
return;
|
|
}
|
|
if (outputExceeded) {
|
|
resolve({
|
|
code: terminationError ? 125 : 1,
|
|
stdout,
|
|
stderr: `Host command output exceeded ${COMMAND_OUTPUT_LIMIT_BYTES} bytes${terminationError ? `\n${terminationError}` : ""}`,
|
|
});
|
|
return;
|
|
}
|
|
resolve({
|
|
code: code ?? 1,
|
|
stdout,
|
|
stderr: stderr || spawnError?.message || "",
|
|
});
|
|
});
|
|
if (options.timeoutMs !== undefined) {
|
|
timeout = setTimeout(() => {
|
|
timedOut = true;
|
|
terminate();
|
|
}, options.timeoutMs);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
export class GitWorkspaceManager {
|
|
private readonly runner: CommandRunner;
|
|
|
|
constructor(runner: CommandRunner = new ExecFileCommandRunner()) {
|
|
this.runner = runner;
|
|
}
|
|
|
|
async prepare(options: PrepareWorkspaceOptions): Promise<WorkspacePlan> {
|
|
const requestedRoot = path.resolve(options.sourceRoot);
|
|
const repository = await this.git(requestedRoot, [
|
|
"rev-parse",
|
|
"--show-toplevel",
|
|
]);
|
|
if (repository.code !== 0) {
|
|
throw new WorkspaceValidationError(
|
|
"not-repository",
|
|
`Unable to resolve Git repository: ${repository.stderr.trim()}`,
|
|
);
|
|
}
|
|
const sourceRoot = await realpath(path.resolve(repository.stdout.trim()));
|
|
if (sourceRoot !== (await realpath(requestedRoot))) {
|
|
throw new WorkspaceValidationError(
|
|
"not-repository",
|
|
`Workflow source must be the repository root ${sourceRoot}`,
|
|
);
|
|
}
|
|
|
|
const status = await this.git(sourceRoot, [
|
|
"status",
|
|
"--porcelain=v1",
|
|
"-z",
|
|
"--untracked-files=all",
|
|
]);
|
|
if (status.code !== 0) {
|
|
throw new WorkspaceValidationError(
|
|
"git-failed",
|
|
`Unable to inspect source state: ${status.stderr.trim()}`,
|
|
);
|
|
}
|
|
const evidence = sourceChangeEvidence(status.stdout);
|
|
if (evidence.hasUnmerged) {
|
|
throw new WorkspaceValidationError(
|
|
"unmerged-source",
|
|
"Source checkout has unmerged paths; resolve conflicts before creating a workflow worktree",
|
|
evidence,
|
|
);
|
|
}
|
|
if (evidence.sourceChanges.length > 0 && !options.allowDirtySource) {
|
|
throw new WorkspaceValidationError(
|
|
"dirty-source",
|
|
"Source checkout has changes that are not included in the workflow worktree created from HEAD",
|
|
evidence,
|
|
);
|
|
}
|
|
|
|
const worktreeRoot = await this.containedWorktreeRoot(
|
|
sourceRoot,
|
|
options.configuredRoot,
|
|
);
|
|
const relativeRoot = path
|
|
.relative(sourceRoot, worktreeRoot)
|
|
.replaceAll("\\", "/");
|
|
const ignored = await this.git(sourceRoot, [
|
|
"check-ignore",
|
|
"--quiet",
|
|
"--no-index",
|
|
"--",
|
|
`${relativeRoot}/.workflow-ignore-check`,
|
|
]);
|
|
if (ignored.code !== 0) {
|
|
throw new WorkspaceValidationError(
|
|
"unignored-root",
|
|
`Configured worktree root ${relativeRoot} must already be ignored by Git`,
|
|
);
|
|
}
|
|
const worktreePath = path.join(
|
|
worktreeRoot,
|
|
`workflow-${options.featureName}-${options.runId}`,
|
|
);
|
|
try {
|
|
await lstat(worktreePath);
|
|
throw new WorkspaceValidationError(
|
|
"existing-worktree-path",
|
|
`Worktree path already exists: ${worktreePath}`,
|
|
);
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
}
|
|
|
|
const branch = `feature/${options.featureName}`;
|
|
const branchCheck = await this.git(sourceRoot, [
|
|
"show-ref",
|
|
"--verify",
|
|
"--quiet",
|
|
`refs/heads/${branch}`,
|
|
]);
|
|
if (branchCheck.code !== 0 && branchCheck.code !== 1) {
|
|
throw new WorkspaceValidationError(
|
|
"git-failed",
|
|
`Unable to inspect branch ${branch}: ${branchCheck.stderr.trim()}`,
|
|
);
|
|
}
|
|
const head = await this.git(sourceRoot, ["rev-parse", "HEAD"]);
|
|
if (head.code !== 0) {
|
|
throw new WorkspaceValidationError(
|
|
"git-failed",
|
|
`Unable to resolve HEAD: ${head.stderr.trim()}`,
|
|
);
|
|
}
|
|
|
|
return {
|
|
sourceRoot,
|
|
worktreeRoot,
|
|
worktreePath,
|
|
branch,
|
|
baseHead: head.stdout.trim(),
|
|
branchExists: branchCheck.code === 0,
|
|
sourceChanges: evidence.sourceChanges,
|
|
sourceChangesOmitted: evidence.sourceChangesOmitted,
|
|
dirtySourceAllowed:
|
|
evidence.sourceChanges.length > 0 && options.allowDirtySource === true,
|
|
};
|
|
}
|
|
|
|
async create(plan: WorkspacePlan): Promise<WorkflowWorkspace> {
|
|
const branch = await this.git(plan.sourceRoot, [
|
|
"show-ref",
|
|
"--verify",
|
|
"--quiet",
|
|
`refs/heads/${plan.branch}`,
|
|
]);
|
|
if (branch.code !== 0 && branch.code !== 1) {
|
|
return {
|
|
state: "creating",
|
|
sourceRoot: plan.sourceRoot,
|
|
root: plan.worktreePath,
|
|
branch: plan.branch,
|
|
baseHead: plan.baseHead,
|
|
sourceChanges: structuredClone(plan.sourceChanges),
|
|
sourceChangesOmitted: plan.sourceChangesOmitted,
|
|
dirtySourceAllowed: plan.dirtySourceAllowed,
|
|
error: `Unable to inspect partial branch creation: ${branch.stderr.trim()}`,
|
|
};
|
|
}
|
|
let addArguments = [
|
|
"worktree",
|
|
"add",
|
|
"-b",
|
|
plan.branch,
|
|
plan.worktreePath,
|
|
plan.baseHead,
|
|
];
|
|
if (branch.code === 0) {
|
|
const branchHead = await this.git(plan.sourceRoot, [
|
|
"rev-parse",
|
|
`refs/heads/${plan.branch}`,
|
|
]);
|
|
if (branchHead.code !== 0 || branchHead.stdout.trim() !== plan.baseHead) {
|
|
return {
|
|
state: "failed",
|
|
sourceRoot: plan.sourceRoot,
|
|
root: plan.worktreePath,
|
|
branch: plan.branch,
|
|
baseHead: plan.baseHead,
|
|
sourceChanges: structuredClone(plan.sourceChanges),
|
|
sourceChangesOmitted: plan.sourceChangesOmitted,
|
|
dirtySourceAllowed: plan.dirtySourceAllowed,
|
|
error: `Existing partial branch ${plan.branch} does not match persisted base HEAD ${plan.baseHead}`,
|
|
};
|
|
}
|
|
addArguments = ["worktree", "add", plan.worktreePath, plan.branch];
|
|
}
|
|
const result = await this.git(plan.sourceRoot, addArguments);
|
|
if (result.code !== 0) {
|
|
return this.reconcile({
|
|
state: "creating",
|
|
sourceRoot: plan.sourceRoot,
|
|
root: plan.worktreePath,
|
|
branch: plan.branch,
|
|
baseHead: plan.baseHead,
|
|
sourceChanges: structuredClone(plan.sourceChanges),
|
|
sourceChangesOmitted: plan.sourceChangesOmitted,
|
|
dirtySourceAllowed: plan.dirtySourceAllowed,
|
|
error: `Git worktree creation returned ${result.code}: ${result.stderr.trim()}`,
|
|
});
|
|
}
|
|
return {
|
|
state: "ready",
|
|
sourceRoot: plan.sourceRoot,
|
|
root: plan.worktreePath,
|
|
branch: plan.branch,
|
|
baseHead: plan.baseHead,
|
|
sourceChanges: structuredClone(plan.sourceChanges),
|
|
sourceChangesOmitted: plan.sourceChangesOmitted,
|
|
dirtySourceAllowed: plan.dirtySourceAllowed,
|
|
};
|
|
}
|
|
|
|
async reconcile(
|
|
workspace: WorkflowWorkspace,
|
|
expectedWorktreeIdentity = workspace.root,
|
|
): Promise<WorkflowWorkspace> {
|
|
if (
|
|
workspace.state !== "creating" ||
|
|
!workspace.root ||
|
|
!workspace.branch
|
|
) {
|
|
return workspace;
|
|
}
|
|
const listed = await this.git(workspace.sourceRoot, [
|
|
"worktree",
|
|
"list",
|
|
"--porcelain",
|
|
]);
|
|
if (listed.code !== 0) {
|
|
return {
|
|
...workspace,
|
|
state: "failed",
|
|
error: `Unable to reconcile worktree: ${listed.stderr.trim()}`,
|
|
};
|
|
}
|
|
const expectedRoot = path.resolve(workspace.root);
|
|
if (
|
|
!expectedWorktreeIdentity ||
|
|
path.resolve(expectedWorktreeIdentity) !== expectedRoot
|
|
) {
|
|
return {
|
|
...workspace,
|
|
state: "failed",
|
|
error: "Persisted worktree lease identity does not match its workspace",
|
|
};
|
|
}
|
|
if (!workspace.baseHead) {
|
|
return {
|
|
...workspace,
|
|
state: "failed",
|
|
error: "Persisted worktree base HEAD is missing",
|
|
};
|
|
}
|
|
const blocks = listed.stdout
|
|
.split(/\n\n+/)
|
|
.map((block) => block.split("\n"));
|
|
const expectedBranch = `branch refs/heads/${workspace.branch}`;
|
|
const matchingPath = blocks.find((lines) =>
|
|
lines.some(
|
|
(line) =>
|
|
line.startsWith("worktree ") &&
|
|
path.resolve(line.slice("worktree ".length)) === expectedRoot,
|
|
),
|
|
);
|
|
const matchingBranch = blocks.find((lines) =>
|
|
lines.includes(expectedBranch),
|
|
);
|
|
if (!matchingPath && !matchingBranch) {
|
|
return { ...workspace, state: "creating" };
|
|
}
|
|
const expectedHead = `HEAD ${workspace.baseHead}`;
|
|
if (
|
|
matchingPath === matchingBranch &&
|
|
matchingPath?.includes(expectedHead)
|
|
) {
|
|
return { ...workspace, state: "ready", error: undefined };
|
|
}
|
|
return {
|
|
...workspace,
|
|
state: "failed",
|
|
error:
|
|
"Interrupted worktree creation conflicts with the persisted path, branch, or base HEAD",
|
|
};
|
|
}
|
|
|
|
private async containedWorktreeRoot(
|
|
sourceRoot: string,
|
|
configuredRoot: string,
|
|
) {
|
|
const requested = path.resolve(sourceRoot, configuredRoot);
|
|
const relative = path.relative(sourceRoot, requested);
|
|
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
throw new WorkspaceValidationError(
|
|
"unignored-root",
|
|
"Configured worktree root must be a directory inside the repository",
|
|
);
|
|
}
|
|
let current = sourceRoot;
|
|
for (const segment of relative.split(path.sep)) {
|
|
current = path.join(current, segment);
|
|
let info;
|
|
try {
|
|
info = await lstat(current);
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") break;
|
|
throw error;
|
|
}
|
|
if (info.isSymbolicLink()) {
|
|
throw new WorkspaceValidationError(
|
|
"unignored-root",
|
|
`Configured worktree root cannot traverse symlink ${current}`,
|
|
);
|
|
}
|
|
}
|
|
const canonicalParent = await realpath(path.dirname(current));
|
|
const canonicalRelative = path.relative(sourceRoot, canonicalParent);
|
|
if (
|
|
canonicalRelative.startsWith("..") ||
|
|
path.isAbsolute(canonicalRelative)
|
|
) {
|
|
throw new WorkspaceValidationError(
|
|
"unignored-root",
|
|
"Configured worktree root resolves outside the repository",
|
|
);
|
|
}
|
|
return requested;
|
|
}
|
|
|
|
private git(cwd: string, args: string[]) {
|
|
return this.runner.run(cwd, "git", args, {
|
|
timeoutMs: INTERNAL_GIT_TIMEOUT_MS,
|
|
});
|
|
}
|
|
}
|