@loucompanion/forge-bridge 0.1.1-dev.7d43998c4521 → 0.1.1-dev.9acab970e5d1
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 +83 -11
- package/dist/bridge/bridge.types.d.ts +16 -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 +60 -8
- package/dist/session/index.d.ts +2 -0
- package/dist/session/index.js +1 -0
- package/dist/session/internals.d.ts +5 -3
- package/dist/session/internals.js +2 -1
- package/dist/session/phase-completion.d.ts +111 -0
- package/dist/session/phase-completion.js +2350 -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 +1885 -45
- 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/qa-sidecar-validation.d.ts +3 -0
- package/dist/session/qa-sidecar-validation.js +44 -0
- package/dist/session/session.service.js +57 -12
- package/dist/session/session.types.d.ts +66 -0
- package/dist/session/session.types.js +165 -2
- 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 +12 -0
- package/dist/worktree/artifact-injector.js +133 -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 +174 -11
- package/dist/worktree/worktree.types.d.ts +7 -1
- package/package.json +1 -1
|
@@ -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 { filterArtifactsForLane, 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,23 @@ export class GitWorktreeService {
|
|
|
34
37
|
}
|
|
35
38
|
}
|
|
36
39
|
await this.ensureGitIdentity(resolvedWorktreePath);
|
|
40
|
+
const artifacts = filterArtifactsForLane(request.artifacts, request.lane, request.phase);
|
|
41
|
+
if (artifacts.length > 0) {
|
|
42
|
+
await injectWorktreeArtifacts(resolvedWorktreePath, artifacts);
|
|
43
|
+
}
|
|
44
|
+
const dependencyWarm = await warmWorktreeDependencies({
|
|
45
|
+
baseCheckoutPath: repoPath,
|
|
46
|
+
worktreePath: resolvedWorktreePath,
|
|
47
|
+
allowedRoot,
|
|
48
|
+
warmPathOverrides: request.warmPathOverrides
|
|
49
|
+
});
|
|
37
50
|
return {
|
|
38
51
|
repoPath,
|
|
39
52
|
worktreePath: resolvedWorktreePath,
|
|
40
53
|
branchName,
|
|
41
54
|
baseBranch,
|
|
42
|
-
cleanupWorktrees: request.cleanupWorktrees
|
|
55
|
+
cleanupWorktrees: request.cleanupWorktrees,
|
|
56
|
+
dependencyWarm
|
|
43
57
|
};
|
|
44
58
|
}
|
|
45
59
|
async release(handle) {
|
|
@@ -54,7 +68,7 @@ export class GitWorktreeService {
|
|
|
54
68
|
await fs.rm(handle.worktreePath, { recursive: true, force: true });
|
|
55
69
|
}
|
|
56
70
|
}
|
|
57
|
-
async resolveRepoPath(repo, allowedRoot) {
|
|
71
|
+
async resolveRepoPath(repo, allowedRoot, baseBranch) {
|
|
58
72
|
if (repo.serverLocalPath && repo.serverLocalPath.trim().length > 0) {
|
|
59
73
|
const requestedPath = await resolveExistingDirectoryPath(repo.serverLocalPath);
|
|
60
74
|
if (!isSubPath(allowedRoot, requestedPath) || !(await gitMetadataExists(requestedPath, allowedRoot))) {
|
|
@@ -65,14 +79,34 @@ export class GitWorktreeService {
|
|
|
65
79
|
if (!repo.cloneOnDemand) {
|
|
66
80
|
throw new Error("Repo has no server-local path and cloneOnDemand is disabled.");
|
|
67
81
|
}
|
|
68
|
-
const
|
|
82
|
+
const discovered = await this.findExistingRepoPath(repo, allowedRoot);
|
|
83
|
+
if (discovered) {
|
|
84
|
+
return discovered;
|
|
85
|
+
}
|
|
86
|
+
const repoPath = path.join(managedRepoRoot(allowedRoot), `${safeSegment(repo.name, "repo")}-${repo.id.replace(/-/g, "")}`);
|
|
69
87
|
if (!(await gitMetadataExists(repoPath, allowedRoot))) {
|
|
70
|
-
await
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
88
|
+
const repoPathExists = await pathExists(repoPath);
|
|
89
|
+
if (repoPathExists) {
|
|
90
|
+
throw new Error(`Repository path ${repoPath} exists but is not a git checkout.`);
|
|
91
|
+
}
|
|
92
|
+
if (isCloneableRemote(repo.remoteUrl)) {
|
|
93
|
+
await fs.mkdir(path.dirname(repoPath), { recursive: true });
|
|
94
|
+
try {
|
|
95
|
+
await this.git.run(path.dirname(repoPath), ["clone", "--origin", "origin", repo.remoteUrl.trim(), repoPath]);
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
await fs.rm(repoPath, { recursive: true, force: true });
|
|
99
|
+
throw err;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
await fs.mkdir(repoPath, { recursive: true });
|
|
104
|
+
await this.git.run(repoPath, ["init", "-b", baseBranch]);
|
|
105
|
+
await this.ensureGitIdentity(repoPath);
|
|
106
|
+
await fs.writeFile(path.join(repoPath, "README.md"), `# ${repo.name}\n`, "utf8");
|
|
107
|
+
await this.git.run(repoPath, ["add", "README.md"]);
|
|
108
|
+
await this.git.run(repoPath, ["commit", "-m", "seed forge bridge repo"]);
|
|
109
|
+
}
|
|
76
110
|
}
|
|
77
111
|
const resolved = await resolveExistingDirectoryPath(repoPath);
|
|
78
112
|
if (!isSubPath(allowedRoot, resolved)) {
|
|
@@ -80,11 +114,85 @@ export class GitWorktreeService {
|
|
|
80
114
|
}
|
|
81
115
|
return resolved;
|
|
82
116
|
}
|
|
117
|
+
async findExistingRepoPath(repo, allowedRoot) {
|
|
118
|
+
const expectedRemote = normalizeRemote(repo.remoteUrl);
|
|
119
|
+
const rawName = repo.name.trim();
|
|
120
|
+
const nameCandidates = uniqueStrings([
|
|
121
|
+
isSinglePathSegment(rawName) ? path.join(allowedRoot, rawName) : "",
|
|
122
|
+
path.join(allowedRoot, safeSegment(repo.name, "repo"))
|
|
123
|
+
]);
|
|
124
|
+
for (const candidate of nameCandidates) {
|
|
125
|
+
const resolved = await this.resolveMatchingRepo(candidate, allowedRoot, expectedRemote);
|
|
126
|
+
if (resolved) {
|
|
127
|
+
return resolved;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (expectedRemote.length > 0) {
|
|
131
|
+
const entries = await fs.readdir(allowedRoot, { withFileTypes: true }).catch(() => []);
|
|
132
|
+
for (const entry of entries) {
|
|
133
|
+
if (!entry.isDirectory() || entry.name === ".forge-worktrees") {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const resolved = await this.resolveMatchingRepo(path.join(allowedRoot, entry.name), allowedRoot, expectedRemote);
|
|
137
|
+
if (resolved) {
|
|
138
|
+
return resolved;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
async resolveMatchingRepo(candidate, allowedRoot, expectedRemote) {
|
|
145
|
+
if (!(await gitMetadataExists(candidate, allowedRoot))) {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
if (expectedRemote.length > 0) {
|
|
149
|
+
const origin = await this.git.run(candidate, ["remote", "get-url", "origin"]).catch(() => "");
|
|
150
|
+
if (normalizeRemote(origin) !== expectedRemote) {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const resolved = await resolveExistingDirectoryPath(candidate);
|
|
155
|
+
return isSubPath(allowedRoot, resolved) ? resolved : null;
|
|
156
|
+
}
|
|
157
|
+
async ensureBaseBranch(repoPath, baseBranch) {
|
|
158
|
+
if (await this.gitRefExists(repoPath, `refs/heads/${baseBranch}`)) {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const origin = await this.git.run(repoPath, ["remote", "get-url", "origin"]).catch(() => "");
|
|
162
|
+
if (origin.trim().length === 0) {
|
|
163
|
+
throw new Error(`Base branch '${baseBranch}' was not found in the local checkout.`);
|
|
164
|
+
}
|
|
165
|
+
await this.git.run(repoPath, ["fetch", "origin", `${baseBranch}:refs/remotes/origin/${baseBranch}`]).catch(() => "");
|
|
166
|
+
if (await this.gitRefExists(repoPath, `refs/remotes/origin/${baseBranch}`)) {
|
|
167
|
+
await this.git.run(repoPath, ["branch", baseBranch, `refs/remotes/origin/${baseBranch}`]);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
throw new Error(`Base branch '${baseBranch}' was not found locally or on origin.`);
|
|
171
|
+
}
|
|
172
|
+
async gitRefExists(repoPath, gitRef) {
|
|
173
|
+
return this.git.run(repoPath, ["show-ref", "--verify", "--quiet", gitRef]).then(() => true).catch(() => false);
|
|
174
|
+
}
|
|
83
175
|
async ensureGitIdentity(repoPath) {
|
|
84
176
|
await this.git.run(repoPath, ["config", "user.email", "forge-bridge@forge.local"]);
|
|
85
177
|
await this.git.run(repoPath, ["config", "user.name", "Forge Bridge"]);
|
|
86
178
|
}
|
|
87
179
|
}
|
|
180
|
+
function managedRepoRoot(allowedRoot) {
|
|
181
|
+
return path.join(allowedRoot, ".forge-worktrees", "_repos");
|
|
182
|
+
}
|
|
183
|
+
function resolveWorktreeRoot(allowedRoot, repoPath) {
|
|
184
|
+
const baseName = path.basename(repoPath);
|
|
185
|
+
return isSubPath(managedRepoRoot(allowedRoot), repoPath)
|
|
186
|
+
? path.join(allowedRoot, ".forge-worktrees", baseName)
|
|
187
|
+
: path.join(path.dirname(repoPath), ".forge-worktrees", baseName);
|
|
188
|
+
}
|
|
189
|
+
function isCloneableRemote(remoteUrl) {
|
|
190
|
+
const trimmed = remoteUrl?.trim() ?? "";
|
|
191
|
+
if (trimmed.length === 0 || trimmed.startsWith("local://")) {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
return trimmed.startsWith("git@") || trimmed.includes("://") || path.isAbsolute(trimmed);
|
|
195
|
+
}
|
|
88
196
|
export function normalizeBranchName(value, fallback) {
|
|
89
197
|
const branch = (value?.trim() || fallback).trim();
|
|
90
198
|
if (branch.length === 0 ||
|
|
@@ -133,7 +241,62 @@ async function gitMetadataExists(repoPath, allowedRoot) {
|
|
|
133
241
|
const resolvedGitDir = await resolveExistingDirectoryPath(gitDirPath);
|
|
134
242
|
return isSubPath(allowedRoot, resolvedGitDir);
|
|
135
243
|
}
|
|
244
|
+
async function pathExists(inputPath) {
|
|
245
|
+
return fs.stat(inputPath).then(() => true).catch(() => false);
|
|
246
|
+
}
|
|
136
247
|
async function directoryExists(inputPath) {
|
|
137
248
|
const stat = await fs.stat(inputPath).catch(() => null);
|
|
138
249
|
return stat?.isDirectory() ?? false;
|
|
139
250
|
}
|
|
251
|
+
function uniqueStrings(values) {
|
|
252
|
+
const seen = new Set();
|
|
253
|
+
const result = [];
|
|
254
|
+
for (const value of values) {
|
|
255
|
+
if (!value || seen.has(value)) {
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
seen.add(value);
|
|
259
|
+
result.push(value);
|
|
260
|
+
}
|
|
261
|
+
return result;
|
|
262
|
+
}
|
|
263
|
+
function isSinglePathSegment(value) {
|
|
264
|
+
return value.length > 0 && value !== "." && value !== ".." && !value.includes("/") && !value.includes("\\");
|
|
265
|
+
}
|
|
266
|
+
function normalizeRemote(remoteUrl) {
|
|
267
|
+
const trimmed = remoteUrl?.trim() ?? "";
|
|
268
|
+
if (trimmed.length === 0) {
|
|
269
|
+
return "";
|
|
270
|
+
}
|
|
271
|
+
const gitHubSsh = /^git@github\.com:(.+)$/i.exec(trimmed);
|
|
272
|
+
if (gitHubSsh) {
|
|
273
|
+
return stripGitSuffix(`https://github.com/${gitHubSsh[1]}`).toLowerCase();
|
|
274
|
+
}
|
|
275
|
+
if (path.isAbsolute(trimmed)) {
|
|
276
|
+
return normalizeLocalPath(trimmed);
|
|
277
|
+
}
|
|
278
|
+
try {
|
|
279
|
+
const url = new URL(trimmed);
|
|
280
|
+
if (url.protocol === "file:") {
|
|
281
|
+
return normalizeLocalPath(normalizeFileUrlPath(url));
|
|
282
|
+
}
|
|
283
|
+
url.username = "";
|
|
284
|
+
url.password = "";
|
|
285
|
+
url.hash = "";
|
|
286
|
+
return stripGitSuffix(url.toString().replace(/\/$/, "")).toLowerCase();
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
return stripGitSuffix(trimmed.replace(/\\/g, "/")).toLowerCase();
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
function stripGitSuffix(value) {
|
|
293
|
+
return value.replace(/\.git$/i, "").replace(/\/$/, "");
|
|
294
|
+
}
|
|
295
|
+
function normalizeFileUrlPath(url) {
|
|
296
|
+
const pathname = decodeURIComponent(url.pathname);
|
|
297
|
+
return /^\/[A-Za-z]:[\\/]/.test(pathname) ? pathname.slice(1) : pathname;
|
|
298
|
+
}
|
|
299
|
+
function normalizeLocalPath(value) {
|
|
300
|
+
const normalized = path.resolve(value).replace(/\\/g, "/");
|
|
301
|
+
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
|
|
302
|
+
}
|
|
@@ -1,10 +1,15 @@
|
|
|
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;
|
|
7
|
+
readonly lane: string;
|
|
8
|
+
readonly phase: string;
|
|
6
9
|
readonly allowedRepoRoot: string;
|
|
7
10
|
readonly cleanupWorktrees: boolean;
|
|
11
|
+
readonly artifacts: readonly DispatchResolvedArtifact[];
|
|
12
|
+
readonly warmPathOverrides?: readonly string[];
|
|
8
13
|
}
|
|
9
14
|
export interface WorktreeHandle {
|
|
10
15
|
readonly repoPath: string;
|
|
@@ -12,4 +17,5 @@ export interface WorktreeHandle {
|
|
|
12
17
|
readonly branchName: string;
|
|
13
18
|
readonly baseBranch: string;
|
|
14
19
|
readonly cleanupWorktrees: boolean;
|
|
20
|
+
readonly dependencyWarm?: WarmDependenciesResult;
|
|
15
21
|
}
|
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.9acab970e5d1",
|
|
4
4
|
"description": "Forge runner bridge CLI for connecting local developer machines to Forge dispatch.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|