forge-workflow 0.1.0-beta.4 → 0.1.0-beta.6
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/AGENTS.md +18 -7
- package/CHANGELOG.md +79 -1
- package/CLAUDE.md +0 -12
- package/CODING_STANDARDS.md +72 -0
- package/README.md +6 -2
- package/bin/forge-cmd.js +20 -0
- package/bin/forge.js +28 -375
- package/docs/INDEX.md +1 -1
- package/docs/guides/BEADS_GITHUB_SYNC.md +2 -31
- package/docs/guides/MIGRATION.md +4 -4
- package/docs/guides/SETUP.md +16 -16
- package/docs/reference/COMMANDS.md +8 -5
- package/docs/reference/FORGE_KERNEL_STORAGE_MODEL.md +4 -0
- package/docs/reference/INSIGHTS_RECAP.md +9 -20
- package/docs/reference/INSTALL.md +4 -0
- package/docs/reference/LEGACY_CLAIM_REPAIR.md +112 -0
- package/docs/reference/RELEASE.md +5 -3
- package/docs/reference/TOOLCHAIN.md +8 -0
- package/docs/reference/github-accounts.md +134 -0
- package/docs/reference/protected-state-surfaces.md +4 -4
- package/docs/reference/shepherd.md +114 -35
- package/lefthook.yml +12 -0
- package/lib/activation/ensure-forge-home.js +33 -15
- package/lib/adapters/pr-state-adapter.js +359 -144
- package/lib/audit-evidence.js +71 -110
- package/lib/base-remote.js +138 -0
- package/lib/beta5-compatibility-evidence.js +1093 -0
- package/lib/bun-lockfile-proof.js +413 -0
- package/lib/bun-workflow-pins.js +461 -0
- package/lib/capabilities/index.js +9 -0
- package/lib/capabilities/model.js +141 -0
- package/lib/capabilities/probes.js +347 -0
- package/lib/capped-jsonl-log.js +236 -0
- package/lib/codex-skills.js +2 -2
- package/lib/commands/_manifest.js +1 -0
- package/lib/commands/_registry.js +50 -20
- package/lib/commands/clean.js +252 -32
- package/lib/commands/dev.js +4 -33
- package/lib/commands/doctor.js +37 -6
- package/lib/commands/gate.js +197 -27
- package/lib/commands/github.js +215 -0
- package/lib/commands/hooks.js +276 -30
- package/lib/commands/insights.js +8 -3
- package/lib/commands/memory.js +66 -2
- package/lib/commands/merge.js +1265 -58
- package/lib/commands/plan.js +33 -2
- package/lib/commands/pr.js +3 -1
- package/lib/commands/preflight.js +21 -4
- package/lib/commands/prime.js +21 -8
- package/lib/commands/push.js +146 -54
- package/lib/commands/recall.js +127 -49
- package/lib/commands/recap.js +6 -1
- package/lib/commands/release.js +39 -3
- package/lib/commands/remember.js +28 -4
- package/lib/commands/serve.js +26 -9
- package/lib/commands/setup.js +323 -98
- package/lib/commands/shepherd.js +591 -73
- package/lib/commands/ship.js +36 -91
- package/lib/commands/skill.js +127 -11
- package/lib/commands/status.js +17 -1
- package/lib/commands/team.js +47 -8
- package/lib/commands/test.js +187 -38
- package/lib/commands/validate.js +65 -21
- package/lib/commands/worktree.js +359 -45
- package/lib/core/runtime-graph.js +1 -1
- package/lib/doc-assertions.js +297 -0
- package/lib/existing-tdd-gate.js +253 -0
- package/lib/fixtures/beta5-corpus/v1/README.md +9 -0
- package/lib/fixtures/beta5-corpus/v1/contract/command-contract.json +26 -0
- package/lib/fixtures/beta5-corpus/v1/contract/package-contract.json +13 -0
- package/lib/fixtures/beta5-corpus/v1/contract/workflow-stage-matrix.json +8 -0
- package/lib/fixtures/beta5-corpus/v1/manifest.json +25 -0
- package/lib/fixtures/beta5-corpus/v1/state/comments.jsonl +1 -0
- package/lib/fixtures/beta5-corpus/v1/state/config.yaml +6 -0
- package/lib/fixtures/beta5-corpus/v1/state/dependencies.jsonl +1 -0
- package/lib/fixtures/beta5-corpus/v1/state/issues.jsonl +2 -0
- package/lib/fixtures/beta5-corpus/v1/state/kernel.sql +20 -0
- package/lib/forge-context.js +1 -4
- package/lib/forge-issues.js +134 -32
- package/lib/gate-events.js +98 -10
- package/lib/git-defaults.js +56 -0
- package/lib/github-context.js +308 -0
- package/lib/global-flags.js +1 -0
- package/lib/harness-capability-matrix.js +3 -3
- package/lib/hook-renderer.js +122 -5
- package/lib/insights.js +96 -80
- package/lib/issue-render.js +19 -0
- package/lib/kernel/backing-issue.js +14 -2
- package/lib/kernel/broker.js +739 -31
- package/lib/kernel/claim-reconciler.js +238 -0
- package/lib/kernel/cli-broker-factory.js +12 -1
- package/lib/kernel/close-on-merge.js +154 -0
- package/lib/kernel/fs-class.js +42 -25
- package/lib/kernel/lease-enforcer.js +9 -4
- package/lib/kernel/legacy-claim-repair.js +442 -0
- package/lib/kernel/live-claim-projection.js +26 -0
- package/lib/kernel/migrations.js +118 -3
- package/lib/kernel/readiness-model.js +184 -12
- package/lib/kernel/schema.js +49 -1
- package/lib/kernel/sqlite-driver.js +3435 -172
- package/lib/kernel/taxonomy-validator.js +4 -1
- package/lib/kernel/windows-private-acl.js +239 -0
- package/lib/lefthook-wiring.js +21 -1
- package/lib/memory/hygiene.js +191 -0
- package/lib/memory/router.js +110 -28
- package/lib/memory/usage-evidence.js +4 -0
- package/lib/memory-digest.js +106 -15
- package/lib/memory-recall-events.js +145 -0
- package/lib/memory-recall.js +71 -10
- package/lib/merge-rules.js +143 -21
- package/lib/npm-publish-workflow.js +465 -0
- package/lib/orientation.js +68 -43
- package/lib/package-root.js +2 -0
- package/lib/plugin-catalog.js +14 -4
- package/lib/pr-bundle.js +5 -6
- package/lib/pr-monitor/auto-actions.js +169 -28
- package/lib/pr-monitor/differ.js +110 -4
- package/lib/pr-monitor/events.js +0 -0
- package/lib/pr-monitor/flow-monitor.js +1424 -0
- package/lib/pr-monitor/gather.js +251 -44
- package/lib/pr-monitor/journal.js +18 -39
- package/lib/pr-monitor/monitor.js +117 -10
- package/lib/pr-monitor/process-identity.js +117 -0
- package/lib/pr-monitor/reconcile-executor.js +1129 -470
- package/lib/pr-monitor/reconcile.js +0 -0
- package/lib/pr-monitor/render-summary.js +293 -0
- package/lib/pr-monitor/review-preflight.js +269 -0
- package/lib/pr-monitor/shepherd-lease.js +38 -20
- package/lib/pr-monitor/verdict.js +438 -0
- package/lib/pr-monitor/watch-lifecycle.js +145 -27
- package/lib/pr-monitor/watch-owner.js +1414 -0
- package/lib/pr-monitor/watch.js +129 -58
- package/lib/pr-pull.js +33 -14
- package/lib/pr-shepherd.js +51 -11
- package/lib/preflight/gates.js +65 -18
- package/lib/preflight/runner.js +5 -0
- package/lib/project-memory.js +178 -4
- package/lib/protected-state-authority.js +1100 -0
- package/lib/protected-state-surfaces.js +243 -45
- package/lib/release-readiness.js +53 -7
- package/lib/review-adapter.js +65 -0
- package/lib/shell-utils.js +1 -1
- package/lib/skills-sync.js +71 -35
- package/lib/smart-merge.js +28 -4
- package/lib/symlink-utils.js +74 -26
- package/lib/upgrade-safety.js +39 -0
- package/lib/using-forge.js +19 -6
- package/lib/validation/risk-manifest.js +339 -0
- package/lib/workflow/enforce-stage.js +44 -0
- package/lib/workflow/plan-authority.js +225 -0
- package/package.json +12 -9
- package/scripts/commitlint.js +13 -15
- package/scripts/doc-asserting-tests.js +158 -0
- package/scripts/generate-risk-manifest.js +91 -0
- package/scripts/github-context-bridge.sh +10 -0
- package/scripts/legacy-claim-repair.js +145 -0
- package/scripts/lib/behavioral-eval-runner.js +310 -0
- package/scripts/lib/behavioral-eval-runtime.js +457 -0
- package/scripts/lib/eval-evidence.js +328 -0
- package/scripts/lib/eval-runner.js +81 -41
- package/scripts/lib/immutable-eval-corpus.js +309 -0
- package/scripts/lib/promotion-evidence-loader.js +94 -0
- package/scripts/lib/promotion-scorecard.js +314 -0
- package/scripts/npm-release-receipt.js +134 -0
- package/scripts/process-tree.js +773 -0
- package/scripts/protected-state-check.js +479 -31
- package/scripts/run-command-eval.js +29 -1
- package/scripts/sync-agent-skills.js +333 -34
- package/scripts/sync-d20-audit.js +172 -0
- package/scripts/test-full-suite.js +935 -37
- package/scripts/test-profile.js +13 -3
- package/scripts/test.js +271 -57
- package/skills/coverage.json +1 -0
- package/skills/review/SKILL.md +6 -11
- package/skills/review/evals/scorecard.json +4 -4
- package/skills/rollback/SKILL.md +4 -11
- package/skills/rollback/evals/scorecard.json +3 -3
- package/skills/setup/SKILL.md +18 -0
- package/skills/setup/evals/scorecard.json +3 -3
- package/skills/shepherd/SKILL.md +39 -16
- package/skills/shepherd/evals/scorecard.json +4 -4
- package/skills/ship/SKILL.md +4 -12
- package/skills/ship/evals/scorecard.json +3 -3
- package/skills/validate/SKILL.md +3 -0
- package/skills/validate/evals/scorecard.json +1 -1
- package/skills/worktree/SKILL.md +6 -1
- package/skills/worktree/evals/scorecard.json +2 -2
- package/lib/beads-setup.js +0 -538
- package/lib/beads-sync-scaffold.js +0 -189
- package/lib/pat-setup.js +0 -207
- package/lib/pr-monitor/render-sticky.js +0 -206
- package/lib/pr-monitor/upsert-sticky.js +0 -169
- package/scripts/beads-context.sh +0 -577
- package/scripts/beads-migrate-to-dolt.sh +0 -7
- package/scripts/beads-upgrade-smoke.sh +0 -284
- package/scripts/lib/beads-migrate-to-dolt.mjs +0 -503
package/lib/commands/worktree.js
CHANGED
|
@@ -3,7 +3,12 @@
|
|
|
3
3
|
const { execFileSync, spawnSync } = require('node:child_process');
|
|
4
4
|
const fs = require('node:fs');
|
|
5
5
|
const path = require('node:path');
|
|
6
|
-
const { detectDefaultBranch } = require('../
|
|
6
|
+
const { detectDefaultBranch } = require('../git-defaults');
|
|
7
|
+
const {
|
|
8
|
+
resolveRepoRelativePath,
|
|
9
|
+
assertNoAncestorSymlinkEscape,
|
|
10
|
+
assertNoSymlinkEscape,
|
|
11
|
+
} = require('../protected-state-surfaces');
|
|
7
12
|
|
|
8
13
|
/**
|
|
9
14
|
* Forge Worktree Command
|
|
@@ -42,12 +47,13 @@ function detectPackageManager(projectRoot, fsApi) {
|
|
|
42
47
|
/**
|
|
43
48
|
* Check if a git branch already exists.
|
|
44
49
|
* @param {string} branchName - Branch name to check
|
|
50
|
+
* @param {string} projectRoot - Repo root to run git in
|
|
45
51
|
* @param {Function} runFile - execFileSync function (for DI)
|
|
46
52
|
* @returns {boolean}
|
|
47
53
|
*/
|
|
48
|
-
function branchExists(branchName, runFile) {
|
|
54
|
+
function branchExists(branchName, projectRoot, runFile) {
|
|
49
55
|
try {
|
|
50
|
-
const output = runFile('git', ['branch', '--list', branchName], { stdio: 'pipe' });
|
|
56
|
+
const output = runFile('git', ['-C', projectRoot, 'branch', '--list', branchName], { stdio: 'pipe' });
|
|
51
57
|
return output.toString().trim().length > 0;
|
|
52
58
|
} catch (_err) { /* intentional: branch doesn't exist */ // NOSONAR S2486
|
|
53
59
|
return false;
|
|
@@ -99,35 +105,226 @@ function isLinkPermissionError(error) {
|
|
|
99
105
|
return ['EPERM', 'EACCES', 'ENOSYS', 'UV_EPERM'].includes(error && error.code);
|
|
100
106
|
}
|
|
101
107
|
|
|
108
|
+
// Yarn is probe-less here: a Plug'n'Play install produces no node_modules tree to
|
|
109
|
+
// inspect, and `yarn check` was removed in Berry, so there is nothing to verify.
|
|
110
|
+
const PROBE_LESS_MANAGERS = new Set(['yarn']);
|
|
111
|
+
|
|
112
|
+
function samePath(left, right) {
|
|
113
|
+
return path.relative(left, right) === '';
|
|
114
|
+
}
|
|
115
|
+
|
|
102
116
|
/**
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
* @param {string}
|
|
107
|
-
* @param {Function} spawnFn - spawnSync-compatible function
|
|
117
|
+
* Direct dependencies the root and its workspaces declare. Optional/peer deps
|
|
118
|
+
* are excluded — they may legitimately be absent. Workspace discovery fails
|
|
119
|
+
* closed so an incomplete manifest set cannot prove an install complete.
|
|
120
|
+
* @param {string} worktreePath - Absolute path to the worktree
|
|
108
121
|
* @param {object} fsApi - fs module (for DI)
|
|
109
|
-
* @returns {{
|
|
110
|
-
* @throws {Error} when the install cannot be spawned or exits non-zero.
|
|
122
|
+
* @returns {Array<{dir: string, name: string, workspaceDir: string|null}>} Declaring directory and package name
|
|
111
123
|
*/
|
|
112
|
-
function
|
|
113
|
-
|
|
114
|
-
|
|
124
|
+
function declaredDependencies(worktreePath, fsApi) {
|
|
125
|
+
let rootPkg;
|
|
126
|
+
try {
|
|
127
|
+
rootPkg = JSON.parse(fsApi.readFileSync(path.join(worktreePath, 'package.json'), 'utf8'));
|
|
128
|
+
} catch (error) {
|
|
129
|
+
throw new Error(`Cannot verify dependencies: unreadable worktree package.json: ${error.message}`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let workspacePatterns = [];
|
|
133
|
+
if (rootPkg.workspaces != null) {
|
|
134
|
+
workspacePatterns = Array.isArray(rootPkg.workspaces)
|
|
135
|
+
? rootPkg.workspaces
|
|
136
|
+
: rootPkg.workspaces.packages;
|
|
137
|
+
if (!Array.isArray(workspacePatterns)) {
|
|
138
|
+
throw new Error('Cannot verify dependencies: workspaces must be an array or an object with a packages array');
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (workspacePatterns.length && typeof fsApi.globSync !== 'function') {
|
|
143
|
+
throw new Error('Cannot verify dependencies: this runtime does not provide workspace glob support');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const manifestPaths = new Set();
|
|
147
|
+
for (const rawPattern of workspacePatterns) {
|
|
148
|
+
if (typeof rawPattern !== 'string' || !rawPattern.trim()) {
|
|
149
|
+
throw new Error(`Cannot verify dependencies: invalid workspace pattern ${JSON.stringify(rawPattern)}`);
|
|
150
|
+
}
|
|
151
|
+
const exclude = rawPattern.startsWith('!');
|
|
152
|
+
const pattern = (exclude ? rawPattern.slice(1) : rawPattern).replace(/\\/g, '/').replace(/\/+$/, '');
|
|
153
|
+
const resolvedPattern = resolveRepoRelativePath(worktreePath, pattern);
|
|
154
|
+
if (!pattern || pattern.includes('\0') || path.isAbsolute(pattern) || !resolvedPattern.insideRoot) {
|
|
155
|
+
throw new Error(`Cannot verify dependencies: workspace pattern resolves outside the worktree: ${rawPattern}`);
|
|
156
|
+
}
|
|
115
157
|
|
|
158
|
+
let matches;
|
|
159
|
+
try {
|
|
160
|
+
matches = fsApi.globSync(`${pattern}/package.json`, {
|
|
161
|
+
cwd: worktreePath,
|
|
162
|
+
exclude: candidate => String(candidate).split(/[\\/]/).includes('node_modules'),
|
|
163
|
+
});
|
|
164
|
+
} catch (error) {
|
|
165
|
+
throw new Error(`Cannot verify dependencies: workspace glob failed for ${rawPattern}: ${error.message}`);
|
|
166
|
+
}
|
|
167
|
+
for (const match of matches) {
|
|
168
|
+
const resolved = resolveRepoRelativePath(worktreePath, String(match));
|
|
169
|
+
if (!resolved.insideRoot) {
|
|
170
|
+
throw new Error(`Cannot verify dependencies: workspace glob resolved outside the worktree: ${match}`);
|
|
171
|
+
}
|
|
172
|
+
const existing = [...manifestPaths].find(manifestPath => samePath(manifestPath, resolved.target));
|
|
173
|
+
if (exclude && existing) manifestPaths.delete(existing);
|
|
174
|
+
else if (!exclude && !existing) manifestPaths.add(resolved.target);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const manifests = [{ dir: worktreePath, pkg: rootPkg }];
|
|
179
|
+
for (const manifestPath of manifestPaths) {
|
|
180
|
+
try {
|
|
181
|
+
manifests.push({
|
|
182
|
+
dir: path.dirname(manifestPath),
|
|
183
|
+
pkg: JSON.parse(fsApi.readFileSync(manifestPath, 'utf8')),
|
|
184
|
+
});
|
|
185
|
+
} catch (error) {
|
|
186
|
+
if (error && ['ENOENT', 'ENOTDIR'].includes(error.code)) continue;
|
|
187
|
+
throw new Error(`Cannot verify dependencies: unreadable workspace manifest ${manifestPath}: ${error.message}`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const workspaceDirs = new Map();
|
|
192
|
+
for (const { dir, pkg } of manifests.slice(1)) {
|
|
193
|
+
if (!pkg.name) continue;
|
|
194
|
+
if (workspaceDirs.has(pkg.name) && !samePath(workspaceDirs.get(pkg.name), dir)) {
|
|
195
|
+
throw new Error(`Cannot verify dependencies: duplicate workspace package name ${pkg.name}`);
|
|
196
|
+
}
|
|
197
|
+
workspaceDirs.set(pkg.name, dir);
|
|
198
|
+
}
|
|
199
|
+
return manifests.flatMap(({ dir, pkg }) => [
|
|
200
|
+
...Object.keys(pkg.dependencies || {}),
|
|
201
|
+
...Object.keys(pkg.devDependencies || {}),
|
|
202
|
+
].map(name => ({ dir, name, workspaceDir: workspaceDirs.get(name) || null })));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function dependencyCandidatePresent(candidate, workspaceDir, fsApi) {
|
|
206
|
+
if (!fsApi.existsSync(path.join(candidate, 'package.json'))) return false;
|
|
207
|
+
if (!workspaceDir) return true;
|
|
208
|
+
try {
|
|
209
|
+
return path.relative(fsApi.realpathSync(workspaceDir), fsApi.realpathSync(candidate)) === '';
|
|
210
|
+
} catch (_err) { /* intentional: an unresolvable workspace link is not installed */ // NOSONAR S2486
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Verify the install actually landed: every declared direct dependency must be a
|
|
217
|
+
* real package in the worktree's node_modules.
|
|
218
|
+
*
|
|
219
|
+
* The package managers cannot answer this. `bun pm ls`, `bun list`, `bun pm why`
|
|
220
|
+
* and `bun install --dry-run` all read the LOCKFILE and exit 0 while packages are
|
|
221
|
+
* physically missing; `pnpm ls --depth=0` does the same; `bun -e require.resolve`
|
|
222
|
+
* is defeated by Bun's auto-install. Only `npm ls --depth=0` exits non-zero, so a
|
|
223
|
+
* filesystem check is the one probe that holds for every manager.
|
|
224
|
+
*
|
|
225
|
+
* @param {string} worktreePath - Absolute path to the worktree
|
|
226
|
+
* @param {object} fsApi - fs module (for DI)
|
|
227
|
+
* @param {string|null} pkgManager - Detected package manager
|
|
228
|
+
* @returns {boolean} true when nothing is missing
|
|
229
|
+
*/
|
|
230
|
+
function dependenciesPresent(worktreePath, fsApi, pkgManager) {
|
|
231
|
+
if (!pkgManager) return true;
|
|
232
|
+
const deps = declaredDependencies(worktreePath, fsApi);
|
|
233
|
+
return deps.every(({ dir, name, workspaceDir }) => {
|
|
234
|
+
const localPackage = path.join(dir, 'node_modules', name);
|
|
235
|
+
if (fsApi.existsSync(localPackage)) {
|
|
236
|
+
return dependencyCandidatePresent(localPackage, workspaceDir, fsApi);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const rootPackage = path.join(worktreePath, 'node_modules', name);
|
|
240
|
+
if (dir === worktreePath || (pkgManager === 'bun' && !workspaceDir)) return false;
|
|
241
|
+
return dependencyCandidatePresent(rootPackage, workspaceDir, fsApi);
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function detachModulesLink(modulesPath, fsApi) {
|
|
246
|
+
try {
|
|
247
|
+
if (!fsApi.lstatSync(modulesPath).isSymbolicLink()) return false;
|
|
248
|
+
fsApi.unlinkSync(modulesPath);
|
|
249
|
+
return true;
|
|
250
|
+
} catch (error) {
|
|
251
|
+
if (error && error.code === 'ENOENT') return false;
|
|
252
|
+
throw error;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const STDERR_DETAIL_LIMIT = 400;
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The install's captured stderr, trimmed and bounded — stdio 'pipe' otherwise
|
|
260
|
+
* hides why the install failed.
|
|
261
|
+
* @param {object} result - spawnSync result
|
|
262
|
+
* @returns {string} A leading-newline detail block, or '' when there is nothing
|
|
263
|
+
*/
|
|
264
|
+
function stderrDetail(result) {
|
|
265
|
+
const text = result && result.stderr ? String(result.stderr).trim() : '';
|
|
266
|
+
if (!text) return '';
|
|
267
|
+
return `\n${text.length > STDERR_DETAIL_LIMIT ? `${text.slice(0, STDERR_DETAIL_LIMIT)}...` : text}`;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Spawn one install, surfacing spawn errors, kills, and non-zero exits.
|
|
272
|
+
* @param {string} pkgManager - Detected package manager command
|
|
273
|
+
* @param {string[]} args - Install args (e.g. ['install'] or ['install', '--force'])
|
|
274
|
+
* @param {string} worktreePath - cwd for the install
|
|
275
|
+
* @param {Function} spawnFn - spawnSync-compatible function
|
|
276
|
+
* @throws {Error} when the install cannot be spawned, is killed, or exits non-zero.
|
|
277
|
+
*/
|
|
278
|
+
function spawnInstall(pkgManager, args, worktreePath, spawnFn) {
|
|
116
279
|
// shell:true on Windows: npm/pnpm/yarn are .cmd shims that cannot be spawned
|
|
117
280
|
// directly (ENOENT / EINVAL). pkgManager comes from lockfile detection (fixed
|
|
118
281
|
// set) and args are hardcoded, so no user input reaches the shell.
|
|
119
|
-
const result = spawnFn(pkgManager,
|
|
282
|
+
const result = spawnFn(pkgManager, args, {
|
|
120
283
|
cwd: worktreePath,
|
|
121
284
|
stdio: 'pipe',
|
|
122
285
|
shell: process.platform === 'win32',
|
|
123
286
|
});
|
|
287
|
+
const label = `${pkgManager} ${args.join(' ')}`;
|
|
124
288
|
if (result && result.error) {
|
|
125
|
-
throw new Error(`Dependency install failed: could not run '${
|
|
289
|
+
throw new Error(`Dependency install failed: could not run '${label}' in ${worktreePath}: ${result.error.message}${stderrDetail(result)}`);
|
|
290
|
+
}
|
|
291
|
+
// A killed install (OOM, SIGTERM, CI cancel) reports status null with a signal
|
|
292
|
+
// set — never a non-zero code — so it must be checked on its own.
|
|
293
|
+
if (result && result.signal) {
|
|
294
|
+
throw new Error(`Dependency install failed: '${label}' was killed by ${result.signal} in ${worktreePath}${stderrDetail(result)}`);
|
|
126
295
|
}
|
|
127
296
|
if (result && typeof result.status === 'number' && result.status !== 0) {
|
|
128
|
-
throw new Error(`Dependency install failed: '${
|
|
297
|
+
throw new Error(`Dependency install failed: '${label}' exited with code ${result.status} in ${worktreePath}${stderrDetail(result)}`);
|
|
129
298
|
}
|
|
130
|
-
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Run package install in the new worktree if a package manager is detected, then
|
|
303
|
+
* VERIFY it. A stale shared store makes a plain install report "no changes" while
|
|
304
|
+
* leaving packages and binaries missing, so the exit code alone is not evidence —
|
|
305
|
+
* on a failed probe, reinstall once with --force and re-probe.
|
|
306
|
+
* @param {string} worktreePath - Absolute path to the new worktree
|
|
307
|
+
* @param {string} projectRoot - Absolute path to the project root
|
|
308
|
+
* @param {Function} spawnFn - spawnSync-compatible function
|
|
309
|
+
* @param {object} fsApi - fs module (for DI)
|
|
310
|
+
* @param {string|null} [pkgManager] - Detected package manager
|
|
311
|
+
* @returns {{ linked: boolean, installed: boolean, healed: boolean }}
|
|
312
|
+
* @throws {Error} when the install fails, or the tree is still broken after --force.
|
|
313
|
+
*/
|
|
314
|
+
function runInstall(worktreePath, projectRoot, spawnFn, fsApi, pkgManager = detectPackageManager(projectRoot, fsApi)) {
|
|
315
|
+
if (!pkgManager) return { linked: false, installed: false, healed: false };
|
|
316
|
+
|
|
317
|
+
spawnInstall(pkgManager, ['install'], worktreePath, spawnFn);
|
|
318
|
+
|
|
319
|
+
if (PROBE_LESS_MANAGERS.has(pkgManager) || dependenciesPresent(worktreePath, fsApi, pkgManager)) {
|
|
320
|
+
return { linked: false, installed: true, healed: false };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
spawnInstall(pkgManager, ['install', '--force'], worktreePath, spawnFn);
|
|
324
|
+
if (!dependenciesPresent(worktreePath, fsApi, pkgManager)) {
|
|
325
|
+
throw new Error(`Dependency install could not be verified: packages are still missing in ${worktreePath} after '${pkgManager} install --force'. Run it manually there and inspect the output.`);
|
|
326
|
+
}
|
|
327
|
+
return { linked: false, installed: true, healed: true };
|
|
131
328
|
}
|
|
132
329
|
|
|
133
330
|
/**
|
|
@@ -142,30 +339,110 @@ function runInstall(worktreePath, projectRoot, spawnFn, fsApi) {
|
|
|
142
339
|
* @param {Function} deps.spawnFn - spawnSync-compatible function
|
|
143
340
|
* @param {object} deps.fsApi - fs module (for DI)
|
|
144
341
|
* @param {string} deps.platform - process.platform value
|
|
145
|
-
* @returns {{ linked: boolean, installed: boolean }}
|
|
342
|
+
* @returns {{ linked: boolean, installed: boolean, healed: boolean }}
|
|
146
343
|
* @throws {Error} when linking fails for a non-privilege reason or install fails.
|
|
147
344
|
*/
|
|
148
345
|
function setupWorktreeDeps(worktreePath, projectRoot, { spawnFn, fsApi, platform }) {
|
|
149
346
|
const srcModules = path.join(projectRoot, 'node_modules');
|
|
150
347
|
const destModules = path.join(worktreePath, 'node_modules');
|
|
348
|
+
const pkgManager = detectPackageManager(projectRoot, fsApi);
|
|
349
|
+
|
|
350
|
+
// Keep the zero-install fast path only when root and workspace dependencies
|
|
351
|
+
// are complete; Bun's isolated linker keeps workspace packages below each workspace.
|
|
352
|
+
if (fsApi.existsSync(destModules)) {
|
|
353
|
+
if (dependenciesPresent(worktreePath, fsApi, pkgManager)) return { linked: false, installed: false, healed: false };
|
|
354
|
+
detachModulesLink(destModules, fsApi);
|
|
355
|
+
return runInstall(worktreePath, projectRoot, spawnFn, fsApi, pkgManager);
|
|
356
|
+
}
|
|
151
357
|
|
|
152
|
-
//
|
|
153
|
-
|
|
358
|
+
// existsSync follows links, so remove a dangling directory entry before
|
|
359
|
+
// either replacing it with the shared install or installing in its place.
|
|
360
|
+
detachModulesLink(destModules, fsApi);
|
|
154
361
|
|
|
155
362
|
// Fast path: link to the shared install when the main repo has one.
|
|
156
363
|
if (fsApi.existsSync(srcModules)) {
|
|
157
364
|
const linkType = platform === 'win32' ? 'junction' : 'dir';
|
|
158
365
|
try {
|
|
159
366
|
fsApi.symlinkSync(srcModules, destModules, linkType);
|
|
160
|
-
return { linked: true, installed: false };
|
|
161
367
|
} catch (error) {
|
|
162
368
|
// Degrade to a real install only when the link was refused for lack of
|
|
163
369
|
// privilege/support; any other failure is surfaced to the caller.
|
|
164
370
|
if (!isLinkPermissionError(error)) throw error;
|
|
371
|
+
return runInstall(worktreePath, projectRoot, spawnFn, fsApi, pkgManager);
|
|
165
372
|
}
|
|
373
|
+
if (dependenciesPresent(worktreePath, fsApi, pkgManager)) {
|
|
374
|
+
return { linked: true, installed: false, healed: false };
|
|
375
|
+
}
|
|
376
|
+
fsApi.unlinkSync(destModules);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return runInstall(worktreePath, projectRoot, spawnFn, fsApi, pkgManager);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function readExistingWorktreeLinkage(driver, worktreePath, reuse) {
|
|
383
|
+
if (!reuse || !driver || typeof driver.getWorktreeLinkage !== 'function') return null;
|
|
384
|
+
return driver.getWorktreeLinkage({ path: worktreePath }) || null;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function detectLinkageConflict({ projectRoot, existing, issueId, workFolder, reuse }) {
|
|
388
|
+
if (!reuse || !existing) return null;
|
|
389
|
+
const existingIssue = existing.issue_id || null;
|
|
390
|
+
const existingFolder = existing.work_folder || null;
|
|
391
|
+
const folderChanged = workFolder && existingFolder
|
|
392
|
+
&& path.resolve(projectRoot, workFolder) !== path.resolve(projectRoot, existingFolder);
|
|
393
|
+
if (!((issueId && existingIssue && issueId !== existingIssue) || folderChanged)) return null;
|
|
394
|
+
return {
|
|
395
|
+
issueId: issueId || existingIssue,
|
|
396
|
+
workFolder: workFolder || existingFolder,
|
|
397
|
+
reason: 'worktree linkage mismatch; existing issue/work-folder was not changed',
|
|
398
|
+
conflict: true,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function isAbsoluteWorkFolder(workFolder) {
|
|
403
|
+
return path.isAbsolute(workFolder) || path.posix.isAbsolute(workFolder) || path.win32.isAbsolute(workFolder);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function markerPathConflict(reason) {
|
|
407
|
+
return { conflict: true, reason: `worktree linkage marker path rejected: ${reason}` };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function validateMarkerTarget(projectRoot, workFolder, fsApi) {
|
|
411
|
+
if (!workFolder) return null;
|
|
412
|
+
if (isAbsoluteWorkFolder(workFolder)) return markerPathConflict('work-folder must be repo-relative');
|
|
413
|
+
|
|
414
|
+
const resolved = resolveRepoRelativePath(projectRoot, workFolder);
|
|
415
|
+
if (!resolved.insideRoot) return markerPathConflict('work-folder escapes project root');
|
|
416
|
+
const ancestorEscape = assertNoAncestorSymlinkEscape(resolved.root, resolved.target);
|
|
417
|
+
if (ancestorEscape) return markerPathConflict(ancestorEscape.reason);
|
|
418
|
+
|
|
419
|
+
const statSync = fsApi.statSync || fs.statSync;
|
|
420
|
+
const folderStat = fsApi.existsSync(resolved.target) ? statSync.call(fsApi, resolved.target) : null;
|
|
421
|
+
if (folderStat && !folderStat.isDirectory()) return markerPathConflict('work-folder is not a directory');
|
|
422
|
+
const markerPath = path.join(resolved.target, '.forge-issue');
|
|
423
|
+
const markerEscape = assertNoSymlinkEscape(resolved.root, markerPath);
|
|
424
|
+
if (markerEscape) {
|
|
425
|
+
return markerPathConflict(markerEscape.reason);
|
|
166
426
|
}
|
|
427
|
+
return { folderAbs: resolved.target, markerPath, folderExists: Boolean(folderStat) };
|
|
428
|
+
}
|
|
167
429
|
|
|
168
|
-
|
|
430
|
+
function reconcileIssueMarker({ target, linkedIssue, linkedFolder, fsApi }) {
|
|
431
|
+
if (!target || !linkedIssue || !target.folderExists) return null;
|
|
432
|
+
let markerIssue = null;
|
|
433
|
+
try {
|
|
434
|
+
if (fsApi.existsSync(target.markerPath)) markerIssue = fsApi.readFileSync(target.markerPath, 'utf8').trim() || null;
|
|
435
|
+
} catch { /* unreadable marker — let the write below repair it */ }
|
|
436
|
+
if (markerIssue && markerIssue !== linkedIssue) {
|
|
437
|
+
return {
|
|
438
|
+
issueId: linkedIssue,
|
|
439
|
+
workFolder: linkedFolder,
|
|
440
|
+
reason: 'worktree linkage mismatch; existing issue marker was not changed',
|
|
441
|
+
conflict: true,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
fsApi.writeFileSync(target.markerPath, `${linkedIssue}\n`, 'utf8');
|
|
445
|
+
return null;
|
|
169
446
|
}
|
|
170
447
|
|
|
171
448
|
/**
|
|
@@ -185,30 +462,44 @@ function setupWorktreeDeps(worktreePath, projectRoot, { spawnFn, fsApi, platform
|
|
|
185
462
|
* @param {string|null} params.issueId
|
|
186
463
|
* @param {string|null} params.workFolder - Repo-relative work-folder path.
|
|
187
464
|
* @param {object} params.opts - DI options (may inject `_kernelDriver`, `_exec`, `_fs`).
|
|
465
|
+
* @param {boolean} [params.reuse] - Validate existing linkage before reconciling.
|
|
188
466
|
* @returns {Promise<{registered: boolean, issueId: string|null, workFolder: string|null, reason?: string}>}
|
|
189
467
|
*/
|
|
190
|
-
async function registerWorktreeLinkage({ projectRoot, worktreePath, branch, issueId, workFolder, opts }) {
|
|
468
|
+
async function registerWorktreeLinkage({ projectRoot, worktreePath, branch, issueId, workFolder, opts, reuse = false }) {
|
|
191
469
|
const runFile = opts._exec || execFileSync;
|
|
192
470
|
const fsApi = opts._fs || fs;
|
|
193
471
|
try {
|
|
194
|
-
// Drop the folder → issue marker first — deterministic even if the DB write fails.
|
|
195
|
-
if (workFolder && issueId) {
|
|
196
|
-
const folderAbs = path.resolve(projectRoot, workFolder);
|
|
197
|
-
fsApi.mkdirSync(folderAbs, { recursive: true });
|
|
198
|
-
fsApi.writeFileSync(path.join(folderAbs, '.forge-issue'), `${issueId}\n`, 'utf8');
|
|
199
|
-
}
|
|
200
|
-
|
|
201
472
|
let driver = opts._kernelDriver;
|
|
202
473
|
if (!driver) {
|
|
203
474
|
// No reachable git repo → no shared Kernel; skip quietly (orientation still works
|
|
204
475
|
// off the folder heuristic). Avoids spawning `git` in a non-repo directory.
|
|
205
|
-
if (
|
|
206
|
-
|
|
476
|
+
if (fsApi.existsSync(path.join(projectRoot, '.git'))) {
|
|
477
|
+
// Lazy require: keep the worktree command light and avoid paying kernel setup
|
|
478
|
+
// when linkage is not needed. Migrated deps guarantee the 007 columns exist.
|
|
479
|
+
const { buildMigratedKernelIssueDeps } = require('../kernel/cli-broker-factory');
|
|
480
|
+
driver = (await buildMigratedKernelIssueDeps({ projectRoot })).kernelDriver;
|
|
207
481
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const existing = readExistingWorktreeLinkage(driver, worktreePath, reuse);
|
|
485
|
+
const linkageConflict = detectLinkageConflict({ projectRoot, existing, issueId, workFolder, reuse });
|
|
486
|
+
if (linkageConflict) return { registered: false, ...linkageConflict };
|
|
487
|
+
|
|
488
|
+
const existingIssue = existing?.issue_id || null;
|
|
489
|
+
const existingFolder = existing?.work_folder || null;
|
|
490
|
+
const linkedIssue = issueId || (reuse ? existingIssue : null);
|
|
491
|
+
const linkedFolder = workFolder || (reuse ? existingFolder : null);
|
|
492
|
+
const markerTarget = validateMarkerTarget(projectRoot, linkedFolder, fsApi);
|
|
493
|
+
if (markerTarget?.conflict) {
|
|
494
|
+
return { registered: false, issueId: linkedIssue || null, workFolder: linkedFolder || null, ...markerTarget };
|
|
495
|
+
}
|
|
496
|
+
const markerConflict = reconcileIssueMarker({ target: markerTarget, linkedIssue, linkedFolder, fsApi });
|
|
497
|
+
if (markerConflict) {
|
|
498
|
+
return { registered: false, ...markerConflict };
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (!driver) {
|
|
502
|
+
return { registered: false, issueId: linkedIssue || null, workFolder: linkedFolder || null, reason: 'no git repository' };
|
|
212
503
|
}
|
|
213
504
|
|
|
214
505
|
const { resolveGitCommonDir } = require('../kernel/broker');
|
|
@@ -219,13 +510,13 @@ async function registerWorktreeLinkage({ projectRoot, worktreePath, branch, issu
|
|
|
219
510
|
path: worktreePath,
|
|
220
511
|
branch,
|
|
221
512
|
actor: process.env.FORGE_ACTOR || null,
|
|
222
|
-
issue_id:
|
|
223
|
-
work_folder:
|
|
513
|
+
issue_id: linkedIssue || null,
|
|
514
|
+
work_folder: linkedFolder || null,
|
|
224
515
|
registered_at: new Date().toISOString(),
|
|
225
516
|
state: 'active',
|
|
226
517
|
});
|
|
227
518
|
|
|
228
|
-
return { registered: true, issueId:
|
|
519
|
+
return { registered: true, issueId: linkedIssue || null, workFolder: linkedFolder || null };
|
|
229
520
|
} catch (error) {
|
|
230
521
|
// Non-fatal: keep the worktree usable. Only warn when the caller explicitly asked
|
|
231
522
|
// for linkage (--issue/--work-folder); a plain `worktree create` stays quiet.
|
|
@@ -331,13 +622,30 @@ async function handleCreate(slug, flags, projectRoot, opts) {
|
|
|
331
622
|
// A detached worktree reports the literal "HEAD" — don't persist that; keep branchName.
|
|
332
623
|
if (head && head !== 'HEAD') existingBranch = head;
|
|
333
624
|
} catch (_error) { /* not a resolvable worktree HEAD — fall back to branchName */ }
|
|
334
|
-
const linkage = await registerWorktreeLinkage({ projectRoot, worktreePath, branch: existingBranch, issueId, workFolder, opts });
|
|
625
|
+
const linkage = await registerWorktreeLinkage({ projectRoot, worktreePath, branch: existingBranch, issueId, workFolder, opts, reuse: true });
|
|
626
|
+
if (linkage.conflict) {
|
|
627
|
+
return { success: false, reused: true, error: linkage.reason, worktreePath, linkage };
|
|
628
|
+
}
|
|
629
|
+
let deps;
|
|
630
|
+
try {
|
|
631
|
+
deps = setupWorktreeDeps(worktreePath, projectRoot, {
|
|
632
|
+
spawnFn: runSpawn,
|
|
633
|
+
fsApi,
|
|
634
|
+
platform: opts._platform || process.platform,
|
|
635
|
+
});
|
|
636
|
+
} catch (error) {
|
|
637
|
+
return { success: false, reused: true, error: error.message, worktreePath, linkage };
|
|
638
|
+
}
|
|
335
639
|
const backing = await autoFileBackingIssue({ projectRoot, worktreePath, branch: existingBranch, issueId, opts });
|
|
640
|
+
const healNote = deps.healed ? ' Healed dependencies (store was stale): reinstalled with --force.' : '';
|
|
336
641
|
return {
|
|
337
642
|
success: true,
|
|
338
643
|
reused: true,
|
|
339
|
-
message: `Worktree already exists at ${worktreePath}`,
|
|
644
|
+
message: `Worktree already exists at ${worktreePath}.${healNote}`,
|
|
340
645
|
worktreePath,
|
|
646
|
+
depsLinked: deps.linked,
|
|
647
|
+
depsInstalled: deps.installed,
|
|
648
|
+
depsHealed: deps.healed,
|
|
341
649
|
linkage,
|
|
342
650
|
backing,
|
|
343
651
|
};
|
|
@@ -350,13 +658,13 @@ async function handleCreate(slug, flags, projectRoot, opts) {
|
|
|
350
658
|
// the repo's DEFAULT branch — NOT the checkout's current HEAD — so the worktree
|
|
351
659
|
// never silently inherits unrelated WIP commits (B2). An existing branch is
|
|
352
660
|
// checked out as-is (no base applies).
|
|
353
|
-
const hasBranch = branchExists(branchName, runFile);
|
|
661
|
+
const hasBranch = branchExists(branchName, projectRoot, runFile);
|
|
354
662
|
let base = null;
|
|
355
663
|
if (hasBranch) {
|
|
356
|
-
runFile('git', ['worktree', 'add', worktreePath, branchName], { stdio: 'pipe' });
|
|
664
|
+
runFile('git', ['-C', projectRoot, 'worktree', 'add', worktreePath, branchName], { stdio: 'pipe' });
|
|
357
665
|
} else {
|
|
358
666
|
base = explicitBase || resolveDefaultBase(projectRoot, runFile);
|
|
359
|
-
runFile('git', ['worktree', 'add', worktreePath, '-b', branchName, base], { stdio: 'pipe' });
|
|
667
|
+
runFile('git', ['-C', projectRoot, 'worktree', 'add', worktreePath, '-b', branchName, base], { stdio: 'pipe' });
|
|
360
668
|
}
|
|
361
669
|
|
|
362
670
|
// Step 3: Populate node_modules (link to the shared install, else install).
|
|
@@ -375,11 +683,16 @@ async function handleCreate(slug, flags, projectRoot, opts) {
|
|
|
375
683
|
}
|
|
376
684
|
|
|
377
685
|
const linkage = await registerWorktreeLinkage({ projectRoot, worktreePath, branch: branchName, issueId, workFolder, opts });
|
|
686
|
+
if (linkage.conflict) {
|
|
687
|
+
return { success: false, error: linkage.reason, worktreePath, branch: branchName, linkage };
|
|
688
|
+
}
|
|
378
689
|
const backing = await autoFileBackingIssue({ projectRoot, worktreePath, branch: branchName, issueId, opts });
|
|
379
690
|
|
|
380
691
|
// Report the base so the fork point is never silent. `base` is null when an
|
|
381
692
|
// existing branch was checked out (no fork happened).
|
|
382
693
|
const baseNote = base ? `based on ${base}` : `existing branch ${branchName}`;
|
|
694
|
+
// Never let a heal be silent — a stale store is worth knowing about.
|
|
695
|
+
const healNote = deps.healed ? ' Healed dependencies (store was stale): reinstalled with --force.' : '';
|
|
383
696
|
return {
|
|
384
697
|
success: true,
|
|
385
698
|
worktreePath,
|
|
@@ -387,9 +700,10 @@ async function handleCreate(slug, flags, projectRoot, opts) {
|
|
|
387
700
|
base,
|
|
388
701
|
depsLinked: deps.linked,
|
|
389
702
|
depsInstalled: deps.installed,
|
|
703
|
+
depsHealed: deps.healed,
|
|
390
704
|
linkage,
|
|
391
705
|
backing,
|
|
392
|
-
output: `Created worktree ${worktreePath} on ${branchName} (${baseNote})
|
|
706
|
+
output: `Created worktree ${worktreePath} on ${branchName} (${baseNote}).${healNote}`,
|
|
393
707
|
};
|
|
394
708
|
}
|
|
395
709
|
|
|
@@ -534,7 +534,7 @@ const RESOLVED_RUNTIME_GRAPH = {
|
|
|
534
534
|
id: 'adapter.issue',
|
|
535
535
|
kind: 'issue',
|
|
536
536
|
label: 'Issue tracking adapter',
|
|
537
|
-
config: { primary: '
|
|
537
|
+
config: { primary: 'kernel', mirrors: ['github'] },
|
|
538
538
|
}),
|
|
539
539
|
Adapter({
|
|
540
540
|
id: 'adapter.harness',
|