@loucompanion/forge-bridge 0.1.3-dev.d1ca67076aa9 → 0.1.3-dev.eeefdb0d19c1
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 +78 -0
- package/dist/bridge/bridge.base.d.ts +6 -1
- package/dist/bridge/bridge.service.d.ts +60 -3
- package/dist/bridge/bridge.service.js +962 -22
- package/dist/bridge/bridge.types.d.ts +172 -1
- package/dist/cleanup/cleanup.service.d.ts +13 -2
- package/dist/cleanup/cleanup.service.js +435 -3
- package/dist/cleanup/cleanup.types.d.ts +10 -0
- package/dist/cleanup/index.d.ts +1 -1
- package/dist/cleanup/index.js +1 -1
- package/dist/cleanup/internals.d.ts +2 -2
- package/dist/cleanup/internals.js +1 -1
- package/dist/cli/cli.help.js +47 -1
- package/dist/cli/cli.service.d.ts +42 -2
- package/dist/cli/cli.service.js +351 -22
- package/dist/cli/local-config.d.ts +6 -0
- package/dist/cli/local-config.js +47 -3
- package/dist/runtime/attempt-node-runtime-launcher.d.ts +17 -0
- package/dist/runtime/attempt-node-runtime-launcher.js +101 -0
- package/dist/runtime/attempt-process-supervisor-registry.d.ts +20 -0
- package/dist/runtime/attempt-process-supervisor-registry.js +64 -0
- package/dist/runtime/attempt-process-supervisor.d.ts +86 -0
- package/dist/runtime/attempt-process-supervisor.js +343 -0
- package/dist/runtime/generated-output-reset.d.ts +10 -0
- package/dist/runtime/generated-output-reset.js +103 -0
- package/dist/runtime/host-listener-inspector.d.ts +44 -0
- package/dist/runtime/host-listener-inspector.js +324 -0
- package/dist/runtime/host-port-broker.d.ts +76 -0
- package/dist/runtime/host-port-broker.js +333 -0
- package/dist/runtime/host-runtime-contract.d.ts +47 -0
- package/dist/runtime/host-runtime-contract.js +700 -0
- package/dist/runtime/host-runtime-lease.d.ts +133 -0
- package/dist/runtime/host-runtime-lease.js +1460 -0
- package/dist/runtime/host-runtime-promotion.d.ts +23 -0
- package/dist/runtime/host-runtime-promotion.js +159 -0
- package/dist/runtime/node-host-runtime-adapter.d.ts +51 -0
- package/dist/runtime/node-host-runtime-adapter.js +268 -0
- package/dist/runtime/playwright-host-runtime-adapter.d.ts +20 -0
- package/dist/runtime/playwright-host-runtime-adapter.js +220 -0
- package/dist/runtime/runtime-isolation-lease.d.ts +67 -0
- package/dist/runtime/runtime-isolation-lease.js +158 -0
- package/dist/sandbox/command-runner.d.ts +4 -0
- package/dist/sandbox/command-runner.js +47 -0
- package/dist/sandbox/contract.d.ts +5 -0
- package/dist/sandbox/contract.js +276 -0
- package/dist/sandbox/event-budget.d.ts +11 -0
- package/dist/sandbox/event-budget.js +27 -0
- package/dist/sandbox/image-policy.d.ts +35 -0
- package/dist/sandbox/image-policy.js +247 -0
- package/dist/sandbox/index.d.ts +5 -0
- package/dist/sandbox/index.js +5 -0
- package/dist/sandbox/lease-watchdog.d.ts +18 -0
- package/dist/sandbox/lease-watchdog.js +67 -0
- package/dist/sandbox/preflight.d.ts +54 -0
- package/dist/sandbox/preflight.js +335 -0
- package/dist/sandbox/process.d.ts +20 -0
- package/dist/sandbox/process.js +209 -0
- package/dist/sandbox/provider-auth/codex.d.ts +26 -0
- package/dist/sandbox/provider-auth/codex.js +212 -0
- package/dist/sandbox/repository-lock.d.ts +5 -0
- package/dist/sandbox/repository-lock.js +119 -0
- package/dist/sandbox/runtime.d.ts +297 -0
- package/dist/sandbox/runtime.js +2338 -0
- package/dist/sandbox/sandbox.types.d.ts +86 -0
- package/dist/sandbox/sandbox.types.js +1 -0
- package/dist/sandbox/smoke.d.ts +76 -0
- package/dist/sandbox/smoke.js +380 -0
- package/dist/sandbox/worker-supervisor.d.ts +13 -0
- package/dist/sandbox/worker-supervisor.js +36 -0
- package/dist/sandbox/worker.d.ts +1 -0
- package/dist/sandbox/worker.js +225 -0
- package/dist/sandbox/workspace.service.d.ts +13 -0
- package/dist/sandbox/workspace.service.js +72 -0
- package/dist/session/managed-attempt-tools.d.ts +12 -0
- package/dist/session/managed-attempt-tools.js +350 -0
- package/dist/session/phase-completion.artifacts.js +32 -4
- package/dist/session/phase-completion.d.ts +4 -2
- package/dist/session/phase-completion.helpers.js +27 -3
- package/dist/session/phase-completion.js +12 -3
- package/dist/session/phase-completion.prompts.d.ts +3 -3
- package/dist/session/phase-completion.prompts.js +19 -5
- package/dist/session/phase-completion.runtime.d.ts +12 -1
- package/dist/session/phase-completion.runtime.js +127 -7
- package/dist/session/phase-completion.schemas.js +108 -3
- package/dist/session/phase-completion.validation.d.ts +4 -1
- package/dist/session/phase-completion.validation.js +106 -2
- package/dist/session/provider-cli/ci-repair-readiness.d.ts +8 -0
- package/dist/session/provider-cli/ci-repair-readiness.js +90 -0
- package/dist/session/provider-cli/provider-cli.artifacts.d.ts +1 -1
- package/dist/session/provider-cli/provider-cli.artifacts.js +28 -4
- package/dist/session/provider-cli/provider-cli.base.d.ts +1 -0
- package/dist/session/provider-cli/provider-cli.deterministic.d.ts +2 -0
- package/dist/session/provider-cli/provider-cli.deterministic.js +66 -9
- package/dist/session/provider-cli/provider-cli.process.d.ts +17 -0
- package/dist/session/provider-cli/provider-cli.process.js +223 -14
- package/dist/session/provider-cli/provider-cli.prompts.d.ts +16 -2
- package/dist/session/provider-cli/provider-cli.prompts.js +202 -18
- package/dist/session/provider-cli/provider-cli.qa-sidecars.js +1 -0
- package/dist/session/provider-cli/provider-cli.runtime.d.ts +1 -1
- package/dist/session/provider-cli/provider-cli.runtime.js +3 -2
- package/dist/session/provider-cli/provider-cli.service.d.ts +7 -3
- package/dist/session/provider-cli/provider-cli.service.js +399 -98
- package/dist/session/provider-cli/provider-cli.types.d.ts +6 -0
- package/dist/session/provider-cli/provider-output-state.d.ts +2 -0
- package/dist/session/provider-cli/provider-output-state.js +30 -2
- package/dist/session/session-redaction.d.ts +3 -0
- package/dist/session/session-redaction.js +23 -0
- package/dist/session/session.base.d.ts +6 -1
- package/dist/session/session.service.d.ts +8 -2
- package/dist/session/session.service.js +463 -58
- package/dist/session/session.types.d.ts +107 -1
- package/dist/session/session.types.js +226 -3
- package/dist/shared/path/path.js +3 -1
- package/dist/shared/process/host-process-recovery.d.ts +12 -0
- package/dist/shared/process/host-process-recovery.js +78 -0
- package/dist/shared/process/host-process-supervisor.d.ts +57 -0
- package/dist/shared/process/host-process-supervisor.js +282 -0
- package/dist/shared/process/process.d.ts +1 -0
- package/dist/shared/process/process.js +1 -0
- package/dist/shared/process/windows-launch.d.ts +1 -1
- package/dist/shared/process/windows-launch.js +6 -3
- package/dist/shared/provider-capabilities.d.ts +1 -0
- package/dist/shared/provider-capabilities.js +3 -0
- package/dist/shared/redaction/redaction.js +10 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worktree/artifact-injector.d.ts +3 -2
- package/dist/worktree/artifact-injector.js +43 -4
- package/dist/worktree/dependency-warmer.d.ts +21 -1
- package/dist/worktree/dependency-warmer.js +138 -5
- package/dist/worktree/index.d.ts +1 -0
- package/dist/worktree/index.js +1 -0
- package/dist/worktree/prior-phase-artifact-hydrator.d.ts +2 -0
- package/dist/worktree/prior-phase-artifact-hydrator.js +136 -0
- package/dist/worktree/worktree-paths.d.ts +4 -0
- package/dist/worktree/worktree-paths.js +30 -0
- package/dist/worktree/worktree.base.d.ts +3 -0
- package/dist/worktree/worktree.service.d.ts +7 -2
- package/dist/worktree/worktree.service.js +72 -44
- package/dist/worktree/worktree.types.d.ts +3 -1
- package/package.json +6 -3
- package/runtime/windows-job-supervisor.ps1 +356 -0
- package/runtime/windows-listener-owner.ps1 +33 -0
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
1
2
|
import fs from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
2
4
|
import path from "node:path";
|
|
3
5
|
import { NodeProcessRunner } from "../shared/process/process.js";
|
|
6
|
+
import { buildWindowsCmdLaunch } from "../shared/process/windows-launch.js";
|
|
4
7
|
import { isSubPath } from "../shared/path/path.js";
|
|
5
8
|
const SKIP_DIR_NAMES = new Set([
|
|
6
9
|
".git",
|
|
@@ -20,6 +23,7 @@ const NATIVE_MODULE_HINTS = [
|
|
|
20
23
|
"better-sqlite3",
|
|
21
24
|
"@swc/core"
|
|
22
25
|
];
|
|
26
|
+
const MAX_COMMAND_FAILURE_OUTPUT_LENGTH = 8_000;
|
|
23
27
|
export async function detectWarmPaths(repoRoot, warmPathOverrides = []) {
|
|
24
28
|
const entries = new Map();
|
|
25
29
|
await collectDetectedPaths(repoRoot, repoRoot, entries, 0);
|
|
@@ -47,10 +51,39 @@ export async function warmWorktreeDependencies(request, executor = defaultComman
|
|
|
47
51
|
const linked = [];
|
|
48
52
|
const skipped = [];
|
|
49
53
|
const fallbacks = [];
|
|
54
|
+
const restoredDotNetRoots = new Set();
|
|
55
|
+
const installedNodeRoots = new Set();
|
|
50
56
|
for (const entry of warmPaths) {
|
|
51
57
|
const sourcePath = path.join(baseCheckoutPath, entry.relativePath);
|
|
52
58
|
const targetPath = path.join(worktreePath, entry.relativePath);
|
|
53
59
|
assertWithinRoot(allowedRoot, targetPath, "Warm target");
|
|
60
|
+
if (entry.ecosystem === "node" && path.basename(entry.relativePath) === "node_modules") {
|
|
61
|
+
await removeLegacyDirectoryLink(targetPath, allowedRoot);
|
|
62
|
+
const installPackageRoot = await resolveNodeInstallPackageRoot(worktreePath, entry.packageRoot);
|
|
63
|
+
const packageRoot = path.resolve(worktreePath, installPackageRoot === "." ? "" : installPackageRoot);
|
|
64
|
+
const packageKey = process.platform === "win32" ? packageRoot.toLowerCase() : packageRoot;
|
|
65
|
+
if (installedNodeRoots.has(packageKey)) {
|
|
66
|
+
skipped.push(entry.relativePath);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
installedNodeRoots.add(packageKey);
|
|
70
|
+
const install = await runInstallFallback({ ...entry, packageRoot: installPackageRoot }, worktreePath, "Managed worktrees require isolated Node dependencies.", executor);
|
|
71
|
+
fallbacks.push(install);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (entry.ecosystem === "dotnet") {
|
|
75
|
+
await removeLegacyDirectoryLink(targetPath, allowedRoot);
|
|
76
|
+
const packageRoot = path.resolve(worktreePath, entry.packageRoot === "." ? "" : entry.packageRoot);
|
|
77
|
+
const packageKey = process.platform === "win32" ? packageRoot.toLowerCase() : packageRoot;
|
|
78
|
+
if (restoredDotNetRoots.has(packageKey)) {
|
|
79
|
+
skipped.push(entry.relativePath);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
restoredDotNetRoots.add(packageKey);
|
|
83
|
+
const install = await runInstallFallback(entry, worktreePath, "Managed worktrees require isolated .NET build outputs.", executor);
|
|
84
|
+
fallbacks.push(install);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
54
87
|
const sourceExists = await directoryExists(sourcePath);
|
|
55
88
|
const fallbackReason = await resolveFallbackReason(entry, baseCheckoutPath, worktreePath, sourceExists);
|
|
56
89
|
if (fallbackReason) {
|
|
@@ -67,6 +100,16 @@ export async function warmWorktreeDependencies(request, executor = defaultComman
|
|
|
67
100
|
}
|
|
68
101
|
return { linked, skipped, fallbacks };
|
|
69
102
|
}
|
|
103
|
+
async function removeLegacyDirectoryLink(targetPath, allowedRoot) {
|
|
104
|
+
const resolvedTarget = path.resolve(targetPath);
|
|
105
|
+
const resolvedRoot = path.resolve(allowedRoot);
|
|
106
|
+
assertWithinRoot(resolvedRoot, resolvedTarget, "Legacy warm target");
|
|
107
|
+
const stat = await fs.lstat(resolvedTarget).catch(() => null);
|
|
108
|
+
if (!stat?.isSymbolicLink()) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
await fs.rm(resolvedTarget, { force: true });
|
|
112
|
+
}
|
|
70
113
|
export async function createDirectoryLink(sourcePath, targetPath, allowedRoot) {
|
|
71
114
|
const resolvedSource = path.resolve(sourcePath);
|
|
72
115
|
const resolvedTarget = path.resolve(targetPath);
|
|
@@ -78,7 +121,10 @@ export async function createDirectoryLink(sourcePath, targetPath, allowedRoot) {
|
|
|
78
121
|
if (!stat?.isDirectory()) {
|
|
79
122
|
throw new Error(`Link source must be an existing directory: ${resolvedSource}`);
|
|
80
123
|
}
|
|
81
|
-
await fs.
|
|
124
|
+
const existingTarget = await fs.lstat(resolvedTarget).catch(() => null);
|
|
125
|
+
await fs.rm(resolvedTarget, existingTarget?.isSymbolicLink()
|
|
126
|
+
? { force: true }
|
|
127
|
+
: { recursive: true, force: true });
|
|
82
128
|
await fs.mkdir(path.dirname(resolvedTarget), { recursive: true });
|
|
83
129
|
if (process.platform === "win32") {
|
|
84
130
|
await fs.symlink(resolvedSource, resolvedTarget, "junction");
|
|
@@ -182,9 +228,17 @@ export async function lockfilesDiffer(baseCheckoutPath, worktreePath, packageRoo
|
|
|
182
228
|
async function runInstallFallback(entry, worktreePath, reason, executor) {
|
|
183
229
|
const cwd = path.join(worktreePath, entry.packageRoot === "." ? "" : entry.packageRoot);
|
|
184
230
|
if (entry.ecosystem === "node") {
|
|
231
|
+
const pnpmLockPath = path.join(cwd, "pnpm-lock.yaml");
|
|
232
|
+
if (await pathExists(pnpmLockPath)) {
|
|
233
|
+
const args = ["install", "--frozen-lockfile"];
|
|
234
|
+
const launch = resolvePnpmLaunch(args);
|
|
235
|
+
await executor.run(cwd, launch.command, launch.args, { env: unattendedPackageManagerEnvironment() });
|
|
236
|
+
return { ecosystem: entry.ecosystem, packageRoot: entry.packageRoot, reason, command: `pnpm ${args.join(" ")}` };
|
|
237
|
+
}
|
|
185
238
|
const lockPath = path.join(cwd, "package-lock.json");
|
|
186
239
|
const args = (await pathExists(lockPath)) ? ["ci"] : ["install"];
|
|
187
|
-
|
|
240
|
+
const launch = resolveNpmLaunch(args);
|
|
241
|
+
await executor.run(cwd, launch.command, launch.args, { env: unattendedPackageManagerEnvironment() });
|
|
188
242
|
return { ecosystem: entry.ecosystem, packageRoot: entry.packageRoot, reason, command: `npm ${args.join(" ")}` };
|
|
189
243
|
}
|
|
190
244
|
if (entry.ecosystem === "python") {
|
|
@@ -215,6 +269,23 @@ async function runInstallFallback(entry, worktreePath, reason, executor) {
|
|
|
215
269
|
command: "dotnet restore"
|
|
216
270
|
};
|
|
217
271
|
}
|
|
272
|
+
async function resolveNodeInstallPackageRoot(worktreePath, packageRoot) {
|
|
273
|
+
const resolvedWorktree = path.resolve(worktreePath);
|
|
274
|
+
let current = path.resolve(resolvedWorktree, packageRoot === "." ? "" : packageRoot);
|
|
275
|
+
assertWithinRoot(resolvedWorktree, current, "Node package root");
|
|
276
|
+
while (isSubPath(resolvedWorktree, current)) {
|
|
277
|
+
if (await pathExists(path.join(current, "pnpm-lock.yaml"))
|
|
278
|
+
|| await pathExists(path.join(current, "pnpm-workspace.yaml"))
|
|
279
|
+
|| await pathExists(path.join(current, "package-lock.json"))) {
|
|
280
|
+
const relative = normalizeRelativePath(path.relative(resolvedWorktree, current));
|
|
281
|
+
return relative || ".";
|
|
282
|
+
}
|
|
283
|
+
if (current === resolvedWorktree)
|
|
284
|
+
break;
|
|
285
|
+
current = path.dirname(current);
|
|
286
|
+
}
|
|
287
|
+
return packageRoot;
|
|
288
|
+
}
|
|
218
289
|
export async function requiresInstallFallback(entry, baseCheckoutPath, worktreePath, sourceExists) {
|
|
219
290
|
return resolveFallbackReason(entry, baseCheckoutPath, worktreePath, sourceExists);
|
|
220
291
|
}
|
|
@@ -286,11 +357,73 @@ async function sha256File(filePath) {
|
|
|
286
357
|
const content = await fs.readFile(filePath);
|
|
287
358
|
return crypto.createHash("sha256").update(content).digest("hex");
|
|
288
359
|
}
|
|
360
|
+
export function resolveNpmLaunch(args, options = {}) {
|
|
361
|
+
const env = options.env ?? process.env;
|
|
362
|
+
const execPath = options.execPath ?? process.execPath;
|
|
363
|
+
const npmExecPath = env.npm_execpath;
|
|
364
|
+
if (npmExecPath) {
|
|
365
|
+
return { command: execPath, args: [npmExecPath, ...args] };
|
|
366
|
+
}
|
|
367
|
+
const platform = options.platform ?? process.platform;
|
|
368
|
+
if (platform === "win32") {
|
|
369
|
+
const comSpec = options.env ? env.ComSpec ?? "cmd.exe" : env.ComSpec;
|
|
370
|
+
return buildWindowsCmdLaunch("npm.cmd", args, comSpec);
|
|
371
|
+
}
|
|
372
|
+
for (const candidate of options.candidates ?? defaultNpmCliCandidates(execPath, options.homeDir ?? os.homedir())) {
|
|
373
|
+
const resolvedCandidate = tryResolveNpmCandidate(candidate);
|
|
374
|
+
if (resolvedCandidate) {
|
|
375
|
+
return { command: execPath, args: [resolvedCandidate, ...args] };
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return { command: "npm", args };
|
|
379
|
+
}
|
|
380
|
+
export function resolvePnpmLaunch(args) {
|
|
381
|
+
if (process.platform === "win32") {
|
|
382
|
+
return buildWindowsCmdLaunch("pnpm.cmd", args, process.env.ComSpec);
|
|
383
|
+
}
|
|
384
|
+
return { command: "pnpm", args };
|
|
385
|
+
}
|
|
386
|
+
export function formatCommandFailureOutput(stdout, stderr) {
|
|
387
|
+
const sections = [
|
|
388
|
+
stderr.trim().length > 0 ? `stderr:\n${stderr.trim()}` : null,
|
|
389
|
+
stdout.trim().length > 0 ? `stdout:\n${stdout.trim()}` : null,
|
|
390
|
+
].filter((section) => section !== null);
|
|
391
|
+
const output = sections.join("\n");
|
|
392
|
+
if (output.length <= MAX_COMMAND_FAILURE_OUTPUT_LENGTH) {
|
|
393
|
+
return output;
|
|
394
|
+
}
|
|
395
|
+
return `[last ${MAX_COMMAND_FAILURE_OUTPUT_LENGTH} characters]\n${output.slice(-MAX_COMMAND_FAILURE_OUTPUT_LENGTH)}`;
|
|
396
|
+
}
|
|
397
|
+
export function unattendedPackageManagerEnvironment(env = process.env) {
|
|
398
|
+
return { ...env, CI: "true" };
|
|
399
|
+
}
|
|
400
|
+
function tryResolveNpmCandidate(candidate) {
|
|
401
|
+
try {
|
|
402
|
+
if (!existsSync(candidate)) {
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
405
|
+
return realpathSync(candidate);
|
|
406
|
+
}
|
|
407
|
+
catch {
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
function defaultNpmCliCandidates(execPath, homeDir) {
|
|
412
|
+
const candidates = [];
|
|
413
|
+
const execDir = path.dirname(execPath);
|
|
414
|
+
if (path.isAbsolute(execDir)) {
|
|
415
|
+
candidates.push(path.join(execDir, "npm"), path.join(execDir, "npm-cli.js"), path.join(execDir, "..", "lib", "node_modules", "npm", "bin", "npm-cli.js"));
|
|
416
|
+
}
|
|
417
|
+
candidates.push("/usr/bin/npm", "/usr/local/bin/npm", path.join(homeDir, ".npm-global", "bin", "npm"), path.join(homeDir, ".npm-global", "lib", "node_modules", "npm", "bin", "npm-cli.js"));
|
|
418
|
+
return candidates;
|
|
419
|
+
}
|
|
289
420
|
const defaultCommandExecutor = {
|
|
290
|
-
async run(cwd, command, args) {
|
|
291
|
-
const result = await defaultProcessRunner.run({ command, cwd, args });
|
|
421
|
+
async run(cwd, command, args, options) {
|
|
422
|
+
const result = await defaultProcessRunner.run({ command, cwd, args, env: options?.env });
|
|
292
423
|
if (result.exitCode !== 0) {
|
|
293
|
-
|
|
424
|
+
const output = formatCommandFailureOutput(result.stdout, result.stderr);
|
|
425
|
+
const details = output.length > 0 ? `\n${output}` : " no output was captured";
|
|
426
|
+
throw new Error(`${command} ${args.join(" ")} failed (${result.exitCode}) in ${cwd}:${details}`);
|
|
294
427
|
}
|
|
295
428
|
}
|
|
296
429
|
};
|
package/dist/worktree/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { GitWorktreeService } from "./worktree.service.js";
|
|
2
|
+
export { managedRepoRoot, resolveManagedWorktreeRoot, resolveManagedWorktreeRootForRepo, } from "./worktree-paths.js";
|
|
2
3
|
export { detectWarmPaths, warmWorktreeDependencies, createDirectoryLink, lockfilesDiffer, requiresInstallFallback } from "./dependency-warmer.js";
|
|
3
4
|
export type { WarmDependenciesResult, WarmPathEntry, InstallFallbackResult } from "./dependency-warmer.js";
|
|
4
5
|
export type { WorktreeService } from "./worktree.base.js";
|
package/dist/worktree/index.js
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
export { GitWorktreeService } from "./worktree.service.js";
|
|
2
|
+
export { managedRepoRoot, resolveManagedWorktreeRoot, resolveManagedWorktreeRootForRepo, } from "./worktree-paths.js";
|
|
2
3
|
export { detectWarmPaths, warmWorktreeDependencies, createDirectoryLink, lockfilesDiffer, requiresInstallFallback } from "./dependency-warmer.js";
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { isSubPath } from "../shared/path/path.js";
|
|
5
|
+
const MAX_ARTIFACT_COUNT = 128;
|
|
6
|
+
const MAX_CONTENT_BYTES = 2 * 1024 * 1024;
|
|
7
|
+
const MAX_TOTAL_BYTES = 12 * 1024 * 1024;
|
|
8
|
+
const phaseFiles = {
|
|
9
|
+
implementation: {
|
|
10
|
+
verdict: "implementation.completeness.json",
|
|
11
|
+
report: "implementation.report.md"
|
|
12
|
+
},
|
|
13
|
+
self_review: {
|
|
14
|
+
verdict: "self-review.report.json",
|
|
15
|
+
report: "self-review.summary.md"
|
|
16
|
+
},
|
|
17
|
+
validation: {
|
|
18
|
+
verdict: "validation.verdict.json",
|
|
19
|
+
report: "validation.report.md"
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
export async function hydratePriorPhaseArtifacts(worktreePath, artifacts = []) {
|
|
23
|
+
if (artifacts.length === 0)
|
|
24
|
+
return 0;
|
|
25
|
+
if (artifacts.length > MAX_ARTIFACT_COUNT) {
|
|
26
|
+
throw new Error(`Prior phase artifact handoff exceeds ${MAX_ARTIFACT_COUNT} artifacts.`);
|
|
27
|
+
}
|
|
28
|
+
const targets = new Set();
|
|
29
|
+
let totalBytes = 0;
|
|
30
|
+
for (const artifact of artifacts) {
|
|
31
|
+
const workItemId = safeArtifactSegment(artifact.workItemId, "work item id");
|
|
32
|
+
const files = phaseFiles[artifact.phase];
|
|
33
|
+
if (!files) {
|
|
34
|
+
throw new Error(`Unsupported prior phase artifact phase '${artifact.phase}'.`);
|
|
35
|
+
}
|
|
36
|
+
const verdictBytes = Buffer.byteLength(artifact.verdictJson, "utf8");
|
|
37
|
+
const reportBytes = Buffer.byteLength(artifact.report, "utf8");
|
|
38
|
+
if (verdictBytes > MAX_CONTENT_BYTES || reportBytes > MAX_CONTENT_BYTES) {
|
|
39
|
+
throw new Error(`Prior phase artifact '${artifact.artifactId}' exceeds the per-file hydration limit.`);
|
|
40
|
+
}
|
|
41
|
+
totalBytes += verdictBytes + reportBytes;
|
|
42
|
+
if (totalBytes > MAX_TOTAL_BYTES) {
|
|
43
|
+
throw new Error("Prior phase artifact handoff exceeds the total hydration limit.");
|
|
44
|
+
}
|
|
45
|
+
verifyContentHash(artifact.verdictJson, artifact.verdictContentHash, artifact.artifactId, "verdict");
|
|
46
|
+
verifyContentHash(artifact.report, artifact.reportContentHash, artifact.artifactId, "report");
|
|
47
|
+
validateVerdict(artifact);
|
|
48
|
+
const artifactRoot = path.resolve(worktreePath, ".forge", "work-items", workItemId, "artifacts");
|
|
49
|
+
if (!isSubPath(worktreePath, artifactRoot)) {
|
|
50
|
+
throw new Error("Prior phase artifact target must stay under the worktree.");
|
|
51
|
+
}
|
|
52
|
+
await ensureNoSymlinkPathSegments(worktreePath, artifactRoot);
|
|
53
|
+
await fs.mkdir(artifactRoot, { recursive: true });
|
|
54
|
+
await ensureNoSymlinkPathSegments(worktreePath, artifactRoot);
|
|
55
|
+
const verdictPath = path.join(artifactRoot, files.verdict);
|
|
56
|
+
const reportPath = path.join(artifactRoot, files.report);
|
|
57
|
+
const targetKey = verdictPath.toLowerCase();
|
|
58
|
+
if (targets.has(targetKey)) {
|
|
59
|
+
throw new Error(`Duplicate prior phase artifact target for work item '${workItemId}' phase '${artifact.phase}'.`);
|
|
60
|
+
}
|
|
61
|
+
targets.add(targetKey);
|
|
62
|
+
await writeAtomic(verdictPath, artifact.verdictJson);
|
|
63
|
+
await writeAtomic(reportPath, artifact.report);
|
|
64
|
+
}
|
|
65
|
+
return artifacts.length;
|
|
66
|
+
}
|
|
67
|
+
function validateVerdict(artifact) {
|
|
68
|
+
let parsed;
|
|
69
|
+
try {
|
|
70
|
+
parsed = JSON.parse(artifact.verdictJson);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
throw new Error(`Prior phase artifact '${artifact.artifactId}' verdict is not valid JSON.`);
|
|
74
|
+
}
|
|
75
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
76
|
+
throw new Error(`Prior phase artifact '${artifact.artifactId}' verdict must be a JSON object.`);
|
|
77
|
+
}
|
|
78
|
+
const recordedPhase = parsed["phase"];
|
|
79
|
+
const normalizedPhase = typeof recordedPhase === "string"
|
|
80
|
+
? recordedPhase.trim().toLowerCase().replace(/-/g, "_")
|
|
81
|
+
: "";
|
|
82
|
+
if (normalizedPhase !== artifact.phase) {
|
|
83
|
+
throw new Error(`Prior phase artifact '${artifact.artifactId}' phase does not match its verdict.`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function verifyContentHash(content, expected, artifactId, label) {
|
|
87
|
+
const normalizedExpected = expected.trim().toLowerCase().replace(/^sha256:/, "");
|
|
88
|
+
if (!/^[0-9a-f]{64}$/.test(normalizedExpected)) {
|
|
89
|
+
throw new Error(`Prior phase artifact '${artifactId}' ${label} hash is invalid.`);
|
|
90
|
+
}
|
|
91
|
+
const actual = createHash("sha256").update(content, "utf8").digest("hex");
|
|
92
|
+
if (actual !== normalizedExpected) {
|
|
93
|
+
throw new Error(`Prior phase artifact '${artifactId}' ${label} content_hash mismatch.`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function safeArtifactSegment(value, label) {
|
|
97
|
+
const trimmed = value.trim();
|
|
98
|
+
if (!/^[a-zA-Z0-9_-]{1,128}$/.test(trimmed)) {
|
|
99
|
+
throw new Error(`Prior phase artifact ${label} is not a safe path segment.`);
|
|
100
|
+
}
|
|
101
|
+
return trimmed;
|
|
102
|
+
}
|
|
103
|
+
async function writeAtomic(targetPath, content) {
|
|
104
|
+
const temporaryPath = `${targetPath}.tmp-${randomUUID()}`;
|
|
105
|
+
try {
|
|
106
|
+
await fs.writeFile(temporaryPath, content, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
107
|
+
await fs.rename(temporaryPath, targetPath);
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
await fs.rm(temporaryPath, { force: true }).catch(() => undefined);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
async function ensureNoSymlinkPathSegments(root, targetPath) {
|
|
114
|
+
const resolvedRoot = path.resolve(root);
|
|
115
|
+
const resolvedTarget = path.resolve(targetPath);
|
|
116
|
+
const relative = path.relative(resolvedRoot, resolvedTarget);
|
|
117
|
+
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
118
|
+
throw new Error("Prior phase artifact path must stay under the worktree.");
|
|
119
|
+
}
|
|
120
|
+
let current = resolvedRoot;
|
|
121
|
+
for (const segment of relative.split(path.sep)) {
|
|
122
|
+
if (!segment)
|
|
123
|
+
continue;
|
|
124
|
+
current = path.join(current, segment);
|
|
125
|
+
const stat = await fs.lstat(current).catch((error) => {
|
|
126
|
+
if (error.code === "ENOENT")
|
|
127
|
+
return null;
|
|
128
|
+
throw error;
|
|
129
|
+
});
|
|
130
|
+
if (!stat)
|
|
131
|
+
return;
|
|
132
|
+
if (stat.isSymbolicLink()) {
|
|
133
|
+
throw new Error("Prior phase artifact path must not traverse symlinked path segments.");
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare function managedRepoRoot(allowedRoot: string): string;
|
|
2
|
+
export declare function resolveManagedWorktreeRoot(allowedRoot: string, repoPath: string): string;
|
|
3
|
+
export declare function resolveManagedWorktreeRootForRepo(repoPath: string, allowedRoot?: string): string;
|
|
4
|
+
export declare function defaultWorktreeCoordinatorStateRoot(): string;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { isSubPath } from "../shared/path/path.js";
|
|
4
|
+
export function managedRepoRoot(allowedRoot) {
|
|
5
|
+
return path.join(path.resolve(allowedRoot), ".forge-worktrees", "_repos");
|
|
6
|
+
}
|
|
7
|
+
export function resolveManagedWorktreeRoot(allowedRoot, repoPath) {
|
|
8
|
+
const resolvedAllowedRoot = path.resolve(allowedRoot);
|
|
9
|
+
const resolvedRepoPath = path.resolve(repoPath);
|
|
10
|
+
const baseName = path.basename(resolvedRepoPath);
|
|
11
|
+
return isSubPath(managedRepoRoot(resolvedAllowedRoot), resolvedRepoPath)
|
|
12
|
+
? path.join(resolvedAllowedRoot, ".forge-worktrees", baseName)
|
|
13
|
+
: path.join(path.dirname(resolvedRepoPath), ".forge-worktrees", baseName);
|
|
14
|
+
}
|
|
15
|
+
export function resolveManagedWorktreeRootForRepo(repoPath, allowedRoot) {
|
|
16
|
+
const resolvedRepoPath = path.resolve(repoPath);
|
|
17
|
+
if (allowedRoot && isSubPath(path.resolve(allowedRoot), resolvedRepoPath)) {
|
|
18
|
+
return resolveManagedWorktreeRoot(allowedRoot, resolvedRepoPath);
|
|
19
|
+
}
|
|
20
|
+
const repoParent = path.dirname(resolvedRepoPath);
|
|
21
|
+
const managedRoot = path.dirname(repoParent);
|
|
22
|
+
if (path.basename(repoParent) === "_repos" && path.basename(managedRoot) === ".forge-worktrees") {
|
|
23
|
+
return path.join(managedRoot, path.basename(resolvedRepoPath));
|
|
24
|
+
}
|
|
25
|
+
return path.join(repoParent, ".forge-worktrees", path.basename(resolvedRepoPath));
|
|
26
|
+
}
|
|
27
|
+
export function defaultWorktreeCoordinatorStateRoot() {
|
|
28
|
+
return process.env.FORGE_SANDBOX_STATE_ROOT
|
|
29
|
+
?? path.join(os.homedir(), ".forge", "bridge", "sandboxes");
|
|
30
|
+
}
|
|
@@ -3,3 +3,6 @@ export interface WorktreeService {
|
|
|
3
3
|
prepare(request: WorktreePrepareRequest): Promise<WorktreeHandle>;
|
|
4
4
|
release(handle: WorktreeHandle): Promise<void>;
|
|
5
5
|
}
|
|
6
|
+
export interface WorktreeLifecycleCoordinator {
|
|
7
|
+
withLock<T>(repoPath: string, action: () => Promise<T>, signal?: AbortSignal): Promise<T>;
|
|
8
|
+
}
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { GitClient } from "../shared/git/git.js";
|
|
2
|
-
import type { WorktreeService } from "./worktree.base.js";
|
|
2
|
+
import type { WorktreeLifecycleCoordinator, WorktreeService } from "./worktree.base.js";
|
|
3
3
|
import type { WorktreeHandle, WorktreePrepareRequest } from "./worktree.types.js";
|
|
4
4
|
export declare class GitWorktreeService implements WorktreeService {
|
|
5
5
|
private readonly git;
|
|
6
|
-
|
|
6
|
+
private readonly coordinator;
|
|
7
|
+
constructor(git?: GitClient, coordinator?: WorktreeLifecycleCoordinator);
|
|
7
8
|
prepare(request: WorktreePrepareRequest): Promise<WorktreeHandle>;
|
|
9
|
+
private trackedForgePaths;
|
|
10
|
+
private repoCreationCoordinationPath;
|
|
8
11
|
release(handle: WorktreeHandle): Promise<void>;
|
|
9
12
|
private resolveRepoPath;
|
|
13
|
+
private managedRepoPath;
|
|
10
14
|
private findExistingRepoPath;
|
|
11
15
|
private resolveMatchingRepo;
|
|
12
16
|
private resolveBaseStartPoint;
|
|
@@ -15,4 +19,5 @@ export declare class GitWorktreeService implements WorktreeService {
|
|
|
15
19
|
private currentBranch;
|
|
16
20
|
private ensureGitIdentity;
|
|
17
21
|
}
|
|
22
|
+
export declare function clearRunArtifactRoots(worktreePath: string, request: WorktreePrepareRequest): Promise<void>;
|
|
18
23
|
export declare function normalizeBranchName(value: string | null | undefined, fallback: string): string;
|
|
@@ -1,57 +1,65 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { RepositoryCoordinatorLock } from "../sandbox/repository-lock.js";
|
|
3
4
|
import { GitClient } from "../shared/git/git.js";
|
|
4
5
|
import { ensureDirectory, isSubPath, resolveExistingDirectoryPath, safeSegment } from "../shared/path/path.js";
|
|
5
6
|
import { filterArtifactsForLane, injectWorktreeArtifacts } from "./artifact-injector.js";
|
|
7
|
+
import { hydratePriorPhaseArtifacts } from "./prior-phase-artifact-hydrator.js";
|
|
6
8
|
import { warmWorktreeDependencies } from "./dependency-warmer.js";
|
|
9
|
+
import { defaultWorktreeCoordinatorStateRoot, managedRepoRoot, resolveManagedWorktreeRoot, } from "./worktree-paths.js";
|
|
7
10
|
export class GitWorktreeService {
|
|
8
11
|
git;
|
|
9
|
-
|
|
12
|
+
coordinator;
|
|
13
|
+
constructor(git = new GitClient(), coordinator = new RepositoryCoordinatorLock(defaultWorktreeCoordinatorStateRoot())) {
|
|
10
14
|
this.git = git;
|
|
15
|
+
this.coordinator = coordinator;
|
|
11
16
|
}
|
|
12
17
|
async prepare(request) {
|
|
13
18
|
const allowedRoot = await ensureDirectory(request.allowedRepoRoot);
|
|
14
19
|
const baseBranch = normalizeBranchName(request.baseBranch, "develop");
|
|
15
|
-
const repoPath = await this.resolveRepoPath(request.repo, allowedRoot, baseBranch);
|
|
16
|
-
await this.ensureGitIdentity(repoPath);
|
|
17
|
-
const baseStartPoint = await this.resolveBaseStartPoint(repoPath, baseBranch);
|
|
18
20
|
const shortId = request.workItem.id.replace(/-/g, "").slice(0, 12);
|
|
19
21
|
const kind = safeSegment(request.workItem.kind, "work-item");
|
|
20
22
|
const fallbackWorktreeKey = `${kind}/${shortId}`;
|
|
21
23
|
const worktreeKey = request.worktreeKey?.trim() || fallbackWorktreeKey;
|
|
22
24
|
const worktreeSegments = worktreeKeySegments(worktreeKey, kind, shortId);
|
|
23
25
|
const branchName = normalizeBranchName(worktreeSegments.join("/"), "work-item/unknown");
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
await
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
26
|
+
const creationCoordinationPath = this.repoCreationCoordinationPath(request.repo, allowedRoot);
|
|
27
|
+
const repoPath = await this.coordinator.withLock(creationCoordinationPath, async () => {
|
|
28
|
+
return await this.resolveRepoPath(request.repo, allowedRoot, baseBranch);
|
|
29
|
+
});
|
|
30
|
+
const resolvedWorktreePath = await this.coordinator.withLock(repoPath, async () => {
|
|
31
|
+
await this.ensureGitIdentity(repoPath);
|
|
32
|
+
const baseStartPoint = await this.resolveBaseStartPoint(repoPath, baseBranch);
|
|
33
|
+
const worktreeRoot = resolveManagedWorktreeRoot(allowedRoot, repoPath);
|
|
34
|
+
const worktreePath = path.join(worktreeRoot, ...worktreeSegments);
|
|
35
|
+
const resolvedWorktreeParent = await ensureDirectory(path.dirname(worktreePath));
|
|
36
|
+
if (!isSubPath(allowedRoot, resolvedWorktreeParent)) {
|
|
37
|
+
throw new Error(`Worktree path must stay under ${allowedRoot}.`);
|
|
38
|
+
}
|
|
39
|
+
if (!(await directoryExists(worktreePath))) {
|
|
40
|
+
await this.git.run(repoPath, ["worktree", "add", "-B", branchName, worktreePath, baseStartPoint]);
|
|
41
|
+
return await resolveExistingDirectoryPath(worktreePath);
|
|
42
|
+
}
|
|
43
|
+
const existingWorktreePath = await resolveExistingDirectoryPath(worktreePath);
|
|
44
|
+
if (!isSubPath(allowedRoot, existingWorktreePath)) {
|
|
39
45
|
throw new Error(`Worktree path must stay under ${allowedRoot}.`);
|
|
40
46
|
}
|
|
41
|
-
const currentBranch = await this.currentBranch(
|
|
47
|
+
const currentBranch = await this.currentBranch(existingWorktreePath);
|
|
42
48
|
if (currentBranch !== branchName) {
|
|
43
|
-
throw new Error(`Existing worktree ${
|
|
49
|
+
throw new Error(`Existing worktree ${existingWorktreePath} is on branch '${currentBranch}', expected '${branchName}'.`);
|
|
44
50
|
}
|
|
45
51
|
if (shouldResetExistingWorktree(request)) {
|
|
46
|
-
await this.resetWorktreeToBase(
|
|
52
|
+
await this.resetWorktreeToBase(existingWorktreePath, baseStartPoint);
|
|
47
53
|
}
|
|
48
|
-
|
|
54
|
+
return existingWorktreePath;
|
|
55
|
+
});
|
|
49
56
|
await this.ensureGitIdentity(resolvedWorktreePath);
|
|
50
57
|
await clearRunArtifactRoots(resolvedWorktreePath, request);
|
|
58
|
+
const hydratedPriorPhaseArtifactCount = await hydratePriorPhaseArtifacts(resolvedWorktreePath, request.priorPhaseArtifacts);
|
|
51
59
|
const qaReworkPacketPath = await materializeQaReworkPacket(resolvedWorktreePath, request);
|
|
52
60
|
const artifacts = filterArtifactsForLane(request.artifacts, request.lane, request.phase);
|
|
53
61
|
if (artifacts.length > 0) {
|
|
54
|
-
await injectWorktreeArtifacts(resolvedWorktreePath, artifacts);
|
|
62
|
+
await injectWorktreeArtifacts(resolvedWorktreePath, artifacts, await this.trackedForgePaths(resolvedWorktreePath));
|
|
55
63
|
}
|
|
56
64
|
const dependencyWarm = await warmWorktreeDependencies({
|
|
57
65
|
baseCheckoutPath: repoPath,
|
|
@@ -66,20 +74,36 @@ export class GitWorktreeService {
|
|
|
66
74
|
baseBranch,
|
|
67
75
|
cleanupWorktrees: request.cleanupWorktrees,
|
|
68
76
|
dependencyWarm,
|
|
69
|
-
qaReworkPacketPath
|
|
77
|
+
qaReworkPacketPath,
|
|
78
|
+
hydratedPriorPhaseArtifactCount
|
|
70
79
|
};
|
|
71
80
|
}
|
|
81
|
+
async trackedForgePaths(worktreePath) {
|
|
82
|
+
const output = await this.git.run(worktreePath, ["ls-files", "-z", "--", ".forge"]);
|
|
83
|
+
return new Set(output.split("\0").filter(Boolean).map(value => value.replaceAll("\\", "/")));
|
|
84
|
+
}
|
|
85
|
+
repoCreationCoordinationPath(repo, allowedRoot) {
|
|
86
|
+
if (repo.serverLocalPath && repo.serverLocalPath.trim().length > 0) {
|
|
87
|
+
return path.resolve(repo.serverLocalPath);
|
|
88
|
+
}
|
|
89
|
+
return this.managedRepoPath(repo, allowedRoot);
|
|
90
|
+
}
|
|
72
91
|
async release(handle) {
|
|
73
|
-
if (!handle.cleanupWorktrees
|
|
92
|
+
if (!handle.cleanupWorktrees) {
|
|
74
93
|
return;
|
|
75
94
|
}
|
|
76
|
-
|
|
77
|
-
await
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
95
|
+
await this.coordinator.withLock(handle.repoPath, async () => {
|
|
96
|
+
if (!(await directoryExists(handle.worktreePath))) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
await this.git.run(handle.repoPath, ["worktree", "remove", "--force", handle.worktreePath]);
|
|
101
|
+
await this.git.run(handle.repoPath, ["worktree", "prune"]);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
await fs.rm(handle.worktreePath, { recursive: true, force: true });
|
|
105
|
+
}
|
|
106
|
+
});
|
|
83
107
|
}
|
|
84
108
|
async resolveRepoPath(repo, allowedRoot, baseBranch) {
|
|
85
109
|
if (repo.serverLocalPath && repo.serverLocalPath.trim().length > 0) {
|
|
@@ -96,7 +120,7 @@ export class GitWorktreeService {
|
|
|
96
120
|
if (discovered) {
|
|
97
121
|
return discovered;
|
|
98
122
|
}
|
|
99
|
-
const repoPath =
|
|
123
|
+
const repoPath = this.managedRepoPath(repo, allowedRoot);
|
|
100
124
|
if (!(await gitMetadataExists(repoPath, allowedRoot))) {
|
|
101
125
|
const repoPathExists = await pathExists(repoPath);
|
|
102
126
|
if (repoPathExists) {
|
|
@@ -127,6 +151,9 @@ export class GitWorktreeService {
|
|
|
127
151
|
}
|
|
128
152
|
return resolved;
|
|
129
153
|
}
|
|
154
|
+
managedRepoPath(repo, allowedRoot) {
|
|
155
|
+
return path.join(managedRepoRoot(allowedRoot), `${safeSegment(repo.name, "repo")}-${repo.id.replace(/-/g, "")}`);
|
|
156
|
+
}
|
|
130
157
|
async findExistingRepoPath(repo, allowedRoot) {
|
|
131
158
|
const expectedRemote = normalizeRemote(repo.remoteUrl);
|
|
132
159
|
const rawName = repo.name.trim();
|
|
@@ -168,6 +195,13 @@ export class GitWorktreeService {
|
|
|
168
195
|
return isSubPath(allowedRoot, resolved) ? resolved : null;
|
|
169
196
|
}
|
|
170
197
|
async resolveBaseStartPoint(repoPath, baseBranch) {
|
|
198
|
+
if (baseBranch === "HEAD") {
|
|
199
|
+
const head = (await this.git.run(repoPath, ["rev-parse", "--verify", "HEAD^{commit}"])).trim();
|
|
200
|
+
if (!/^[0-9a-f]{40,64}$/iu.test(head)) {
|
|
201
|
+
throw new Error("The repository checkout does not have a valid committed HEAD revision.");
|
|
202
|
+
}
|
|
203
|
+
return head;
|
|
204
|
+
}
|
|
171
205
|
const localRef = `refs/heads/${baseBranch}`;
|
|
172
206
|
const remoteRef = `refs/remotes/origin/${baseBranch}`;
|
|
173
207
|
const origin = await this.git.run(repoPath, ["remote", "get-url", "origin"]).catch(() => "");
|
|
@@ -199,15 +233,6 @@ export class GitWorktreeService {
|
|
|
199
233
|
await this.git.run(repoPath, ["config", "user.name", "Forge Bridge"]);
|
|
200
234
|
}
|
|
201
235
|
}
|
|
202
|
-
function managedRepoRoot(allowedRoot) {
|
|
203
|
-
return path.join(allowedRoot, ".forge-worktrees", "_repos");
|
|
204
|
-
}
|
|
205
|
-
function resolveWorktreeRoot(allowedRoot, repoPath) {
|
|
206
|
-
const baseName = path.basename(repoPath);
|
|
207
|
-
return isSubPath(managedRepoRoot(allowedRoot), repoPath)
|
|
208
|
-
? path.join(allowedRoot, ".forge-worktrees", baseName)
|
|
209
|
-
: path.join(path.dirname(repoPath), ".forge-worktrees", baseName);
|
|
210
|
-
}
|
|
211
236
|
function isCloneableRemote(remoteUrl) {
|
|
212
237
|
const trimmed = remoteUrl?.trim() ?? "";
|
|
213
238
|
if (trimmed.length === 0 || trimmed.startsWith("local://")) {
|
|
@@ -215,9 +240,12 @@ function isCloneableRemote(remoteUrl) {
|
|
|
215
240
|
}
|
|
216
241
|
return trimmed.startsWith("git@") || trimmed.includes("://") || path.isAbsolute(trimmed);
|
|
217
242
|
}
|
|
218
|
-
async function clearRunArtifactRoots(worktreePath, request) {
|
|
243
|
+
export async function clearRunArtifactRoots(worktreePath, request) {
|
|
219
244
|
const roots = new Set();
|
|
220
245
|
roots.add(workItemArtifactRoot(worktreePath, request.workItem.id));
|
|
246
|
+
for (const artifact of request.priorPhaseArtifacts ?? []) {
|
|
247
|
+
roots.add(workItemArtifactRoot(worktreePath, artifact.workItemId));
|
|
248
|
+
}
|
|
221
249
|
const ownerType = normalizeOwnerType(request.ownerType);
|
|
222
250
|
const ownerId = request.ownerId?.trim();
|
|
223
251
|
if (ownerType && ownerId) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { DispatchQaReworkContext, DispatchRepo, DispatchWorkItem, DispatchResolvedArtifact } from "../session/session.types.js";
|
|
1
|
+
import type { DispatchQaReworkContext, DispatchPriorPhaseArtifact, DispatchRepo, DispatchWorkItem, DispatchResolvedArtifact } from "../session/session.types.js";
|
|
2
2
|
import type { WarmDependenciesResult } from "./dependency-warmer.js";
|
|
3
3
|
export interface WorktreePrepareRequest {
|
|
4
4
|
readonly repo: DispatchRepo;
|
|
@@ -12,6 +12,7 @@ export interface WorktreePrepareRequest {
|
|
|
12
12
|
readonly allowedRepoRoot: string;
|
|
13
13
|
readonly cleanupWorktrees: boolean;
|
|
14
14
|
readonly artifacts: readonly DispatchResolvedArtifact[];
|
|
15
|
+
readonly priorPhaseArtifacts?: readonly DispatchPriorPhaseArtifact[];
|
|
15
16
|
readonly qaRework?: DispatchQaReworkContext | null;
|
|
16
17
|
readonly warmPathOverrides?: readonly string[];
|
|
17
18
|
}
|
|
@@ -23,4 +24,5 @@ export interface WorktreeHandle {
|
|
|
23
24
|
readonly cleanupWorktrees: boolean;
|
|
24
25
|
readonly dependencyWarm?: WarmDependenciesResult;
|
|
25
26
|
readonly qaReworkPacketPath?: string;
|
|
27
|
+
readonly hydratedPriorPhaseArtifactCount?: number;
|
|
26
28
|
}
|