@loucompanion/forge-bridge 0.1.1-dev.7d43998c4521 → 0.1.1-dev.ac486debe210
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 +1 -0
- package/dist/bridge/bridge.service.js +16 -3
- package/dist/bridge/bridge.types.d.ts +7 -0
- package/dist/cli/cli.service.d.ts +27 -2
- package/dist/cli/cli.service.js +406 -16
- package/dist/cli/local-config.d.ts +4 -1
- package/dist/cli/local-config.js +42 -7
- package/dist/session/index.d.ts +2 -0
- package/dist/session/index.js +1 -0
- package/dist/session/internals.d.ts +4 -2
- package/dist/session/internals.js +2 -1
- package/dist/session/phase-completion.d.ts +86 -0
- package/dist/session/phase-completion.js +1018 -0
- package/dist/session/provider-cli/index.d.ts +2 -2
- package/dist/session/provider-cli/index.js +1 -1
- package/dist/session/provider-cli/provider-cli.base.d.ts +2 -2
- package/dist/session/provider-cli/provider-cli.service.d.ts +48 -3
- package/dist/session/provider-cli/provider-cli.service.js +1241 -42
- package/dist/session/provider-cli/provider-cli.types.d.ts +2 -0
- package/dist/session/provider-cli/provider-usage.d.ts +25 -0
- package/dist/session/provider-cli/provider-usage.js +64 -0
- package/dist/session/session.service.js +51 -11
- package/dist/session/session.types.d.ts +18 -0
- package/dist/session/session.types.js +58 -1
- package/dist/shared/cursor-agent/cursor-agent.launch.d.ts +9 -0
- package/dist/shared/cursor-agent/cursor-agent.launch.js +66 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worktree/artifact-injector.d.ts +11 -0
- package/dist/worktree/artifact-injector.js +104 -0
- package/dist/worktree/dependency-warmer.d.ts +32 -0
- package/dist/worktree/dependency-warmer.js +297 -0
- package/dist/worktree/index.d.ts +2 -0
- package/dist/worktree/index.js +1 -0
- package/dist/worktree/worktree.service.d.ts +4 -0
- package/dist/worktree/worktree.service.js +173 -11
- package/dist/worktree/worktree.types.d.ts +5 -1
- package/package.json +1 -1
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { NodeProcessRunner } from "../shared/process/process.js";
|
|
4
|
+
import { isSubPath } from "../shared/path/path.js";
|
|
5
|
+
const SKIP_DIR_NAMES = new Set([
|
|
6
|
+
".git",
|
|
7
|
+
"node_modules",
|
|
8
|
+
".venv",
|
|
9
|
+
"bin",
|
|
10
|
+
"obj",
|
|
11
|
+
".forge-worktrees",
|
|
12
|
+
".forge"
|
|
13
|
+
]);
|
|
14
|
+
const NATIVE_MODULE_HINTS = [
|
|
15
|
+
"node-gyp",
|
|
16
|
+
"bcrypt",
|
|
17
|
+
"sharp",
|
|
18
|
+
"esbuild",
|
|
19
|
+
"sqlite3",
|
|
20
|
+
"better-sqlite3",
|
|
21
|
+
"@swc/core"
|
|
22
|
+
];
|
|
23
|
+
export async function detectWarmPaths(repoRoot, warmPathOverrides = []) {
|
|
24
|
+
const entries = new Map();
|
|
25
|
+
await collectDetectedPaths(repoRoot, repoRoot, entries, 0);
|
|
26
|
+
for (const override of warmPathOverrides) {
|
|
27
|
+
const normalized = normalizeRelativePath(override);
|
|
28
|
+
if (!normalized) {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
const packageRoot = await resolvePackageRoot(repoRoot, normalized);
|
|
32
|
+
entries.set(normalized, {
|
|
33
|
+
relativePath: normalized,
|
|
34
|
+
ecosystem: inferEcosystemFromPath(normalized),
|
|
35
|
+
packageRoot
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return [...entries.values()];
|
|
39
|
+
}
|
|
40
|
+
export async function warmWorktreeDependencies(request, executor = defaultCommandExecutor) {
|
|
41
|
+
const baseCheckoutPath = path.resolve(request.baseCheckoutPath);
|
|
42
|
+
const worktreePath = path.resolve(request.worktreePath);
|
|
43
|
+
const allowedRoot = path.resolve(request.allowedRoot);
|
|
44
|
+
assertWithinRoot(allowedRoot, baseCheckoutPath, "Base checkout");
|
|
45
|
+
assertWithinRoot(allowedRoot, worktreePath, "Worktree");
|
|
46
|
+
const warmPaths = await detectWarmPaths(baseCheckoutPath, request.warmPathOverrides ?? []);
|
|
47
|
+
const linked = [];
|
|
48
|
+
const skipped = [];
|
|
49
|
+
const fallbacks = [];
|
|
50
|
+
for (const entry of warmPaths) {
|
|
51
|
+
const sourcePath = path.join(baseCheckoutPath, entry.relativePath);
|
|
52
|
+
const targetPath = path.join(worktreePath, entry.relativePath);
|
|
53
|
+
assertWithinRoot(allowedRoot, targetPath, "Warm target");
|
|
54
|
+
const sourceExists = await directoryExists(sourcePath);
|
|
55
|
+
const fallbackReason = await resolveFallbackReason(entry, baseCheckoutPath, worktreePath, sourceExists);
|
|
56
|
+
if (fallbackReason) {
|
|
57
|
+
const install = await runInstallFallback(entry, worktreePath, fallbackReason, executor);
|
|
58
|
+
fallbacks.push(install);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (!sourceExists) {
|
|
62
|
+
skipped.push(entry.relativePath);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
await createDirectoryLink(sourcePath, targetPath, allowedRoot);
|
|
66
|
+
linked.push(entry.relativePath);
|
|
67
|
+
}
|
|
68
|
+
return { linked, skipped, fallbacks };
|
|
69
|
+
}
|
|
70
|
+
export async function createDirectoryLink(sourcePath, targetPath, allowedRoot) {
|
|
71
|
+
const resolvedSource = path.resolve(sourcePath);
|
|
72
|
+
const resolvedTarget = path.resolve(targetPath);
|
|
73
|
+
const resolvedRoot = path.resolve(allowedRoot);
|
|
74
|
+
assertWithinRoot(resolvedRoot, resolvedSource, "Link source");
|
|
75
|
+
assertWithinRoot(resolvedRoot, resolvedTarget, "Link target");
|
|
76
|
+
assertWithinRoot(resolvedRoot, path.dirname(resolvedTarget), "Link target parent");
|
|
77
|
+
const stat = await fs.stat(resolvedSource).catch(() => null);
|
|
78
|
+
if (!stat?.isDirectory()) {
|
|
79
|
+
throw new Error(`Link source must be an existing directory: ${resolvedSource}`);
|
|
80
|
+
}
|
|
81
|
+
await fs.rm(resolvedTarget, { recursive: true, force: true });
|
|
82
|
+
await fs.mkdir(path.dirname(resolvedTarget), { recursive: true });
|
|
83
|
+
if (process.platform === "win32") {
|
|
84
|
+
await fs.symlink(resolvedSource, resolvedTarget, "junction");
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
await fs.symlink(resolvedSource, resolvedTarget, "dir");
|
|
88
|
+
}
|
|
89
|
+
async function collectDetectedPaths(repoRoot, currentDir, entries, depth) {
|
|
90
|
+
if (depth > 6) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const relativeDir = path.relative(repoRoot, currentDir);
|
|
94
|
+
const normalizedDir = relativeDir.length === 0 ? "" : normalizeRelativePath(relativeDir);
|
|
95
|
+
if (normalizedDir) {
|
|
96
|
+
const packageJsonPath = path.join(currentDir, "package.json");
|
|
97
|
+
if (await pathExists(packageJsonPath)) {
|
|
98
|
+
addEntry(entries, repoRoot, normalizedDir, "node_modules", "node");
|
|
99
|
+
}
|
|
100
|
+
if (await hasPythonMarker(currentDir)) {
|
|
101
|
+
addEntry(entries, repoRoot, normalizedDir, ".venv", "python");
|
|
102
|
+
}
|
|
103
|
+
if (await hasDotNetMarker(currentDir)) {
|
|
104
|
+
const binRelative = joinRelative(normalizedDir, "bin");
|
|
105
|
+
const objRelative = joinRelative(normalizedDir, "obj");
|
|
106
|
+
if (await directoryExists(path.join(repoRoot, binRelative))) {
|
|
107
|
+
addEntry(entries, repoRoot, normalizedDir, "bin", "dotnet", binRelative);
|
|
108
|
+
}
|
|
109
|
+
if (await directoryExists(path.join(repoRoot, objRelative))) {
|
|
110
|
+
addEntry(entries, repoRoot, normalizedDir, "obj", "dotnet", objRelative);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
if (await pathExists(path.join(currentDir, "package.json"))) {
|
|
116
|
+
addEntry(entries, repoRoot, "", "node_modules", "node");
|
|
117
|
+
}
|
|
118
|
+
if (await hasPythonMarker(currentDir)) {
|
|
119
|
+
addEntry(entries, repoRoot, "", ".venv", "python");
|
|
120
|
+
}
|
|
121
|
+
if (await hasDotNetMarker(currentDir)) {
|
|
122
|
+
if (await directoryExists(path.join(repoRoot, "bin"))) {
|
|
123
|
+
addEntry(entries, repoRoot, "", "bin", "dotnet", "bin");
|
|
124
|
+
}
|
|
125
|
+
if (await directoryExists(path.join(repoRoot, "obj"))) {
|
|
126
|
+
addEntry(entries, repoRoot, "", "obj", "dotnet", "obj");
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const children = await fs.readdir(currentDir, { withFileTypes: true }).catch(() => []);
|
|
131
|
+
for (const child of children) {
|
|
132
|
+
if (!child.isDirectory() || SKIP_DIR_NAMES.has(child.name) || child.name.startsWith(".")) {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
await collectDetectedPaths(repoRoot, path.join(currentDir, child.name), entries, depth + 1);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
function addEntry(entries, repoRoot, packageRootRelative, folderName, ecosystem, explicitRelativePath) {
|
|
139
|
+
const relativePath = explicitRelativePath ?? joinRelative(packageRootRelative, folderName);
|
|
140
|
+
entries.set(relativePath, {
|
|
141
|
+
relativePath,
|
|
142
|
+
ecosystem,
|
|
143
|
+
packageRoot: packageRootRelative.length > 0 ? packageRootRelative : "."
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
async function resolveFallbackReason(entry, baseCheckoutPath, worktreePath, sourceExists) {
|
|
147
|
+
if (entry.ecosystem === "node") {
|
|
148
|
+
if (!sourceExists) {
|
|
149
|
+
return "Base checkout is missing node_modules.";
|
|
150
|
+
}
|
|
151
|
+
if (await lockfilesDiffer(baseCheckoutPath, worktreePath, entry.packageRoot)) {
|
|
152
|
+
return "Lockfile differs between base checkout and worktree.";
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (entry.ecosystem === "python" && !sourceExists) {
|
|
156
|
+
return "Base checkout is missing .venv.";
|
|
157
|
+
}
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
export async function lockfilesDiffer(baseCheckoutPath, worktreePath, packageRootRelative) {
|
|
161
|
+
const lockfileNames = ["package-lock.json", "pnpm-lock.yaml", "yarn.lock", "npm-shrinkwrap.json"];
|
|
162
|
+
const packageRoot = packageRootRelative === "." ? "" : packageRootRelative;
|
|
163
|
+
for (const lockfile of lockfileNames) {
|
|
164
|
+
const baseLock = path.join(baseCheckoutPath, packageRoot, lockfile);
|
|
165
|
+
const worktreeLock = path.join(worktreePath, packageRoot, lockfile);
|
|
166
|
+
const baseExists = await pathExists(baseLock);
|
|
167
|
+
const worktreeExists = await pathExists(worktreeLock);
|
|
168
|
+
if (baseExists !== worktreeExists) {
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
if (!baseExists) {
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const baseHash = await sha256File(baseLock);
|
|
175
|
+
const worktreeHash = await sha256File(worktreeLock);
|
|
176
|
+
if (baseHash !== worktreeHash) {
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
async function runInstallFallback(entry, worktreePath, reason, executor) {
|
|
183
|
+
const cwd = path.join(worktreePath, entry.packageRoot === "." ? "" : entry.packageRoot);
|
|
184
|
+
if (entry.ecosystem === "node") {
|
|
185
|
+
const lockPath = path.join(cwd, "package-lock.json");
|
|
186
|
+
const args = (await pathExists(lockPath)) ? ["ci"] : ["install"];
|
|
187
|
+
await executor.run(cwd, "npm", args);
|
|
188
|
+
return { ecosystem: entry.ecosystem, packageRoot: entry.packageRoot, reason, command: `npm ${args.join(" ")}` };
|
|
189
|
+
}
|
|
190
|
+
if (entry.ecosystem === "python") {
|
|
191
|
+
await executor.run(cwd, "python", ["-m", "venv", ".venv"]);
|
|
192
|
+
const requirements = path.join(cwd, "requirements.txt");
|
|
193
|
+
if (await pathExists(requirements)) {
|
|
194
|
+
const pip = process.platform === "win32" ? path.join(".venv", "Scripts", "pip") : path.join(".venv", "bin", "pip");
|
|
195
|
+
await executor.run(cwd, pip, ["install", "-r", "requirements.txt"]);
|
|
196
|
+
return {
|
|
197
|
+
ecosystem: entry.ecosystem,
|
|
198
|
+
packageRoot: entry.packageRoot,
|
|
199
|
+
reason,
|
|
200
|
+
command: "python -m venv .venv && pip install -r requirements.txt"
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
ecosystem: entry.ecosystem,
|
|
205
|
+
packageRoot: entry.packageRoot,
|
|
206
|
+
reason,
|
|
207
|
+
command: "python -m venv .venv"
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
await executor.run(cwd, "dotnet", ["restore"]);
|
|
211
|
+
return {
|
|
212
|
+
ecosystem: entry.ecosystem,
|
|
213
|
+
packageRoot: entry.packageRoot,
|
|
214
|
+
reason,
|
|
215
|
+
command: "dotnet restore"
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
export async function requiresInstallFallback(entry, baseCheckoutPath, worktreePath, sourceExists) {
|
|
219
|
+
return resolveFallbackReason(entry, baseCheckoutPath, worktreePath, sourceExists);
|
|
220
|
+
}
|
|
221
|
+
export async function declaresNativeModules(packageRootPath) {
|
|
222
|
+
const packageJsonPath = path.join(packageRootPath, "package.json");
|
|
223
|
+
if (!(await pathExists(packageJsonPath))) {
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
const raw = await fs.readFile(packageJsonPath, "utf8");
|
|
227
|
+
const lower = raw.toLowerCase();
|
|
228
|
+
return NATIVE_MODULE_HINTS.some((hint) => lower.includes(`"${hint}"`));
|
|
229
|
+
}
|
|
230
|
+
async function hasPythonMarker(dir) {
|
|
231
|
+
return (await pathExists(path.join(dir, "pyproject.toml")))
|
|
232
|
+
|| (await pathExists(path.join(dir, "requirements.txt")));
|
|
233
|
+
}
|
|
234
|
+
async function hasDotNetMarker(dir) {
|
|
235
|
+
const entries = await fs.readdir(dir).catch(() => []);
|
|
236
|
+
return entries.some((name) => name.endsWith(".csproj") || name.endsWith(".sln"));
|
|
237
|
+
}
|
|
238
|
+
async function resolvePackageRoot(repoRoot, relativePath) {
|
|
239
|
+
const segments = relativePath.split("/").filter(Boolean);
|
|
240
|
+
if (segments.length === 0) {
|
|
241
|
+
return ".";
|
|
242
|
+
}
|
|
243
|
+
const last = segments[segments.length - 1];
|
|
244
|
+
if (last === "node_modules" || last === ".venv" || last === "bin" || last === "obj") {
|
|
245
|
+
const parent = segments.slice(0, -1).join("/");
|
|
246
|
+
return parent.length > 0 ? parent : ".";
|
|
247
|
+
}
|
|
248
|
+
const candidate = path.join(repoRoot, ...segments);
|
|
249
|
+
if (await pathExists(path.join(candidate, "package.json"))) {
|
|
250
|
+
return segments.join("/");
|
|
251
|
+
}
|
|
252
|
+
return segments.slice(0, -1).join("/") || ".";
|
|
253
|
+
}
|
|
254
|
+
function inferEcosystemFromPath(relativePath) {
|
|
255
|
+
const base = path.basename(relativePath);
|
|
256
|
+
if (base === ".venv") {
|
|
257
|
+
return "python";
|
|
258
|
+
}
|
|
259
|
+
if (base === "bin" || base === "obj") {
|
|
260
|
+
return "dotnet";
|
|
261
|
+
}
|
|
262
|
+
return "node";
|
|
263
|
+
}
|
|
264
|
+
function joinRelative(packageRootRelative, folderName) {
|
|
265
|
+
return packageRootRelative.length > 0
|
|
266
|
+
? `${packageRootRelative}/${folderName}`.replace(/\\/g, "/")
|
|
267
|
+
: folderName;
|
|
268
|
+
}
|
|
269
|
+
function normalizeRelativePath(value) {
|
|
270
|
+
return value.replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+$/, "");
|
|
271
|
+
}
|
|
272
|
+
function assertWithinRoot(root, candidate, label) {
|
|
273
|
+
if (!isSubPath(root, candidate)) {
|
|
274
|
+
throw new Error(`${label} must stay under ${root}.`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
async function directoryExists(inputPath) {
|
|
278
|
+
const stat = await fs.stat(inputPath).catch(() => null);
|
|
279
|
+
return stat?.isDirectory() ?? false;
|
|
280
|
+
}
|
|
281
|
+
async function pathExists(inputPath) {
|
|
282
|
+
return (await fs.stat(inputPath).catch(() => null)) !== null;
|
|
283
|
+
}
|
|
284
|
+
async function sha256File(filePath) {
|
|
285
|
+
const crypto = await import("node:crypto");
|
|
286
|
+
const content = await fs.readFile(filePath);
|
|
287
|
+
return crypto.createHash("sha256").update(content).digest("hex");
|
|
288
|
+
}
|
|
289
|
+
const defaultCommandExecutor = {
|
|
290
|
+
async run(cwd, command, args) {
|
|
291
|
+
const result = await defaultProcessRunner.run({ command, cwd, args });
|
|
292
|
+
if (result.exitCode !== 0) {
|
|
293
|
+
throw new Error(`${command} ${args.join(" ")} failed (${result.exitCode}): ${result.stderr.trim()}`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
const defaultProcessRunner = new NodeProcessRunner();
|
package/dist/worktree/index.d.ts
CHANGED
|
@@ -1,2 +1,4 @@
|
|
|
1
1
|
export { GitWorktreeService } from "./worktree.service.js";
|
|
2
|
+
export { detectWarmPaths, warmWorktreeDependencies, createDirectoryLink, lockfilesDiffer, requiresInstallFallback } from "./dependency-warmer.js";
|
|
3
|
+
export type { WarmDependenciesResult, WarmPathEntry, InstallFallbackResult } from "./dependency-warmer.js";
|
|
2
4
|
export type { WorktreeService } from "./worktree.base.js";
|
package/dist/worktree/index.js
CHANGED
|
@@ -7,6 +7,10 @@ export declare class GitWorktreeService implements WorktreeService {
|
|
|
7
7
|
prepare(request: WorktreePrepareRequest): Promise<WorktreeHandle>;
|
|
8
8
|
release(handle: WorktreeHandle): Promise<void>;
|
|
9
9
|
private resolveRepoPath;
|
|
10
|
+
private findExistingRepoPath;
|
|
11
|
+
private resolveMatchingRepo;
|
|
12
|
+
private ensureBaseBranch;
|
|
13
|
+
private gitRefExists;
|
|
10
14
|
private ensureGitIdentity;
|
|
11
15
|
}
|
|
12
16
|
export declare function normalizeBranchName(value: string | null | undefined, fallback: string): string;
|
|
@@ -2,6 +2,8 @@ import fs from "node:fs/promises";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { GitClient } from "../shared/git/git.js";
|
|
4
4
|
import { ensureDirectory, isSubPath, resolveExistingDirectoryPath, safeSegment } from "../shared/path/path.js";
|
|
5
|
+
import { injectWorktreeArtifacts } from "./artifact-injector.js";
|
|
6
|
+
import { warmWorktreeDependencies } from "./dependency-warmer.js";
|
|
5
7
|
export class GitWorktreeService {
|
|
6
8
|
git;
|
|
7
9
|
constructor(git = new GitClient()) {
|
|
@@ -9,13 +11,14 @@ export class GitWorktreeService {
|
|
|
9
11
|
}
|
|
10
12
|
async prepare(request) {
|
|
11
13
|
const allowedRoot = await ensureDirectory(request.allowedRepoRoot);
|
|
12
|
-
const repoPath = await this.resolveRepoPath(request.repo, allowedRoot);
|
|
13
14
|
const baseBranch = normalizeBranchName(request.baseBranch, "develop");
|
|
15
|
+
const repoPath = await this.resolveRepoPath(request.repo, allowedRoot, baseBranch);
|
|
14
16
|
await this.ensureGitIdentity(repoPath);
|
|
17
|
+
await this.ensureBaseBranch(repoPath, baseBranch);
|
|
15
18
|
const shortId = request.workItem.id.replace(/-/g, "").slice(0, 12);
|
|
16
19
|
const kind = safeSegment(request.workItem.kind, "work-item");
|
|
17
20
|
const branchName = normalizeBranchName(`${kind}/${shortId}`, "work-item/unknown");
|
|
18
|
-
const worktreeRoot =
|
|
21
|
+
const worktreeRoot = resolveWorktreeRoot(allowedRoot, repoPath);
|
|
19
22
|
const worktreePath = path.join(worktreeRoot, kind, shortId);
|
|
20
23
|
const resolvedWorktreeParent = await ensureDirectory(path.dirname(worktreePath));
|
|
21
24
|
if (!isSubPath(allowedRoot, resolvedWorktreeParent)) {
|
|
@@ -34,12 +37,22 @@ export class GitWorktreeService {
|
|
|
34
37
|
}
|
|
35
38
|
}
|
|
36
39
|
await this.ensureGitIdentity(resolvedWorktreePath);
|
|
40
|
+
if (request.artifacts.length > 0) {
|
|
41
|
+
await injectWorktreeArtifacts(resolvedWorktreePath, request.artifacts);
|
|
42
|
+
}
|
|
43
|
+
const dependencyWarm = await warmWorktreeDependencies({
|
|
44
|
+
baseCheckoutPath: repoPath,
|
|
45
|
+
worktreePath: resolvedWorktreePath,
|
|
46
|
+
allowedRoot,
|
|
47
|
+
warmPathOverrides: request.warmPathOverrides
|
|
48
|
+
});
|
|
37
49
|
return {
|
|
38
50
|
repoPath,
|
|
39
51
|
worktreePath: resolvedWorktreePath,
|
|
40
52
|
branchName,
|
|
41
53
|
baseBranch,
|
|
42
|
-
cleanupWorktrees: request.cleanupWorktrees
|
|
54
|
+
cleanupWorktrees: request.cleanupWorktrees,
|
|
55
|
+
dependencyWarm
|
|
43
56
|
};
|
|
44
57
|
}
|
|
45
58
|
async release(handle) {
|
|
@@ -54,7 +67,7 @@ export class GitWorktreeService {
|
|
|
54
67
|
await fs.rm(handle.worktreePath, { recursive: true, force: true });
|
|
55
68
|
}
|
|
56
69
|
}
|
|
57
|
-
async resolveRepoPath(repo, allowedRoot) {
|
|
70
|
+
async resolveRepoPath(repo, allowedRoot, baseBranch) {
|
|
58
71
|
if (repo.serverLocalPath && repo.serverLocalPath.trim().length > 0) {
|
|
59
72
|
const requestedPath = await resolveExistingDirectoryPath(repo.serverLocalPath);
|
|
60
73
|
if (!isSubPath(allowedRoot, requestedPath) || !(await gitMetadataExists(requestedPath, allowedRoot))) {
|
|
@@ -65,14 +78,34 @@ export class GitWorktreeService {
|
|
|
65
78
|
if (!repo.cloneOnDemand) {
|
|
66
79
|
throw new Error("Repo has no server-local path and cloneOnDemand is disabled.");
|
|
67
80
|
}
|
|
68
|
-
const
|
|
81
|
+
const discovered = await this.findExistingRepoPath(repo, allowedRoot);
|
|
82
|
+
if (discovered) {
|
|
83
|
+
return discovered;
|
|
84
|
+
}
|
|
85
|
+
const repoPath = path.join(managedRepoRoot(allowedRoot), `${safeSegment(repo.name, "repo")}-${repo.id.replace(/-/g, "")}`);
|
|
69
86
|
if (!(await gitMetadataExists(repoPath, allowedRoot))) {
|
|
70
|
-
await
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
87
|
+
const repoPathExists = await pathExists(repoPath);
|
|
88
|
+
if (repoPathExists) {
|
|
89
|
+
throw new Error(`Repository path ${repoPath} exists but is not a git checkout.`);
|
|
90
|
+
}
|
|
91
|
+
if (isCloneableRemote(repo.remoteUrl)) {
|
|
92
|
+
await fs.mkdir(path.dirname(repoPath), { recursive: true });
|
|
93
|
+
try {
|
|
94
|
+
await this.git.run(path.dirname(repoPath), ["clone", "--origin", "origin", repo.remoteUrl.trim(), repoPath]);
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
await fs.rm(repoPath, { recursive: true, force: true });
|
|
98
|
+
throw err;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
await fs.mkdir(repoPath, { recursive: true });
|
|
103
|
+
await this.git.run(repoPath, ["init", "-b", baseBranch]);
|
|
104
|
+
await this.ensureGitIdentity(repoPath);
|
|
105
|
+
await fs.writeFile(path.join(repoPath, "README.md"), `# ${repo.name}\n`, "utf8");
|
|
106
|
+
await this.git.run(repoPath, ["add", "README.md"]);
|
|
107
|
+
await this.git.run(repoPath, ["commit", "-m", "seed forge bridge repo"]);
|
|
108
|
+
}
|
|
76
109
|
}
|
|
77
110
|
const resolved = await resolveExistingDirectoryPath(repoPath);
|
|
78
111
|
if (!isSubPath(allowedRoot, resolved)) {
|
|
@@ -80,11 +113,85 @@ export class GitWorktreeService {
|
|
|
80
113
|
}
|
|
81
114
|
return resolved;
|
|
82
115
|
}
|
|
116
|
+
async findExistingRepoPath(repo, allowedRoot) {
|
|
117
|
+
const expectedRemote = normalizeRemote(repo.remoteUrl);
|
|
118
|
+
const rawName = repo.name.trim();
|
|
119
|
+
const nameCandidates = uniqueStrings([
|
|
120
|
+
isSinglePathSegment(rawName) ? path.join(allowedRoot, rawName) : "",
|
|
121
|
+
path.join(allowedRoot, safeSegment(repo.name, "repo"))
|
|
122
|
+
]);
|
|
123
|
+
for (const candidate of nameCandidates) {
|
|
124
|
+
const resolved = await this.resolveMatchingRepo(candidate, allowedRoot, expectedRemote);
|
|
125
|
+
if (resolved) {
|
|
126
|
+
return resolved;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (expectedRemote.length > 0) {
|
|
130
|
+
const entries = await fs.readdir(allowedRoot, { withFileTypes: true }).catch(() => []);
|
|
131
|
+
for (const entry of entries) {
|
|
132
|
+
if (!entry.isDirectory() || entry.name === ".forge-worktrees") {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const resolved = await this.resolveMatchingRepo(path.join(allowedRoot, entry.name), allowedRoot, expectedRemote);
|
|
136
|
+
if (resolved) {
|
|
137
|
+
return resolved;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
async resolveMatchingRepo(candidate, allowedRoot, expectedRemote) {
|
|
144
|
+
if (!(await gitMetadataExists(candidate, allowedRoot))) {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
if (expectedRemote.length > 0) {
|
|
148
|
+
const origin = await this.git.run(candidate, ["remote", "get-url", "origin"]).catch(() => "");
|
|
149
|
+
if (normalizeRemote(origin) !== expectedRemote) {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const resolved = await resolveExistingDirectoryPath(candidate);
|
|
154
|
+
return isSubPath(allowedRoot, resolved) ? resolved : null;
|
|
155
|
+
}
|
|
156
|
+
async ensureBaseBranch(repoPath, baseBranch) {
|
|
157
|
+
if (await this.gitRefExists(repoPath, `refs/heads/${baseBranch}`)) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const origin = await this.git.run(repoPath, ["remote", "get-url", "origin"]).catch(() => "");
|
|
161
|
+
if (origin.trim().length === 0) {
|
|
162
|
+
throw new Error(`Base branch '${baseBranch}' was not found in the local checkout.`);
|
|
163
|
+
}
|
|
164
|
+
await this.git.run(repoPath, ["fetch", "origin", `${baseBranch}:refs/remotes/origin/${baseBranch}`]).catch(() => "");
|
|
165
|
+
if (await this.gitRefExists(repoPath, `refs/remotes/origin/${baseBranch}`)) {
|
|
166
|
+
await this.git.run(repoPath, ["branch", baseBranch, `refs/remotes/origin/${baseBranch}`]);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
throw new Error(`Base branch '${baseBranch}' was not found locally or on origin.`);
|
|
170
|
+
}
|
|
171
|
+
async gitRefExists(repoPath, gitRef) {
|
|
172
|
+
return this.git.run(repoPath, ["show-ref", "--verify", "--quiet", gitRef]).then(() => true).catch(() => false);
|
|
173
|
+
}
|
|
83
174
|
async ensureGitIdentity(repoPath) {
|
|
84
175
|
await this.git.run(repoPath, ["config", "user.email", "forge-bridge@forge.local"]);
|
|
85
176
|
await this.git.run(repoPath, ["config", "user.name", "Forge Bridge"]);
|
|
86
177
|
}
|
|
87
178
|
}
|
|
179
|
+
function managedRepoRoot(allowedRoot) {
|
|
180
|
+
return path.join(allowedRoot, ".forge-worktrees", "_repos");
|
|
181
|
+
}
|
|
182
|
+
function resolveWorktreeRoot(allowedRoot, repoPath) {
|
|
183
|
+
const baseName = path.basename(repoPath);
|
|
184
|
+
return isSubPath(managedRepoRoot(allowedRoot), repoPath)
|
|
185
|
+
? path.join(allowedRoot, ".forge-worktrees", baseName)
|
|
186
|
+
: path.join(path.dirname(repoPath), ".forge-worktrees", baseName);
|
|
187
|
+
}
|
|
188
|
+
function isCloneableRemote(remoteUrl) {
|
|
189
|
+
const trimmed = remoteUrl?.trim() ?? "";
|
|
190
|
+
if (trimmed.length === 0 || trimmed.startsWith("local://")) {
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
return trimmed.startsWith("git@") || trimmed.includes("://") || path.isAbsolute(trimmed);
|
|
194
|
+
}
|
|
88
195
|
export function normalizeBranchName(value, fallback) {
|
|
89
196
|
const branch = (value?.trim() || fallback).trim();
|
|
90
197
|
if (branch.length === 0 ||
|
|
@@ -133,7 +240,62 @@ async function gitMetadataExists(repoPath, allowedRoot) {
|
|
|
133
240
|
const resolvedGitDir = await resolveExistingDirectoryPath(gitDirPath);
|
|
134
241
|
return isSubPath(allowedRoot, resolvedGitDir);
|
|
135
242
|
}
|
|
243
|
+
async function pathExists(inputPath) {
|
|
244
|
+
return fs.stat(inputPath).then(() => true).catch(() => false);
|
|
245
|
+
}
|
|
136
246
|
async function directoryExists(inputPath) {
|
|
137
247
|
const stat = await fs.stat(inputPath).catch(() => null);
|
|
138
248
|
return stat?.isDirectory() ?? false;
|
|
139
249
|
}
|
|
250
|
+
function uniqueStrings(values) {
|
|
251
|
+
const seen = new Set();
|
|
252
|
+
const result = [];
|
|
253
|
+
for (const value of values) {
|
|
254
|
+
if (!value || seen.has(value)) {
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
seen.add(value);
|
|
258
|
+
result.push(value);
|
|
259
|
+
}
|
|
260
|
+
return result;
|
|
261
|
+
}
|
|
262
|
+
function isSinglePathSegment(value) {
|
|
263
|
+
return value.length > 0 && value !== "." && value !== ".." && !value.includes("/") && !value.includes("\\");
|
|
264
|
+
}
|
|
265
|
+
function normalizeRemote(remoteUrl) {
|
|
266
|
+
const trimmed = remoteUrl?.trim() ?? "";
|
|
267
|
+
if (trimmed.length === 0) {
|
|
268
|
+
return "";
|
|
269
|
+
}
|
|
270
|
+
const gitHubSsh = /^git@github\.com:(.+)$/i.exec(trimmed);
|
|
271
|
+
if (gitHubSsh) {
|
|
272
|
+
return stripGitSuffix(`https://github.com/${gitHubSsh[1]}`).toLowerCase();
|
|
273
|
+
}
|
|
274
|
+
if (path.isAbsolute(trimmed)) {
|
|
275
|
+
return normalizeLocalPath(trimmed);
|
|
276
|
+
}
|
|
277
|
+
try {
|
|
278
|
+
const url = new URL(trimmed);
|
|
279
|
+
if (url.protocol === "file:") {
|
|
280
|
+
return normalizeLocalPath(normalizeFileUrlPath(url));
|
|
281
|
+
}
|
|
282
|
+
url.username = "";
|
|
283
|
+
url.password = "";
|
|
284
|
+
url.hash = "";
|
|
285
|
+
return stripGitSuffix(url.toString().replace(/\/$/, "")).toLowerCase();
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
return stripGitSuffix(trimmed.replace(/\\/g, "/")).toLowerCase();
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
function stripGitSuffix(value) {
|
|
292
|
+
return value.replace(/\.git$/i, "").replace(/\/$/, "");
|
|
293
|
+
}
|
|
294
|
+
function normalizeFileUrlPath(url) {
|
|
295
|
+
const pathname = decodeURIComponent(url.pathname);
|
|
296
|
+
return /^\/[A-Za-z]:[\\/]/.test(pathname) ? pathname.slice(1) : pathname;
|
|
297
|
+
}
|
|
298
|
+
function normalizeLocalPath(value) {
|
|
299
|
+
const normalized = path.resolve(value).replace(/\\/g, "/");
|
|
300
|
+
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
|
|
301
|
+
}
|
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import type { DispatchRepo, DispatchWorkItem } from "../session/session.types.js";
|
|
1
|
+
import type { DispatchRepo, DispatchWorkItem, DispatchResolvedArtifact } from "../session/session.types.js";
|
|
2
|
+
import type { WarmDependenciesResult } from "./dependency-warmer.js";
|
|
2
3
|
export interface WorktreePrepareRequest {
|
|
3
4
|
readonly repo: DispatchRepo;
|
|
4
5
|
readonly workItem: DispatchWorkItem;
|
|
5
6
|
readonly baseBranch: string;
|
|
6
7
|
readonly allowedRepoRoot: string;
|
|
7
8
|
readonly cleanupWorktrees: boolean;
|
|
9
|
+
readonly artifacts: readonly DispatchResolvedArtifact[];
|
|
10
|
+
readonly warmPathOverrides?: readonly string[];
|
|
8
11
|
}
|
|
9
12
|
export interface WorktreeHandle {
|
|
10
13
|
readonly repoPath: string;
|
|
@@ -12,4 +15,5 @@ export interface WorktreeHandle {
|
|
|
12
15
|
readonly branchName: string;
|
|
13
16
|
readonly baseBranch: string;
|
|
14
17
|
readonly cleanupWorktrees: boolean;
|
|
18
|
+
readonly dependencyWarm?: WarmDependenciesResult;
|
|
15
19
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@loucompanion/forge-bridge",
|
|
3
|
-
"version": "0.1.1-dev.
|
|
3
|
+
"version": "0.1.1-dev.ac486debe210",
|
|
4
4
|
"description": "Forge runner bridge CLI for connecting local developer machines to Forge dispatch.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|