@loucompanion/forge-bridge 0.1.1-dev.32d4dd5874ee → 0.1.1-dev.40fbc494669b

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.
@@ -2,7 +2,7 @@ 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";
5
+ import { filterArtifactsForLane, injectWorktreeArtifacts } from "./artifact-injector.js";
6
6
  import { warmWorktreeDependencies } from "./dependency-warmer.js";
7
7
  export class GitWorktreeService {
8
8
  git;
@@ -17,9 +17,12 @@ export class GitWorktreeService {
17
17
  await this.ensureBaseBranch(repoPath, baseBranch);
18
18
  const shortId = request.workItem.id.replace(/-/g, "").slice(0, 12);
19
19
  const kind = safeSegment(request.workItem.kind, "work-item");
20
- const branchName = normalizeBranchName(`${kind}/${shortId}`, "work-item/unknown");
20
+ const fallbackWorktreeKey = `${kind}/${shortId}`;
21
+ const worktreeKey = request.worktreeKey?.trim() || fallbackWorktreeKey;
22
+ const worktreeSegments = worktreeKeySegments(worktreeKey, kind, shortId);
23
+ const branchName = normalizeBranchName(worktreeSegments.join("/"), "work-item/unknown");
21
24
  const worktreeRoot = resolveWorktreeRoot(allowedRoot, repoPath);
22
- const worktreePath = path.join(worktreeRoot, kind, shortId);
25
+ const worktreePath = path.join(worktreeRoot, ...worktreeSegments);
23
26
  const resolvedWorktreeParent = await ensureDirectory(path.dirname(worktreePath));
24
27
  if (!isSubPath(allowedRoot, resolvedWorktreeParent)) {
25
28
  throw new Error(`Worktree path must stay under ${allowedRoot}.`);
@@ -35,10 +38,17 @@ export class GitWorktreeService {
35
38
  if (!isSubPath(allowedRoot, resolvedWorktreePath)) {
36
39
  throw new Error(`Worktree path must stay under ${allowedRoot}.`);
37
40
  }
41
+ const currentBranch = await this.currentBranch(resolvedWorktreePath);
42
+ if (currentBranch !== branchName) {
43
+ throw new Error(`Existing worktree ${resolvedWorktreePath} is on branch '${currentBranch}', expected '${branchName}'.`);
44
+ }
38
45
  }
39
46
  await this.ensureGitIdentity(resolvedWorktreePath);
40
- if (request.artifacts.length > 0) {
41
- await injectWorktreeArtifacts(resolvedWorktreePath, request.artifacts);
47
+ await clearRunArtifactRoots(resolvedWorktreePath, request);
48
+ const qaReworkPacketPath = await materializeQaReworkPacket(resolvedWorktreePath, request);
49
+ const artifacts = filterArtifactsForLane(request.artifacts, request.lane, request.phase);
50
+ if (artifacts.length > 0) {
51
+ await injectWorktreeArtifacts(resolvedWorktreePath, artifacts);
42
52
  }
43
53
  const dependencyWarm = await warmWorktreeDependencies({
44
54
  baseCheckoutPath: repoPath,
@@ -52,7 +62,8 @@ export class GitWorktreeService {
52
62
  branchName,
53
63
  baseBranch,
54
64
  cleanupWorktrees: request.cleanupWorktrees,
55
- dependencyWarm
65
+ dependencyWarm,
66
+ qaReworkPacketPath
56
67
  };
57
68
  }
58
69
  async release(handle) {
@@ -171,6 +182,9 @@ export class GitWorktreeService {
171
182
  async gitRefExists(repoPath, gitRef) {
172
183
  return this.git.run(repoPath, ["show-ref", "--verify", "--quiet", gitRef]).then(() => true).catch(() => false);
173
184
  }
185
+ async currentBranch(repoPath) {
186
+ return (await this.git.run(repoPath, ["rev-parse", "--abbrev-ref", "HEAD"])).trim();
187
+ }
174
188
  async ensureGitIdentity(repoPath) {
175
189
  await this.git.run(repoPath, ["config", "user.email", "forge-bridge@forge.local"]);
176
190
  await this.git.run(repoPath, ["config", "user.name", "Forge Bridge"]);
@@ -192,6 +206,76 @@ function isCloneableRemote(remoteUrl) {
192
206
  }
193
207
  return trimmed.startsWith("git@") || trimmed.includes("://") || path.isAbsolute(trimmed);
194
208
  }
209
+ async function clearRunArtifactRoots(worktreePath, request) {
210
+ const roots = new Set();
211
+ roots.add(workItemArtifactRoot(worktreePath, request.workItem.id));
212
+ const ownerType = normalizeOwnerType(request.ownerType);
213
+ const ownerId = request.ownerId?.trim();
214
+ if (ownerType && ownerId) {
215
+ roots.add(ownerArtifactRoot(worktreePath, ownerType, ownerId));
216
+ }
217
+ for (const root of roots) {
218
+ const resolvedRoot = path.resolve(root);
219
+ if (!isSubPath(worktreePath, resolvedRoot)) {
220
+ throw new Error(`Artifact root must stay under ${worktreePath}.`);
221
+ }
222
+ await ensureNoSymlinkPathSegments(worktreePath, resolvedRoot, "Artifact root must not traverse symlinked path segments.");
223
+ await fs.rm(resolvedRoot, { recursive: true, force: true });
224
+ await fs.mkdir(resolvedRoot, { recursive: true });
225
+ }
226
+ }
227
+ async function materializeQaReworkPacket(worktreePath, request) {
228
+ const qaRework = request.qaRework;
229
+ if (!qaRework) {
230
+ return undefined;
231
+ }
232
+ const artifactRoot = artifactRootForRequest(worktreePath, request);
233
+ const packetFileName = qaReworkPacketFileName(qaRework.packetPath);
234
+ const packetPath = path.join(artifactRoot, packetFileName);
235
+ const paths = new Set([packetPath, path.join(artifactRoot, "qa-rework-packet.json")]);
236
+ const packetJson = qaRework.packetJson.endsWith("\n") ? qaRework.packetJson : `${qaRework.packetJson}\n`;
237
+ for (const target of paths) {
238
+ const resolvedTarget = path.resolve(target);
239
+ if (!isSubPath(worktreePath, resolvedTarget)) {
240
+ throw new Error(`QA rework packet path must stay under ${worktreePath}.`);
241
+ }
242
+ await ensureNoSymlinkPathSegments(worktreePath, path.dirname(resolvedTarget), "QA rework packet path must not traverse symlinked path segments.");
243
+ await fs.mkdir(path.dirname(resolvedTarget), { recursive: true });
244
+ await fs.writeFile(resolvedTarget, packetJson, "utf8");
245
+ }
246
+ return path.resolve(packetPath);
247
+ }
248
+ function artifactRootForRequest(worktreePath, request) {
249
+ const ownerType = normalizeOwnerType(request.ownerType);
250
+ const ownerId = request.ownerId?.trim();
251
+ if (ownerType && ownerId) {
252
+ return ownerArtifactRoot(worktreePath, ownerType, ownerId);
253
+ }
254
+ return workItemArtifactRoot(worktreePath, request.workItem.id);
255
+ }
256
+ function workItemArtifactRoot(worktreePath, workItemId) {
257
+ return path.join(worktreePath, ".forge", "work-items", safeSegment(workItemId, "work item id"), "artifacts");
258
+ }
259
+ function ownerArtifactRoot(worktreePath, ownerType, ownerId) {
260
+ return path.join(worktreePath, ".forge", ownerType === "story" ? "stories" : "work-items", safeSegment(ownerId, "owner id"), "artifacts");
261
+ }
262
+ function normalizeOwnerType(value) {
263
+ const normalized = (value ?? "").trim().toLowerCase().replace(/-/g, "_");
264
+ if (normalized === "story") {
265
+ return "story";
266
+ }
267
+ if (normalized === "work_item" || normalized === "workitem" || normalized === "work item") {
268
+ return "work_item";
269
+ }
270
+ return null;
271
+ }
272
+ function qaReworkPacketFileName(packetPath) {
273
+ const normalized = packetPath.trim().replace(/\\/g, "/");
274
+ const fileName = normalized.split("/").filter(Boolean).at(-1) ?? "";
275
+ return /^qa-rework-packet(?:\.attempt-\d+)?\.json$/i.test(fileName)
276
+ ? fileName
277
+ : "qa-rework-packet.json";
278
+ }
195
279
  export function normalizeBranchName(value, fallback) {
196
280
  const branch = (value?.trim() || fallback).trim();
197
281
  if (branch.length === 0 ||
@@ -204,6 +288,7 @@ export function normalizeBranchName(value, fallback) {
204
288
  branch.endsWith(".") ||
205
289
  branch.toLowerCase().endsWith(".lock") ||
206
290
  /\s/.test(branch) ||
291
+ // eslint-disable-next-line no-control-regex -- Git branch validation must reject ASCII control characters.
207
292
  /[\u0000-\u001f~^:?*[\\]/.test(branch) ||
208
293
  branch.split("/").some((part) => part.length === 0 || part.startsWith(".") || part.toLowerCase().endsWith(".lock"))) {
209
294
  throw new Error("Git branch name must be valid, without whitespace, and cannot start with '-'.");
@@ -240,6 +325,39 @@ async function gitMetadataExists(repoPath, allowedRoot) {
240
325
  const resolvedGitDir = await resolveExistingDirectoryPath(gitDirPath);
241
326
  return isSubPath(allowedRoot, resolvedGitDir);
242
327
  }
328
+ async function ensureNoSymlinkPathSegments(root, targetPath, errorMessage) {
329
+ const resolvedRoot = path.resolve(root);
330
+ const resolvedTarget = path.resolve(targetPath);
331
+ const relative = path.relative(resolvedRoot, resolvedTarget);
332
+ if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
333
+ throw new Error(errorMessage);
334
+ }
335
+ let current = resolvedRoot;
336
+ const rootStat = await fs.lstat(current);
337
+ if (rootStat.isSymbolicLink()) {
338
+ throw new Error(errorMessage);
339
+ }
340
+ if (!relative) {
341
+ return;
342
+ }
343
+ for (const segment of relative.split(path.sep)) {
344
+ if (!segment) {
345
+ continue;
346
+ }
347
+ current = path.join(current, segment);
348
+ const stat = await fs.lstat(current).catch((error) => {
349
+ if (error.code === "ENOENT")
350
+ return null;
351
+ throw error;
352
+ });
353
+ if (!stat) {
354
+ return;
355
+ }
356
+ if (stat.isSymbolicLink()) {
357
+ throw new Error(errorMessage);
358
+ }
359
+ }
360
+ }
243
361
  async function pathExists(inputPath) {
244
362
  return fs.stat(inputPath).then(() => true).catch(() => false);
245
363
  }
@@ -259,6 +377,14 @@ function uniqueStrings(values) {
259
377
  }
260
378
  return result;
261
379
  }
380
+ function worktreeKeySegments(worktreeKey, fallbackKind, fallbackShortId) {
381
+ const segments = worktreeKey
382
+ .split("/")
383
+ .map((segment) => segment.trim())
384
+ .filter((segment) => segment.length > 0)
385
+ .map((segment) => safeSegment(segment, "worktree"));
386
+ return segments.length > 0 ? segments : [fallbackKind, fallbackShortId];
387
+ }
262
388
  function isSinglePathSegment(value) {
263
389
  return value.length > 0 && value !== "." && value !== ".." && !value.includes("/") && !value.includes("\\");
264
390
  }
@@ -1,12 +1,18 @@
1
- import type { DispatchRepo, DispatchWorkItem, DispatchResolvedArtifact } from "../session/session.types.js";
1
+ import type { DispatchQaReworkContext, 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;
5
5
  readonly workItem: DispatchWorkItem;
6
6
  readonly baseBranch: string;
7
+ readonly worktreeKey?: string | null;
8
+ readonly ownerType?: string | null;
9
+ readonly ownerId?: string | null;
10
+ readonly lane: string;
11
+ readonly phase: string;
7
12
  readonly allowedRepoRoot: string;
8
13
  readonly cleanupWorktrees: boolean;
9
14
  readonly artifacts: readonly DispatchResolvedArtifact[];
15
+ readonly qaRework?: DispatchQaReworkContext | null;
10
16
  readonly warmPathOverrides?: readonly string[];
11
17
  }
12
18
  export interface WorktreeHandle {
@@ -16,4 +22,5 @@ export interface WorktreeHandle {
16
22
  readonly baseBranch: string;
17
23
  readonly cleanupWorktrees: boolean;
18
24
  readonly dependencyWarm?: WarmDependenciesResult;
25
+ readonly qaReworkPacketPath?: string;
19
26
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loucompanion/forge-bridge",
3
- "version": "0.1.1-dev.32d4dd5874ee",
3
+ "version": "0.1.1-dev.40fbc494669b",
4
4
  "description": "Forge runner bridge CLI for connecting local developer machines to Forge dispatch.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -30,7 +30,7 @@
30
30
  "url": "https://github.com/coleman399/Forge/issues"
31
31
  },
32
32
  "engines": {
33
- "node": ">=22.0.0"
33
+ "node": ">=22.13.0"
34
34
  },
35
35
  "publishConfig": {
36
36
  "access": "public"
@@ -39,14 +39,19 @@
39
39
  "build": "tsc -p tsconfig.json",
40
40
  "test": "vitest run",
41
41
  "test:watch": "vitest",
42
- "smoke:package": "npm run build && node scripts/smoke-package.mjs"
42
+ "smoke:package": "npm run build && node scripts/smoke-package.mjs",
43
+ "lint": "eslint \"src/**/*.ts\" \"scripts/**/*.mjs\" \"vitest.config.ts\""
43
44
  },
44
45
  "dependencies": {
45
46
  "@microsoft/signalr": "^8.0.17"
46
47
  },
47
48
  "devDependencies": {
49
+ "@eslint/js": "^10.0.1",
48
50
  "@types/node": "^22.15.32",
51
+ "eslint": "^10.5.0",
52
+ "globals": "^17.6.0",
49
53
  "typescript": "^5.8.3",
54
+ "typescript-eslint": "^8.61.1",
50
55
  "vitest": "^3.2.3"
51
56
  }
52
57
  }