@imdeadpool/guardex 7.0.21 → 7.0.22
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 +33 -29
- package/package.json +1 -1
- package/src/cli/main.js +594 -2796
- package/src/context.js +180 -30
- package/src/doctor/index.js +1071 -0
- package/src/finish/index.js +456 -358
- package/src/git/index.js +604 -1
- package/src/output/index.js +8 -1
- package/src/sandbox/index.js +301 -52
- package/src/scaffold/index.js +680 -0
- package/src/toolchain/index.js +622 -178
- package/templates/scripts/agent-branch-finish.sh +56 -5
- package/templates/scripts/agent-worktree-prune.sh +15 -1
- package/templates/vscode/guardex-active-agents/README.md +2 -0
- package/templates/vscode/guardex-active-agents/extension.js +283 -5
- package/templates/vscode/guardex-active-agents/icon.png +0 -0
- package/templates/vscode/guardex-active-agents/package.json +3 -1
package/src/cli/main.js
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
const hooksModule = require('../hooks');
|
|
4
4
|
const sandboxModule = require('../sandbox');
|
|
5
5
|
const toolchainModule = require('../toolchain');
|
|
6
|
-
const
|
|
6
|
+
const finishCommands = require('../finish');
|
|
7
|
+
const doctorModule = require('../doctor');
|
|
7
8
|
const {
|
|
8
9
|
fs,
|
|
9
10
|
path,
|
|
@@ -60,6 +61,9 @@ const {
|
|
|
60
61
|
DEPRECATED_COMMAND_ALIASES,
|
|
61
62
|
envFlagIsTruthy,
|
|
62
63
|
defaultAgentWorktreeRelativeDir,
|
|
64
|
+
listAiSetupPartNames,
|
|
65
|
+
parseAiSetupPartNames,
|
|
66
|
+
renderAiSetupPrompt,
|
|
63
67
|
AI_SETUP_PROMPT,
|
|
64
68
|
AI_SETUP_COMMANDS,
|
|
65
69
|
SCORECARD_RISK_BY_CHECK,
|
|
@@ -69,6 +73,37 @@ const {
|
|
|
69
73
|
resolveRepoRoot,
|
|
70
74
|
isGitRepo,
|
|
71
75
|
discoverNestedGitRepos,
|
|
76
|
+
uniquePreserveOrder,
|
|
77
|
+
listLocalUserBranches,
|
|
78
|
+
listLocalAgentBranches,
|
|
79
|
+
mapWorktreePathsByBranch,
|
|
80
|
+
gitRefExists,
|
|
81
|
+
hasSignificantWorkingTreeChanges,
|
|
82
|
+
readConfiguredProtectedBranches,
|
|
83
|
+
readProtectedBranches,
|
|
84
|
+
ensureSetupProtectedBranches,
|
|
85
|
+
writeProtectedBranches,
|
|
86
|
+
readGitConfig,
|
|
87
|
+
resolveBaseBranch,
|
|
88
|
+
resolveSyncStrategy,
|
|
89
|
+
currentBranchName,
|
|
90
|
+
repoHasHeadCommit,
|
|
91
|
+
readBranchDisplayName,
|
|
92
|
+
hasOriginRemote: repoHasOriginRemote,
|
|
93
|
+
detectComposeHintFiles,
|
|
94
|
+
printSetupRepoHints,
|
|
95
|
+
ensureRepoBranch,
|
|
96
|
+
ensureOriginBaseRef,
|
|
97
|
+
workingTreeIsDirty,
|
|
98
|
+
aheadBehind,
|
|
99
|
+
lockRegistryStatus,
|
|
100
|
+
listAgentWorktrees,
|
|
101
|
+
listLocalAgentBranchesForFinish,
|
|
102
|
+
worktreeHasLocalChanges,
|
|
103
|
+
branchExists,
|
|
104
|
+
resolveFinishBaseBranch,
|
|
105
|
+
branchMergedIntoBase,
|
|
106
|
+
syncOperation,
|
|
72
107
|
} = require('../git');
|
|
73
108
|
const {
|
|
74
109
|
run,
|
|
@@ -119,94 +154,30 @@ const {
|
|
|
119
154
|
renderShellDispatchShim,
|
|
120
155
|
renderPythonDispatchShim,
|
|
121
156
|
managedForceConflictMessage,
|
|
157
|
+
renderManagedFile,
|
|
158
|
+
ensureGeneratedScriptShim,
|
|
159
|
+
ensureHookShim,
|
|
160
|
+
copyTemplateFile,
|
|
161
|
+
ensureTemplateFilePresent,
|
|
162
|
+
ensureOmxScaffold,
|
|
163
|
+
ensureLockRegistry,
|
|
164
|
+
lockStateOrError,
|
|
165
|
+
writeLockState,
|
|
166
|
+
removeLegacyPackageScripts,
|
|
167
|
+
installUserLevelAsset,
|
|
168
|
+
removeLegacyManagedRepoFile,
|
|
169
|
+
ensureAgentsSnippet,
|
|
170
|
+
ensureManagedGitignore,
|
|
171
|
+
stripJsonComments,
|
|
172
|
+
stripJsonTrailingCommas,
|
|
173
|
+
parseJsonObjectLikeFile,
|
|
174
|
+
buildRepoVscodeSettings,
|
|
175
|
+
ensureRepoVscodeSettings,
|
|
176
|
+
configureHooks,
|
|
122
177
|
printOperations,
|
|
123
178
|
printStandaloneOperations,
|
|
124
179
|
} = require('../scaffold');
|
|
125
180
|
|
|
126
|
-
let sandboxApi;
|
|
127
|
-
let toolchainApi;
|
|
128
|
-
let finishApi;
|
|
129
|
-
|
|
130
|
-
function getSandboxApi() {
|
|
131
|
-
if (!sandboxApi) {
|
|
132
|
-
sandboxApi = sandboxModule.createSandboxApi({
|
|
133
|
-
protectedBaseWriteBlock,
|
|
134
|
-
runInstallInternal,
|
|
135
|
-
ensureSetupProtectedBranches,
|
|
136
|
-
ensureParentWorkspaceView,
|
|
137
|
-
buildParentWorkspaceView,
|
|
138
|
-
runFixInternal,
|
|
139
|
-
});
|
|
140
|
-
}
|
|
141
|
-
return sandboxApi;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
function getToolchainApi() {
|
|
145
|
-
if (!toolchainApi) {
|
|
146
|
-
toolchainApi = toolchainModule.createToolchainApi({
|
|
147
|
-
TOOL_NAME,
|
|
148
|
-
NPM_BIN,
|
|
149
|
-
NPX_BIN,
|
|
150
|
-
packageJson,
|
|
151
|
-
OPENSPEC_PACKAGE,
|
|
152
|
-
OPENSPEC_BIN,
|
|
153
|
-
GLOBAL_TOOLCHAIN_PACKAGES,
|
|
154
|
-
parseAutoApproval,
|
|
155
|
-
isInteractiveTerminal,
|
|
156
|
-
promptYesNoStrict,
|
|
157
|
-
run,
|
|
158
|
-
checkForGuardexUpdate,
|
|
159
|
-
printUpdateAvailableBanner,
|
|
160
|
-
readInstalledGuardexVersion,
|
|
161
|
-
restartIntoUpdatedGuardex,
|
|
162
|
-
checkForOpenSpecPackageUpdate,
|
|
163
|
-
printOpenSpecUpdateAvailableBanner,
|
|
164
|
-
resolveGlobalInstallApproval,
|
|
165
|
-
detectGlobalToolchainPackages,
|
|
166
|
-
detectOptionalLocalCompanionTools,
|
|
167
|
-
formatGlobalToolchainServiceName,
|
|
168
|
-
askGlobalInstallForMissing,
|
|
169
|
-
});
|
|
170
|
-
}
|
|
171
|
-
return toolchainApi;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
function getFinishApi() {
|
|
175
|
-
if (!finishApi) {
|
|
176
|
-
finishApi = finishModule.createFinishApi({
|
|
177
|
-
TOOL_NAME,
|
|
178
|
-
LOCK_FILE_RELATIVE,
|
|
179
|
-
path,
|
|
180
|
-
fs,
|
|
181
|
-
run,
|
|
182
|
-
runPackageAsset,
|
|
183
|
-
resolveRepoRoot,
|
|
184
|
-
parseCleanupArgs,
|
|
185
|
-
parseMergeArgs,
|
|
186
|
-
parseFinishArgs,
|
|
187
|
-
parseSyncArgs,
|
|
188
|
-
listAgentWorktrees,
|
|
189
|
-
listLocalAgentBranchesForFinish,
|
|
190
|
-
uniquePreserveOrder,
|
|
191
|
-
branchExists,
|
|
192
|
-
resolveFinishBaseBranch,
|
|
193
|
-
worktreeHasLocalChanges,
|
|
194
|
-
branchMergedIntoBase,
|
|
195
|
-
autoCommitWorktreeForFinish,
|
|
196
|
-
resolveBaseBranch,
|
|
197
|
-
resolveSyncStrategy,
|
|
198
|
-
ensureOriginBaseRef,
|
|
199
|
-
gitRun,
|
|
200
|
-
currentBranchName,
|
|
201
|
-
workingTreeIsDirty,
|
|
202
|
-
aheadBehind,
|
|
203
|
-
lockRegistryStatus,
|
|
204
|
-
syncOperation,
|
|
205
|
-
});
|
|
206
|
-
}
|
|
207
|
-
return finishApi;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
181
|
/**
|
|
211
182
|
* @typedef {Object} AutoFinishSummary
|
|
212
183
|
* @property {boolean} [enabled]
|
|
@@ -261,116 +232,27 @@ function getFinishApi() {
|
|
|
261
232
|
* @property {AutoFinishSummary} autoFinish
|
|
262
233
|
* @property {string | null} sandboxLockContent
|
|
263
234
|
*/
|
|
264
|
-
function renderManagedFile(repoRoot, relativePath, content, options = {}) {
|
|
265
|
-
const destinationPath = path.join(repoRoot, relativePath);
|
|
266
|
-
const destinationExists = fs.existsSync(destinationPath);
|
|
267
|
-
const force = Boolean(options.force);
|
|
268
|
-
const dryRun = Boolean(options.dryRun);
|
|
269
|
-
|
|
270
|
-
if (destinationExists) {
|
|
271
|
-
const existingContent = fs.readFileSync(destinationPath, 'utf8');
|
|
272
|
-
if (existingContent === content) {
|
|
273
|
-
ensureExecutable(destinationPath, relativePath, dryRun);
|
|
274
|
-
return { status: 'unchanged', file: relativePath };
|
|
275
|
-
}
|
|
276
|
-
if (!force && !isCriticalGuardrailPath(relativePath)) {
|
|
277
|
-
throw new Error(managedForceConflictMessage(relativePath));
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
ensureParentDir(repoRoot, destinationPath, dryRun);
|
|
282
|
-
if (!dryRun) {
|
|
283
|
-
fs.writeFileSync(destinationPath, content, 'utf8');
|
|
284
|
-
ensureExecutable(destinationPath, relativePath, dryRun);
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
if (destinationExists && !force && isCriticalGuardrailPath(relativePath)) {
|
|
288
|
-
return { status: dryRun ? 'would-repair-critical' : 'repaired-critical', file: relativePath };
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
return { status: destinationExists ? 'overwritten' : 'created', file: relativePath };
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
function ensureGeneratedScriptShim(repoRoot, spec, options = {}) {
|
|
295
|
-
const content = spec.kind === 'python'
|
|
296
|
-
? renderPythonDispatchShim(spec.command)
|
|
297
|
-
: renderShellDispatchShim(spec.command);
|
|
298
|
-
return renderManagedFile(repoRoot, spec.relativePath, content, options);
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
function ensureHookShim(repoRoot, hookName, options = {}) {
|
|
302
|
-
return renderManagedFile(
|
|
303
|
-
repoRoot,
|
|
304
|
-
path.posix.join('.githooks', hookName),
|
|
305
|
-
renderShellDispatchShim(['hook', 'run', hookName]),
|
|
306
|
-
options,
|
|
307
|
-
);
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
function copyTemplateFile(repoRoot, relativeTemplatePath, force, dryRun) {
|
|
311
|
-
const sourcePath = path.join(TEMPLATE_ROOT, relativeTemplatePath);
|
|
312
|
-
const destinationRelativePath = toDestinationPath(relativeTemplatePath);
|
|
313
|
-
const destinationPath = path.join(repoRoot, destinationRelativePath);
|
|
314
|
-
|
|
315
|
-
const sourceContent = fs.readFileSync(sourcePath, 'utf8');
|
|
316
|
-
const destinationExists = fs.existsSync(destinationPath);
|
|
317
|
-
|
|
318
|
-
if (destinationExists) {
|
|
319
|
-
const existingContent = fs.readFileSync(destinationPath, 'utf8');
|
|
320
|
-
if (existingContent === sourceContent) {
|
|
321
|
-
ensureExecutable(destinationPath, destinationRelativePath, dryRun);
|
|
322
|
-
return { status: 'unchanged', file: destinationRelativePath };
|
|
323
|
-
}
|
|
324
|
-
if (!force && !isCriticalGuardrailPath(destinationRelativePath)) {
|
|
325
|
-
throw new Error(managedForceConflictMessage(destinationRelativePath));
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
235
|
|
|
329
|
-
|
|
330
|
-
if (!
|
|
331
|
-
|
|
332
|
-
ensureExecutable(destinationPath, destinationRelativePath, dryRun);
|
|
236
|
+
function appendForceArgs(args, options) {
|
|
237
|
+
if (!options.force) {
|
|
238
|
+
return;
|
|
333
239
|
}
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
240
|
+
args.push('--force');
|
|
241
|
+
for (const managedPath of options.forceManagedPaths || []) {
|
|
242
|
+
args.push(managedPath);
|
|
337
243
|
}
|
|
338
|
-
|
|
339
|
-
return { status: destinationExists ? 'overwritten' : 'created', file: destinationRelativePath };
|
|
340
244
|
}
|
|
341
245
|
|
|
342
|
-
function
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
const destinationPath = path.join(repoRoot, destinationRelativePath);
|
|
346
|
-
const sourceContent = fs.readFileSync(sourcePath, 'utf8');
|
|
347
|
-
|
|
348
|
-
if (fs.existsSync(destinationPath)) {
|
|
349
|
-
const existingContent = fs.readFileSync(destinationPath, 'utf8');
|
|
350
|
-
if (existingContent === sourceContent) {
|
|
351
|
-
ensureExecutable(destinationPath, destinationRelativePath, dryRun);
|
|
352
|
-
return { status: 'unchanged', file: destinationRelativePath };
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
if (isCriticalGuardrailPath(destinationRelativePath)) {
|
|
356
|
-
if (!dryRun) {
|
|
357
|
-
fs.writeFileSync(destinationPath, sourceContent, 'utf8');
|
|
358
|
-
ensureExecutable(destinationPath, destinationRelativePath, dryRun);
|
|
359
|
-
}
|
|
360
|
-
return { status: dryRun ? 'would-repair-critical' : 'repaired-critical', file: destinationRelativePath };
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
// In fix mode, avoid silently replacing local customizations.
|
|
364
|
-
return { status: 'skipped-conflict', file: destinationRelativePath };
|
|
246
|
+
function shouldForceManagedPath(options, relativePath) {
|
|
247
|
+
if (!options.force) {
|
|
248
|
+
return false;
|
|
365
249
|
}
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
fs.writeFileSync(destinationPath, sourceContent, 'utf8');
|
|
370
|
-
ensureExecutable(destinationPath, destinationRelativePath, dryRun);
|
|
250
|
+
const targetedPaths = Array.isArray(options.forceManagedPaths) ? options.forceManagedPaths : [];
|
|
251
|
+
if (targetedPaths.length === 0) {
|
|
252
|
+
return true;
|
|
371
253
|
}
|
|
372
|
-
|
|
373
|
-
return
|
|
254
|
+
const normalized = normalizeManagedForcePath(relativePath);
|
|
255
|
+
return normalized !== null && targetedPaths.includes(normalized);
|
|
374
256
|
}
|
|
375
257
|
|
|
376
258
|
function ensureTargetedLegacyWorkflowShims(repoRoot, options) {
|
|
@@ -389,2718 +271,595 @@ function ensureTargetedLegacyWorkflowShims(repoRoot, options) {
|
|
|
389
271
|
return operations;
|
|
390
272
|
}
|
|
391
273
|
|
|
392
|
-
function
|
|
393
|
-
return
|
|
274
|
+
function normalizeWorkspacePath(relativePath) {
|
|
275
|
+
return String(relativePath || '.').replace(/\\/g, '/');
|
|
394
276
|
}
|
|
395
277
|
|
|
396
|
-
function
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
for (const relativeDir of REPO_SCAFFOLD_DIRECTORIES) {
|
|
400
|
-
const absoluteDir = path.join(repoRoot, relativeDir);
|
|
401
|
-
if (fs.existsSync(absoluteDir)) {
|
|
402
|
-
if (!fs.statSync(absoluteDir).isDirectory()) {
|
|
403
|
-
throw new Error(`Expected directory at ${relativeDir} but found a file.`);
|
|
404
|
-
}
|
|
405
|
-
operations.push({ status: 'unchanged', file: relativeDir });
|
|
406
|
-
continue;
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
if (!dryRun) {
|
|
410
|
-
fs.mkdirSync(absoluteDir, { recursive: true });
|
|
411
|
-
}
|
|
412
|
-
operations.push({ status: 'created', file: relativeDir });
|
|
413
|
-
}
|
|
278
|
+
function isCommandAvailable(commandName) {
|
|
279
|
+
return run('which', [commandName]).status === 0;
|
|
280
|
+
}
|
|
414
281
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
}
|
|
421
|
-
operations.push({ status: 'unchanged', file: relativeDir });
|
|
422
|
-
continue;
|
|
423
|
-
}
|
|
282
|
+
function buildParentWorkspaceView(repoRoot) {
|
|
283
|
+
const parentDir = path.dirname(repoRoot);
|
|
284
|
+
const workspaceFileName = `${path.basename(repoRoot)}-branches.code-workspace`;
|
|
285
|
+
const workspacePath = path.join(parentDir, workspaceFileName);
|
|
286
|
+
const repoRelativePath = normalizeWorkspacePath(path.relative(parentDir, repoRoot) || '.');
|
|
424
287
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
288
|
+
return {
|
|
289
|
+
workspacePath,
|
|
290
|
+
payload: {
|
|
291
|
+
folders: [
|
|
292
|
+
{ path: repoRelativePath },
|
|
293
|
+
...AGENT_WORKTREE_RELATIVE_DIRS.map((relativeDir) => ({
|
|
294
|
+
path: normalizeWorkspacePath(path.join(repoRelativePath === '.' ? '' : repoRelativePath, relativeDir)),
|
|
295
|
+
})),
|
|
296
|
+
],
|
|
297
|
+
settings: {
|
|
298
|
+
'scm.alwaysShowRepositories': true,
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
}
|
|
430
303
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
}
|
|
437
|
-
operations.push({ status: 'unchanged', file: relativeFile });
|
|
438
|
-
continue;
|
|
439
|
-
}
|
|
304
|
+
function ensureParentWorkspaceView(repoRoot, dryRun) {
|
|
305
|
+
const { workspacePath, payload } = buildParentWorkspaceView(repoRoot);
|
|
306
|
+
const operationFile = path.relative(repoRoot, workspacePath) || path.basename(workspacePath);
|
|
307
|
+
const nextContent = `${JSON.stringify(payload, null, 2)}\n`;
|
|
308
|
+
const note = 'parent VS Code workspace view';
|
|
440
309
|
|
|
310
|
+
if (!fs.existsSync(workspacePath)) {
|
|
441
311
|
if (!dryRun) {
|
|
442
|
-
fs.
|
|
443
|
-
fs.writeFileSync(absoluteFile, defaultContent, 'utf8');
|
|
312
|
+
fs.writeFileSync(workspacePath, nextContent, 'utf8');
|
|
444
313
|
}
|
|
445
|
-
|
|
314
|
+
return { status: dryRun ? 'would-create' : 'created', file: operationFile, note };
|
|
446
315
|
}
|
|
447
316
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
function ensureLockRegistry(repoRoot, dryRun) {
|
|
452
|
-
const absolutePath = lockFilePath(repoRoot);
|
|
453
|
-
if (fs.existsSync(absolutePath)) {
|
|
454
|
-
return { status: 'unchanged', file: LOCK_FILE_RELATIVE };
|
|
317
|
+
const currentContent = fs.readFileSync(workspacePath, 'utf8');
|
|
318
|
+
if (currentContent === nextContent) {
|
|
319
|
+
return { status: 'unchanged', file: operationFile, note };
|
|
455
320
|
}
|
|
456
321
|
|
|
457
322
|
if (!dryRun) {
|
|
458
|
-
fs.
|
|
459
|
-
fs.writeFileSync(absolutePath, JSON.stringify({ locks: {} }, null, 2) + '\n', 'utf8');
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
return { status: 'created', file: LOCK_FILE_RELATIVE };
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
function lockStateOrError(repoRoot) {
|
|
466
|
-
const lockPath = lockFilePath(repoRoot);
|
|
467
|
-
if (!fs.existsSync(lockPath)) {
|
|
468
|
-
return { ok: false, error: `${LOCK_FILE_RELATIVE} is missing` };
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
try {
|
|
472
|
-
const parsed = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
|
|
473
|
-
if (!parsed || typeof parsed !== 'object' || typeof parsed.locks !== 'object' || parsed.locks === null) {
|
|
474
|
-
return { ok: false, error: `${LOCK_FILE_RELATIVE} has invalid schema (expected { locks: {} })` };
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
// Normalize older schema entries.
|
|
478
|
-
for (const [filePath, entry] of Object.entries(parsed.locks)) {
|
|
479
|
-
if (!entry || typeof entry !== 'object') {
|
|
480
|
-
parsed.locks[filePath] = { branch: '', claimed_at: '', allow_delete: false };
|
|
481
|
-
continue;
|
|
482
|
-
}
|
|
483
|
-
if (!Object.prototype.hasOwnProperty.call(entry, 'allow_delete')) {
|
|
484
|
-
entry.allow_delete = false;
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
return { ok: true, raw: parsed, locks: parsed.locks };
|
|
489
|
-
} catch (error) {
|
|
490
|
-
return { ok: false, error: `${LOCK_FILE_RELATIVE} is invalid JSON: ${error.message}` };
|
|
323
|
+
fs.writeFileSync(workspacePath, nextContent, 'utf8');
|
|
491
324
|
}
|
|
325
|
+
return { status: dryRun ? 'would-update' : 'updated', file: operationFile, note };
|
|
492
326
|
}
|
|
493
327
|
|
|
494
|
-
function
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
328
|
+
function hasGuardexBootstrapFiles(repoRoot) {
|
|
329
|
+
const required = [
|
|
330
|
+
'AGENTS.md',
|
|
331
|
+
'.githooks/pre-commit',
|
|
332
|
+
'.githooks/pre-push',
|
|
333
|
+
LOCK_FILE_RELATIVE,
|
|
334
|
+
];
|
|
335
|
+
return required.every((relativePath) => fs.existsSync(path.join(repoRoot, relativePath)));
|
|
499
336
|
}
|
|
500
337
|
|
|
501
|
-
function
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
return { status: 'skipped', file: 'package.json', note: 'package.json not found' };
|
|
338
|
+
function protectedBaseWriteBlock(options, { requireBootstrap = true } = {}) {
|
|
339
|
+
if (options.dryRun || options.allowProtectedBaseWrite) {
|
|
340
|
+
return null;
|
|
505
341
|
}
|
|
506
342
|
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
} catch (error) {
|
|
511
|
-
throw new Error(`Unable to parse package.json in target repo: ${error.message}`);
|
|
343
|
+
const repoRoot = resolveRepoRoot(options.target);
|
|
344
|
+
if (requireBootstrap && !hasGuardexBootstrapFiles(repoRoot)) {
|
|
345
|
+
return null;
|
|
512
346
|
}
|
|
513
347
|
|
|
514
|
-
const
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
pkg.scripts = existingScripts;
|
|
518
|
-
let changed = false;
|
|
519
|
-
for (const [key, value] of Object.entries(LEGACY_MANAGED_PACKAGE_SCRIPTS)) {
|
|
520
|
-
if (existingScripts[key] === value) {
|
|
521
|
-
delete existingScripts[key];
|
|
522
|
-
changed = true;
|
|
523
|
-
}
|
|
348
|
+
const branch = currentBranchName(repoRoot);
|
|
349
|
+
if (branch !== 'main') {
|
|
350
|
+
return null;
|
|
524
351
|
}
|
|
525
352
|
|
|
526
|
-
|
|
527
|
-
|
|
353
|
+
const protectedBranches = readProtectedBranches(repoRoot);
|
|
354
|
+
if (!protectedBranches.includes(branch)) {
|
|
355
|
+
return null;
|
|
528
356
|
}
|
|
529
357
|
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
358
|
+
return {
|
|
359
|
+
repoRoot,
|
|
360
|
+
branch,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
533
363
|
|
|
534
|
-
|
|
364
|
+
function assertProtectedMainWriteAllowed(options, commandName) {
|
|
365
|
+
return sandboxModule.assertProtectedMainWriteAllowed(options, commandName);
|
|
535
366
|
}
|
|
536
367
|
|
|
537
|
-
function
|
|
538
|
-
const
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
if (
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
return { status: 'skipped-conflict', file: asset.destination };
|
|
368
|
+
function runSetupBootstrapInternal(options) {
|
|
369
|
+
const installPayload = runInstallInternal(options);
|
|
370
|
+
installPayload.operations.push(
|
|
371
|
+
ensureSetupProtectedBranches(installPayload.repoRoot, Boolean(options.dryRun)),
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
let parentWorkspace = null;
|
|
375
|
+
if (options.parentWorkspaceView) {
|
|
376
|
+
installPayload.operations.push(
|
|
377
|
+
ensureParentWorkspaceView(installPayload.repoRoot, Boolean(options.dryRun)),
|
|
378
|
+
);
|
|
379
|
+
if (!options.dryRun) {
|
|
380
|
+
parentWorkspace = buildParentWorkspaceView(installPayload.repoRoot);
|
|
551
381
|
}
|
|
552
382
|
}
|
|
553
383
|
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
384
|
+
const fixPayload = runFixInternal({
|
|
385
|
+
target: installPayload.repoRoot,
|
|
386
|
+
dryRun: options.dryRun,
|
|
387
|
+
force: options.force,
|
|
388
|
+
forceManagedPaths: options.forceManagedPaths,
|
|
389
|
+
dropStaleLocks: true,
|
|
390
|
+
skipAgents: options.skipAgents,
|
|
391
|
+
skipPackageJson: options.skipPackageJson,
|
|
392
|
+
skipGitignore: options.skipGitignore,
|
|
393
|
+
allowProtectedBaseWrite: options.allowProtectedBaseWrite,
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
return {
|
|
397
|
+
installPayload,
|
|
398
|
+
fixPayload,
|
|
399
|
+
parentWorkspace,
|
|
400
|
+
};
|
|
559
401
|
}
|
|
560
402
|
|
|
561
|
-
function
|
|
562
|
-
const
|
|
563
|
-
const
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
return { status: 'skipped-conflict', file: relativePath, note: 'not a regular file' };
|
|
570
|
-
}
|
|
403
|
+
function extractAgentBranchStartMetadata(output) {
|
|
404
|
+
const branchMatch = String(output || '').match(/^\[agent-branch-start\] Created branch: (.+)$/m);
|
|
405
|
+
const worktreeMatch = String(output || '').match(/^\[agent-branch-start\] Worktree: (.+)$/m);
|
|
406
|
+
return {
|
|
407
|
+
branch: branchMatch ? branchMatch[1].trim() : '',
|
|
408
|
+
worktreePath: worktreeMatch ? worktreeMatch[1].trim() : '',
|
|
409
|
+
};
|
|
410
|
+
}
|
|
571
411
|
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
}
|
|
412
|
+
function resolveSandboxTarget(repoRoot, worktreePath, targetPath) {
|
|
413
|
+
const resolvedTarget = path.resolve(targetPath);
|
|
414
|
+
const relativeTarget = path.relative(repoRoot, resolvedTarget);
|
|
415
|
+
if (relativeTarget.startsWith('..') || path.isAbsolute(relativeTarget)) {
|
|
416
|
+
throw new Error(`sandbox target must stay inside repo root: ${resolvedTarget}`);
|
|
578
417
|
}
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
? skillAsset.source.slice(TEMPLATE_ROOT.length + 1)
|
|
582
|
-
: relativePath.replace(/^\./, '');
|
|
583
|
-
const sourcePath = path.join(TEMPLATE_ROOT, templateRelative);
|
|
584
|
-
if (!fs.existsSync(sourcePath)) {
|
|
585
|
-
return { status: 'skipped', file: relativePath, note: 'template source missing' };
|
|
418
|
+
if (!relativeTarget || relativeTarget === '.') {
|
|
419
|
+
return worktreePath;
|
|
586
420
|
}
|
|
421
|
+
return path.join(worktreePath, relativeTarget);
|
|
422
|
+
}
|
|
587
423
|
|
|
588
|
-
|
|
589
|
-
const
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
424
|
+
function buildSandboxSetupArgs(options, sandboxTarget) {
|
|
425
|
+
const args = ['setup', '--target', sandboxTarget, '--no-global-install', '--no-recursive'];
|
|
426
|
+
appendForceArgs(args, options);
|
|
427
|
+
if (options.skipAgents) args.push('--skip-agents');
|
|
428
|
+
if (options.skipPackageJson) args.push('--skip-package-json');
|
|
429
|
+
if (options.skipGitignore) args.push('--no-gitignore');
|
|
430
|
+
if (options.dryRun) args.push('--dry-run');
|
|
431
|
+
return args;
|
|
432
|
+
}
|
|
593
433
|
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
}
|
|
597
|
-
return { status: dryRun ? 'would-remove' : 'removed', file: relativePath };
|
|
434
|
+
function isSpawnFailure(result) {
|
|
435
|
+
return Boolean(result?.error) && typeof result?.status !== 'number';
|
|
598
436
|
}
|
|
599
437
|
|
|
600
|
-
function
|
|
601
|
-
const
|
|
602
|
-
const
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
'
|
|
606
|
-
)
|
|
438
|
+
function protectedBaseSandboxBranchPrefix() {
|
|
439
|
+
const now = new Date();
|
|
440
|
+
const stamp = [
|
|
441
|
+
now.getUTCFullYear(),
|
|
442
|
+
String(now.getUTCMonth() + 1).padStart(2, '0'),
|
|
443
|
+
String(now.getUTCDate()).padStart(2, '0'),
|
|
444
|
+
].join('') + '-' + [
|
|
445
|
+
String(now.getUTCHours()).padStart(2, '0'),
|
|
446
|
+
String(now.getUTCMinutes()).padStart(2, '0'),
|
|
447
|
+
String(now.getUTCSeconds()).padStart(2, '0'),
|
|
448
|
+
].join('');
|
|
449
|
+
return `agent/gx/${stamp}`;
|
|
450
|
+
}
|
|
607
451
|
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
}
|
|
612
|
-
return { status: 'created', file: 'AGENTS.md' };
|
|
613
|
-
}
|
|
452
|
+
function protectedBaseSandboxWorktreePath(repoRoot, branchName) {
|
|
453
|
+
return path.join(repoRoot, defaultAgentWorktreeRelativeDir(), branchName.replace(/\//g, '__'));
|
|
454
|
+
}
|
|
614
455
|
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
return { status: 'unchanged', file: 'AGENTS.md' };
|
|
620
|
-
}
|
|
621
|
-
if (!dryRun) {
|
|
622
|
-
fs.writeFileSync(agentsPath, next, 'utf8');
|
|
623
|
-
}
|
|
624
|
-
return { status: 'updated', file: 'AGENTS.md', note: 'refreshed gitguardex-managed block' };
|
|
456
|
+
function resolveProtectedBaseSandboxStartRef(repoRoot, baseBranch) {
|
|
457
|
+
run('git', ['-C', repoRoot, 'fetch', 'origin', baseBranch, '--quiet'], { timeout: 20_000 });
|
|
458
|
+
if (gitRefExists(repoRoot, `refs/remotes/origin/${baseBranch}`)) {
|
|
459
|
+
return `origin/${baseBranch}`;
|
|
625
460
|
}
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
return { status: 'unchanged', file: 'AGENTS.md', note: 'existing marker found without managed end marker' };
|
|
461
|
+
if (gitRefExists(repoRoot, `refs/heads/${baseBranch}`)) {
|
|
462
|
+
return baseBranch;
|
|
629
463
|
}
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
if (!dryRun) {
|
|
633
|
-
fs.writeFileSync(agentsPath, `${existing}${separator}${snippet}\n`, 'utf8');
|
|
464
|
+
if (currentBranchName(repoRoot) === baseBranch) {
|
|
465
|
+
return null;
|
|
634
466
|
}
|
|
635
|
-
|
|
636
|
-
return { status: 'updated', file: 'AGENTS.md' };
|
|
467
|
+
throw new Error(`Unable to find base ref for sandbox bootstrap: ${baseBranch}`);
|
|
637
468
|
}
|
|
638
469
|
|
|
639
|
-
function
|
|
640
|
-
const
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
...MANAGED_GITIGNORE_PATHS,
|
|
644
|
-
GITIGNORE_MARKER_END,
|
|
645
|
-
].join('\n');
|
|
646
|
-
const managedRegex = new RegExp(
|
|
647
|
-
`${GITIGNORE_MARKER_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s\\S]*?${GITIGNORE_MARKER_END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`,
|
|
648
|
-
'm',
|
|
649
|
-
);
|
|
650
|
-
|
|
651
|
-
if (!fs.existsSync(gitignorePath)) {
|
|
652
|
-
if (!dryRun) {
|
|
653
|
-
fs.writeFileSync(gitignorePath, `${managedBlock}\n`, 'utf8');
|
|
654
|
-
}
|
|
655
|
-
return { status: 'created', file: '.gitignore', note: 'added gitguardex-managed entries' };
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
const existing = fs.readFileSync(gitignorePath, 'utf8');
|
|
659
|
-
if (managedRegex.test(existing)) {
|
|
660
|
-
const next = existing.replace(managedRegex, managedBlock);
|
|
661
|
-
if (next === existing) {
|
|
662
|
-
return { status: 'unchanged', file: '.gitignore' };
|
|
663
|
-
}
|
|
664
|
-
if (!dryRun) {
|
|
665
|
-
fs.writeFileSync(gitignorePath, next, 'utf8');
|
|
666
|
-
}
|
|
667
|
-
return { status: 'updated', file: '.gitignore', note: 'refreshed gitguardex-managed entries' };
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
const separator = existing.endsWith('\n') ? '\n' : '\n\n';
|
|
671
|
-
if (!dryRun) {
|
|
672
|
-
fs.writeFileSync(gitignorePath, `${existing}${separator}${managedBlock}\n`, 'utf8');
|
|
673
|
-
}
|
|
674
|
-
return { status: 'updated', file: '.gitignore', note: 'appended gitguardex-managed entries' };
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
function stripJsonComments(source) {
|
|
678
|
-
let result = '';
|
|
679
|
-
let inString = false;
|
|
680
|
-
let escapeNext = false;
|
|
681
|
-
let inLineComment = false;
|
|
682
|
-
let inBlockComment = false;
|
|
683
|
-
|
|
684
|
-
for (let index = 0; index < source.length; index += 1) {
|
|
685
|
-
const current = source[index];
|
|
686
|
-
const next = source[index + 1];
|
|
687
|
-
|
|
688
|
-
if (inLineComment) {
|
|
689
|
-
if (current === '\n' || current === '\r') {
|
|
690
|
-
inLineComment = false;
|
|
691
|
-
result += current;
|
|
692
|
-
}
|
|
693
|
-
continue;
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
if (inBlockComment) {
|
|
697
|
-
if (current === '*' && next === '/') {
|
|
698
|
-
inBlockComment = false;
|
|
699
|
-
index += 1;
|
|
700
|
-
continue;
|
|
701
|
-
}
|
|
702
|
-
if (current === '\n' || current === '\r') {
|
|
703
|
-
result += current;
|
|
704
|
-
}
|
|
705
|
-
continue;
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
if (inString) {
|
|
709
|
-
result += current;
|
|
710
|
-
if (escapeNext) {
|
|
711
|
-
escapeNext = false;
|
|
712
|
-
} else if (current === '\\') {
|
|
713
|
-
escapeNext = true;
|
|
714
|
-
} else if (current === '"') {
|
|
715
|
-
inString = false;
|
|
716
|
-
}
|
|
717
|
-
continue;
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
if (current === '"') {
|
|
721
|
-
inString = true;
|
|
722
|
-
result += current;
|
|
723
|
-
continue;
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
if (current === '/' && next === '/') {
|
|
727
|
-
inLineComment = true;
|
|
728
|
-
index += 1;
|
|
729
|
-
continue;
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
if (current === '/' && next === '*') {
|
|
733
|
-
inBlockComment = true;
|
|
734
|
-
index += 1;
|
|
735
|
-
continue;
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
result += current;
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
return result;
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
function stripJsonTrailingCommas(source) {
|
|
745
|
-
let result = '';
|
|
746
|
-
let inString = false;
|
|
747
|
-
let escapeNext = false;
|
|
748
|
-
|
|
749
|
-
for (let index = 0; index < source.length; index += 1) {
|
|
750
|
-
const current = source[index];
|
|
751
|
-
|
|
752
|
-
if (inString) {
|
|
753
|
-
result += current;
|
|
754
|
-
if (escapeNext) {
|
|
755
|
-
escapeNext = false;
|
|
756
|
-
} else if (current === '\\') {
|
|
757
|
-
escapeNext = true;
|
|
758
|
-
} else if (current === '"') {
|
|
759
|
-
inString = false;
|
|
760
|
-
}
|
|
761
|
-
continue;
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
if (current === '"') {
|
|
765
|
-
inString = true;
|
|
766
|
-
result += current;
|
|
767
|
-
continue;
|
|
768
|
-
}
|
|
769
|
-
|
|
770
|
-
if (current === ',') {
|
|
771
|
-
let lookahead = index + 1;
|
|
772
|
-
while (lookahead < source.length && /\s/.test(source[lookahead])) {
|
|
773
|
-
lookahead += 1;
|
|
774
|
-
}
|
|
775
|
-
if (source[lookahead] === '}' || source[lookahead] === ']') {
|
|
776
|
-
continue;
|
|
777
|
-
}
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
result += current;
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
return result;
|
|
784
|
-
}
|
|
785
|
-
|
|
786
|
-
function parseJsonObjectLikeFile(source, relativePath) {
|
|
787
|
-
let parsed;
|
|
788
|
-
try {
|
|
789
|
-
parsed = JSON.parse(stripJsonTrailingCommas(stripJsonComments(source)));
|
|
790
|
-
} catch (error) {
|
|
791
|
-
throw new Error(`Unable to parse ${relativePath} as JSON or JSONC: ${error.message}`);
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
795
|
-
throw new Error(`${relativePath} must contain a top-level object.`);
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
return parsed;
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
function uniqueStringList(values) {
|
|
802
|
-
const seen = new Set();
|
|
803
|
-
const result = [];
|
|
804
|
-
|
|
805
|
-
for (const value of values) {
|
|
806
|
-
if (typeof value !== 'string' || seen.has(value)) {
|
|
807
|
-
continue;
|
|
808
|
-
}
|
|
809
|
-
seen.add(value);
|
|
810
|
-
result.push(value);
|
|
811
|
-
}
|
|
812
|
-
|
|
813
|
-
return result;
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
function buildRepoVscodeSettings(existingSettings = {}) {
|
|
817
|
-
const nextSettings = { ...existingSettings };
|
|
818
|
-
const existingIgnoredFolders = Array.isArray(existingSettings[REPO_SCAN_IGNORED_FOLDERS_SETTING])
|
|
819
|
-
? existingSettings[REPO_SCAN_IGNORED_FOLDERS_SETTING]
|
|
820
|
-
: [];
|
|
821
|
-
|
|
822
|
-
nextSettings[REPO_SCAN_IGNORED_FOLDERS_SETTING] = uniqueStringList([
|
|
823
|
-
...existingIgnoredFolders,
|
|
824
|
-
...MANAGED_REPO_SCAN_IGNORED_FOLDERS,
|
|
825
|
-
]);
|
|
826
|
-
|
|
827
|
-
return nextSettings;
|
|
828
|
-
}
|
|
829
|
-
|
|
830
|
-
function ensureRepoVscodeSettings(repoRoot, dryRun) {
|
|
831
|
-
const settingsPath = path.join(repoRoot, SHARED_VSCODE_SETTINGS_RELATIVE);
|
|
832
|
-
const destinationExists = fs.existsSync(settingsPath);
|
|
833
|
-
const existingContent = destinationExists ? fs.readFileSync(settingsPath, 'utf8') : '';
|
|
834
|
-
const existingSettings = destinationExists
|
|
835
|
-
? parseJsonObjectLikeFile(existingContent, SHARED_VSCODE_SETTINGS_RELATIVE)
|
|
836
|
-
: {};
|
|
837
|
-
const nextContent = `${JSON.stringify(buildRepoVscodeSettings(existingSettings), null, 2)}\n`;
|
|
838
|
-
|
|
839
|
-
if (destinationExists && existingContent === nextContent) {
|
|
840
|
-
return { status: 'unchanged', file: SHARED_VSCODE_SETTINGS_RELATIVE };
|
|
841
|
-
}
|
|
842
|
-
|
|
843
|
-
ensureParentDir(repoRoot, settingsPath, dryRun);
|
|
844
|
-
if (!dryRun) {
|
|
845
|
-
fs.writeFileSync(settingsPath, nextContent, 'utf8');
|
|
846
|
-
}
|
|
847
|
-
|
|
848
|
-
return {
|
|
849
|
-
status: destinationExists ? 'updated' : 'created',
|
|
850
|
-
file: SHARED_VSCODE_SETTINGS_RELATIVE,
|
|
851
|
-
note: 'shared VS Code repo scan ignores for Guardex worktrees',
|
|
852
|
-
};
|
|
853
|
-
}
|
|
854
|
-
|
|
855
|
-
function configureHooks(repoRoot, dryRun) {
|
|
856
|
-
if (dryRun) {
|
|
857
|
-
return { status: 'would-set', key: 'core.hooksPath', value: '.githooks' };
|
|
858
|
-
}
|
|
859
|
-
|
|
860
|
-
const result = run('git', ['-C', repoRoot, 'config', 'core.hooksPath', '.githooks']);
|
|
861
|
-
if (result.status !== 0) {
|
|
862
|
-
throw new Error(`Failed to set git hooksPath: ${(result.stderr || '').trim()}`);
|
|
863
|
-
}
|
|
864
|
-
|
|
865
|
-
return { status: 'set', key: 'core.hooksPath', value: '.githooks' };
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
function appendForceArgs(args, options) {
|
|
869
|
-
if (!options.force) {
|
|
870
|
-
return;
|
|
871
|
-
}
|
|
872
|
-
args.push('--force');
|
|
873
|
-
for (const managedPath of options.forceManagedPaths || []) {
|
|
874
|
-
args.push(managedPath);
|
|
875
|
-
}
|
|
876
|
-
}
|
|
877
|
-
|
|
878
|
-
function shouldForceManagedPath(options, relativePath) {
|
|
879
|
-
if (!options.force) {
|
|
880
|
-
return false;
|
|
881
|
-
}
|
|
882
|
-
const targetedPaths = Array.isArray(options.forceManagedPaths) ? options.forceManagedPaths : [];
|
|
883
|
-
if (targetedPaths.length === 0) {
|
|
884
|
-
return true;
|
|
885
|
-
}
|
|
886
|
-
const normalized = normalizeManagedForcePath(relativePath);
|
|
887
|
-
return normalized !== null && targetedPaths.includes(normalized);
|
|
888
|
-
}
|
|
889
|
-
|
|
890
|
-
function normalizeWorkspacePath(relativePath) {
|
|
891
|
-
return String(relativePath || '.').replace(/\\/g, '/');
|
|
892
|
-
}
|
|
893
|
-
|
|
894
|
-
function buildParentWorkspaceView(repoRoot) {
|
|
895
|
-
const parentDir = path.dirname(repoRoot);
|
|
896
|
-
const workspaceFileName = `${path.basename(repoRoot)}-branches.code-workspace`;
|
|
897
|
-
const workspacePath = path.join(parentDir, workspaceFileName);
|
|
898
|
-
const repoRelativePath = normalizeWorkspacePath(path.relative(parentDir, repoRoot) || '.');
|
|
899
|
-
|
|
900
|
-
return {
|
|
901
|
-
workspacePath,
|
|
902
|
-
payload: {
|
|
903
|
-
folders: [
|
|
904
|
-
{ path: repoRelativePath },
|
|
905
|
-
...AGENT_WORKTREE_RELATIVE_DIRS.map((relativeDir) => ({
|
|
906
|
-
path: normalizeWorkspacePath(path.join(repoRelativePath === '.' ? '' : repoRelativePath, relativeDir)),
|
|
907
|
-
})),
|
|
908
|
-
],
|
|
909
|
-
settings: {
|
|
910
|
-
'scm.alwaysShowRepositories': true,
|
|
911
|
-
},
|
|
912
|
-
},
|
|
913
|
-
};
|
|
914
|
-
}
|
|
915
|
-
|
|
916
|
-
function ensureParentWorkspaceView(repoRoot, dryRun) {
|
|
917
|
-
const { workspacePath, payload } = buildParentWorkspaceView(repoRoot);
|
|
918
|
-
const operationFile = path.relative(repoRoot, workspacePath) || path.basename(workspacePath);
|
|
919
|
-
const nextContent = `${JSON.stringify(payload, null, 2)}\n`;
|
|
920
|
-
const note = 'parent VS Code workspace view';
|
|
921
|
-
|
|
922
|
-
if (!fs.existsSync(workspacePath)) {
|
|
923
|
-
if (!dryRun) {
|
|
924
|
-
fs.writeFileSync(workspacePath, nextContent, 'utf8');
|
|
925
|
-
}
|
|
926
|
-
return { status: dryRun ? 'would-create' : 'created', file: operationFile, note };
|
|
927
|
-
}
|
|
928
|
-
|
|
929
|
-
const currentContent = fs.readFileSync(workspacePath, 'utf8');
|
|
930
|
-
if (currentContent === nextContent) {
|
|
931
|
-
return { status: 'unchanged', file: operationFile, note };
|
|
932
|
-
}
|
|
933
|
-
|
|
934
|
-
if (!dryRun) {
|
|
935
|
-
fs.writeFileSync(workspacePath, nextContent, 'utf8');
|
|
936
|
-
}
|
|
937
|
-
return { status: dryRun ? 'would-update' : 'updated', file: operationFile, note };
|
|
938
|
-
}
|
|
939
|
-
|
|
940
|
-
function hasGuardexBootstrapFiles(repoRoot) {
|
|
941
|
-
const required = [
|
|
942
|
-
'AGENTS.md',
|
|
943
|
-
'.githooks/pre-commit',
|
|
944
|
-
'.githooks/pre-push',
|
|
945
|
-
LOCK_FILE_RELATIVE,
|
|
946
|
-
];
|
|
947
|
-
return required.every((relativePath) => fs.existsSync(path.join(repoRoot, relativePath)));
|
|
948
|
-
}
|
|
949
|
-
|
|
950
|
-
function protectedBaseWriteBlock(options, { requireBootstrap = true } = {}) {
|
|
951
|
-
if (options.dryRun || options.allowProtectedBaseWrite) {
|
|
952
|
-
return null;
|
|
953
|
-
}
|
|
954
|
-
|
|
955
|
-
const repoRoot = resolveRepoRoot(options.target);
|
|
956
|
-
if (requireBootstrap && !hasGuardexBootstrapFiles(repoRoot)) {
|
|
957
|
-
return null;
|
|
958
|
-
}
|
|
959
|
-
|
|
960
|
-
const branch = currentBranchName(repoRoot);
|
|
961
|
-
if (branch !== 'main') {
|
|
962
|
-
return null;
|
|
963
|
-
}
|
|
964
|
-
|
|
965
|
-
const protectedBranches = readProtectedBranches(repoRoot);
|
|
966
|
-
if (!protectedBranches.includes(branch)) {
|
|
967
|
-
return null;
|
|
968
|
-
}
|
|
969
|
-
|
|
970
|
-
return {
|
|
971
|
-
repoRoot,
|
|
972
|
-
branch,
|
|
973
|
-
};
|
|
974
|
-
}
|
|
975
|
-
|
|
976
|
-
function assertProtectedMainWriteAllowed(options, commandName) {
|
|
977
|
-
return getSandboxApi().assertProtectedMainWriteAllowed(options, commandName);
|
|
978
|
-
}
|
|
979
|
-
|
|
980
|
-
function runSetupBootstrapInternal(options) {
|
|
981
|
-
return getSandboxApi().runSetupBootstrapInternal(options);
|
|
982
|
-
}
|
|
983
|
-
|
|
984
|
-
function extractAgentBranchStartMetadata(output) {
|
|
985
|
-
const branchMatch = String(output || '').match(/^\[agent-branch-start\] Created branch: (.+)$/m);
|
|
986
|
-
const worktreeMatch = String(output || '').match(/^\[agent-branch-start\] Worktree: (.+)$/m);
|
|
987
|
-
return {
|
|
988
|
-
branch: branchMatch ? branchMatch[1].trim() : '',
|
|
989
|
-
worktreePath: worktreeMatch ? worktreeMatch[1].trim() : '',
|
|
990
|
-
};
|
|
991
|
-
}
|
|
992
|
-
|
|
993
|
-
function resolveSandboxTarget(repoRoot, worktreePath, targetPath) {
|
|
994
|
-
const resolvedTarget = path.resolve(targetPath);
|
|
995
|
-
const relativeTarget = path.relative(repoRoot, resolvedTarget);
|
|
996
|
-
if (relativeTarget.startsWith('..') || path.isAbsolute(relativeTarget)) {
|
|
997
|
-
throw new Error(`sandbox target must stay inside repo root: ${resolvedTarget}`);
|
|
998
|
-
}
|
|
999
|
-
if (!relativeTarget || relativeTarget === '.') {
|
|
1000
|
-
return worktreePath;
|
|
1001
|
-
}
|
|
1002
|
-
return path.join(worktreePath, relativeTarget);
|
|
1003
|
-
}
|
|
1004
|
-
|
|
1005
|
-
function buildSandboxSetupArgs(options, sandboxTarget) {
|
|
1006
|
-
const args = ['setup', '--target', sandboxTarget, '--no-global-install', '--no-recursive'];
|
|
1007
|
-
appendForceArgs(args, options);
|
|
1008
|
-
if (options.skipAgents) args.push('--skip-agents');
|
|
1009
|
-
if (options.skipPackageJson) args.push('--skip-package-json');
|
|
1010
|
-
if (options.skipGitignore) args.push('--no-gitignore');
|
|
1011
|
-
if (options.dryRun) args.push('--dry-run');
|
|
1012
|
-
return args;
|
|
1013
|
-
}
|
|
1014
|
-
|
|
1015
|
-
function buildSandboxDoctorArgs(options, sandboxTarget) {
|
|
1016
|
-
const args = ['doctor', '--target', sandboxTarget];
|
|
1017
|
-
if (options.dryRun) args.push('--dry-run');
|
|
1018
|
-
appendForceArgs(args, options);
|
|
1019
|
-
if (options.skipAgents) args.push('--skip-agents');
|
|
1020
|
-
if (options.skipPackageJson) args.push('--skip-package-json');
|
|
1021
|
-
if (options.skipGitignore) args.push('--no-gitignore');
|
|
1022
|
-
if (!options.dropStaleLocks) args.push('--keep-stale-locks');
|
|
1023
|
-
args.push(options.waitForMerge ? '--wait-for-merge' : '--no-wait-for-merge');
|
|
1024
|
-
if (options.verboseAutoFinish) args.push('--verbose-auto-finish');
|
|
1025
|
-
if (options.json) args.push('--json');
|
|
1026
|
-
return args;
|
|
1027
|
-
}
|
|
1028
|
-
|
|
1029
|
-
function isSpawnFailure(result) {
|
|
1030
|
-
return Boolean(result?.error) && typeof result?.status !== 'number';
|
|
1031
|
-
}
|
|
1032
|
-
|
|
1033
|
-
function ensureRepoBranch(repoRoot, branch) {
|
|
1034
|
-
const current = currentBranchName(repoRoot);
|
|
1035
|
-
if (current === branch) {
|
|
1036
|
-
return { ok: true, changed: false };
|
|
1037
|
-
}
|
|
1038
|
-
|
|
1039
|
-
const checkoutResult = run('git', ['-C', repoRoot, 'checkout', branch], { timeout: 20_000 });
|
|
1040
|
-
if (isSpawnFailure(checkoutResult)) {
|
|
1041
|
-
return {
|
|
1042
|
-
ok: false,
|
|
1043
|
-
changed: false,
|
|
1044
|
-
stdout: checkoutResult.stdout || '',
|
|
1045
|
-
stderr: checkoutResult.stderr || '',
|
|
1046
|
-
};
|
|
1047
|
-
}
|
|
1048
|
-
if (checkoutResult.status !== 0) {
|
|
1049
|
-
return {
|
|
1050
|
-
ok: false,
|
|
1051
|
-
changed: false,
|
|
1052
|
-
stdout: checkoutResult.stdout || '',
|
|
1053
|
-
stderr: checkoutResult.stderr || '',
|
|
1054
|
-
};
|
|
1055
|
-
}
|
|
1056
|
-
|
|
1057
|
-
return { ok: true, changed: true };
|
|
1058
|
-
}
|
|
1059
|
-
|
|
1060
|
-
function protectedBaseSandboxBranchPrefix() {
|
|
1061
|
-
const now = new Date();
|
|
1062
|
-
const stamp = [
|
|
1063
|
-
now.getUTCFullYear(),
|
|
1064
|
-
String(now.getUTCMonth() + 1).padStart(2, '0'),
|
|
1065
|
-
String(now.getUTCDate()).padStart(2, '0'),
|
|
1066
|
-
].join('') + '-' + [
|
|
1067
|
-
String(now.getUTCHours()).padStart(2, '0'),
|
|
1068
|
-
String(now.getUTCMinutes()).padStart(2, '0'),
|
|
1069
|
-
String(now.getUTCSeconds()).padStart(2, '0'),
|
|
1070
|
-
].join('');
|
|
1071
|
-
return `agent/gx/${stamp}`;
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1074
|
-
function protectedBaseSandboxWorktreePath(repoRoot, branchName) {
|
|
1075
|
-
return path.join(repoRoot, defaultAgentWorktreeRelativeDir(), branchName.replace(/\//g, '__'));
|
|
1076
|
-
}
|
|
1077
|
-
|
|
1078
|
-
function gitRefExists(repoRoot, ref) {
|
|
1079
|
-
return run('git', ['-C', repoRoot, 'show-ref', '--verify', '--quiet', ref]).status === 0;
|
|
1080
|
-
}
|
|
1081
|
-
|
|
1082
|
-
function resolveProtectedBaseSandboxStartRef(repoRoot, baseBranch) {
|
|
1083
|
-
run('git', ['-C', repoRoot, 'fetch', 'origin', baseBranch, '--quiet'], { timeout: 20_000 });
|
|
1084
|
-
if (gitRefExists(repoRoot, `refs/remotes/origin/${baseBranch}`)) {
|
|
1085
|
-
return `origin/${baseBranch}`;
|
|
1086
|
-
}
|
|
1087
|
-
if (gitRefExists(repoRoot, `refs/heads/${baseBranch}`)) {
|
|
1088
|
-
return baseBranch;
|
|
1089
|
-
}
|
|
1090
|
-
if (currentBranchName(repoRoot) === baseBranch) {
|
|
1091
|
-
return null;
|
|
1092
|
-
}
|
|
1093
|
-
throw new Error(`Unable to find base ref for sandbox bootstrap: ${baseBranch}`);
|
|
1094
|
-
}
|
|
1095
|
-
|
|
1096
|
-
function startProtectedBaseSandboxFallback(blocked, sandboxSuffix) {
|
|
1097
|
-
const branchPrefix = protectedBaseSandboxBranchPrefix();
|
|
1098
|
-
let selectedBranch = '';
|
|
1099
|
-
let selectedWorktreePath = '';
|
|
1100
|
-
|
|
1101
|
-
for (let attempt = 0; attempt < 30; attempt += 1) {
|
|
1102
|
-
const suffix = attempt === 0 ? sandboxSuffix : `${attempt + 1}-${sandboxSuffix}`;
|
|
1103
|
-
const candidateBranch = `${branchPrefix}-${suffix}`;
|
|
1104
|
-
const candidateWorktreePath = protectedBaseSandboxWorktreePath(blocked.repoRoot, candidateBranch);
|
|
1105
|
-
if (gitRefExists(blocked.repoRoot, `refs/heads/${candidateBranch}`)) {
|
|
1106
|
-
continue;
|
|
1107
|
-
}
|
|
1108
|
-
if (fs.existsSync(candidateWorktreePath)) {
|
|
1109
|
-
continue;
|
|
1110
|
-
}
|
|
1111
|
-
selectedBranch = candidateBranch;
|
|
1112
|
-
selectedWorktreePath = candidateWorktreePath;
|
|
1113
|
-
break;
|
|
1114
|
-
}
|
|
1115
|
-
|
|
1116
|
-
if (!selectedBranch || !selectedWorktreePath) {
|
|
1117
|
-
throw new Error('Unable to allocate unique sandbox branch/worktree');
|
|
1118
|
-
}
|
|
1119
|
-
|
|
1120
|
-
fs.mkdirSync(path.dirname(selectedWorktreePath), { recursive: true });
|
|
1121
|
-
const startRef = resolveProtectedBaseSandboxStartRef(blocked.repoRoot, blocked.branch);
|
|
1122
|
-
const addArgs = startRef
|
|
1123
|
-
? ['-C', blocked.repoRoot, 'worktree', 'add', '-b', selectedBranch, selectedWorktreePath, startRef]
|
|
1124
|
-
: ['-C', blocked.repoRoot, 'worktree', 'add', '--orphan', selectedWorktreePath];
|
|
1125
|
-
const addResult = run('git', addArgs);
|
|
1126
|
-
if (isSpawnFailure(addResult)) {
|
|
1127
|
-
throw addResult.error;
|
|
1128
|
-
}
|
|
1129
|
-
if (addResult.status !== 0) {
|
|
1130
|
-
throw new Error((addResult.stderr || addResult.stdout || 'failed to create sandbox').trim());
|
|
1131
|
-
}
|
|
1132
|
-
|
|
1133
|
-
if (!startRef) {
|
|
1134
|
-
const renameResult = run(
|
|
1135
|
-
'git',
|
|
1136
|
-
['-C', selectedWorktreePath, 'branch', '-m', selectedBranch],
|
|
1137
|
-
{ timeout: 20_000 },
|
|
1138
|
-
);
|
|
1139
|
-
if (isSpawnFailure(renameResult)) {
|
|
1140
|
-
throw renameResult.error;
|
|
1141
|
-
}
|
|
1142
|
-
if (renameResult.status !== 0) {
|
|
1143
|
-
throw new Error(
|
|
1144
|
-
(renameResult.stderr || renameResult.stdout || 'failed to name orphan sandbox branch').trim(),
|
|
1145
|
-
);
|
|
1146
|
-
}
|
|
1147
|
-
}
|
|
1148
|
-
|
|
1149
|
-
return {
|
|
1150
|
-
metadata: {
|
|
1151
|
-
branch: selectedBranch,
|
|
1152
|
-
worktreePath: selectedWorktreePath,
|
|
1153
|
-
},
|
|
1154
|
-
stdout:
|
|
1155
|
-
`[agent-branch-start] Created branch: ${selectedBranch}\n` +
|
|
1156
|
-
`[agent-branch-start] Worktree: ${selectedWorktreePath}\n`,
|
|
1157
|
-
stderr: addResult.stderr || '',
|
|
1158
|
-
};
|
|
1159
|
-
}
|
|
1160
|
-
|
|
1161
|
-
function startProtectedBaseSandbox(blocked, { taskName, sandboxSuffix }) {
|
|
1162
|
-
if (sandboxSuffix === 'gx-doctor') {
|
|
1163
|
-
return startProtectedBaseSandboxFallback(blocked, sandboxSuffix);
|
|
1164
|
-
}
|
|
1165
|
-
|
|
1166
|
-
const startResult = runPackageAsset('branchStart', [
|
|
1167
|
-
'--task',
|
|
1168
|
-
taskName,
|
|
1169
|
-
'--agent',
|
|
1170
|
-
SHORT_TOOL_NAME,
|
|
1171
|
-
'--base',
|
|
1172
|
-
blocked.branch,
|
|
1173
|
-
], { cwd: blocked.repoRoot });
|
|
1174
|
-
if (isSpawnFailure(startResult)) {
|
|
1175
|
-
throw startResult.error;
|
|
1176
|
-
}
|
|
1177
|
-
if (startResult.status !== 0) {
|
|
1178
|
-
return startProtectedBaseSandboxFallback(blocked, sandboxSuffix);
|
|
1179
|
-
}
|
|
1180
|
-
|
|
1181
|
-
const metadata = extractAgentBranchStartMetadata(startResult.stdout);
|
|
1182
|
-
const currentBranch = currentBranchName(blocked.repoRoot);
|
|
1183
|
-
const worktreePath = metadata.worktreePath ? path.resolve(metadata.worktreePath) : '';
|
|
1184
|
-
const repoRootPath = path.resolve(blocked.repoRoot);
|
|
1185
|
-
const hasSafeWorktree = Boolean(worktreePath) && worktreePath !== repoRootPath;
|
|
1186
|
-
const branchChanged = Boolean(currentBranch) && currentBranch !== blocked.branch;
|
|
1187
|
-
|
|
1188
|
-
if (!hasSafeWorktree || branchChanged) {
|
|
1189
|
-
const restoreResult = ensureRepoBranch(blocked.repoRoot, blocked.branch);
|
|
1190
|
-
if (!restoreResult.ok) {
|
|
1191
|
-
const detail = [restoreResult.stderr, restoreResult.stdout].filter(Boolean).join('\n').trim();
|
|
1192
|
-
throw new Error(
|
|
1193
|
-
`sandbox startup switched protected base checkout and could not restore '${blocked.branch}'.` +
|
|
1194
|
-
(detail ? `\n${detail}` : ''),
|
|
1195
|
-
);
|
|
1196
|
-
}
|
|
1197
|
-
return startProtectedBaseSandboxFallback(blocked, sandboxSuffix);
|
|
1198
|
-
}
|
|
1199
|
-
|
|
1200
|
-
return {
|
|
1201
|
-
metadata,
|
|
1202
|
-
stdout: startResult.stdout || '',
|
|
1203
|
-
stderr: startResult.stderr || '',
|
|
1204
|
-
};
|
|
1205
|
-
}
|
|
1206
|
-
|
|
1207
|
-
function cleanupProtectedBaseSandbox(repoRoot, metadata) {
|
|
1208
|
-
const result = {
|
|
1209
|
-
worktree: 'skipped',
|
|
1210
|
-
branch: 'skipped',
|
|
1211
|
-
note: 'missing sandbox metadata',
|
|
1212
|
-
};
|
|
1213
|
-
|
|
1214
|
-
if (!metadata?.worktreePath || !metadata?.branch) {
|
|
1215
|
-
return result;
|
|
1216
|
-
}
|
|
1217
|
-
|
|
1218
|
-
if (fs.existsSync(metadata.worktreePath)) {
|
|
1219
|
-
const removeResult = run(
|
|
1220
|
-
'git',
|
|
1221
|
-
['-C', repoRoot, 'worktree', 'remove', '--force', metadata.worktreePath],
|
|
1222
|
-
{ timeout: 30_000 },
|
|
1223
|
-
);
|
|
1224
|
-
if (isSpawnFailure(removeResult)) {
|
|
1225
|
-
throw removeResult.error;
|
|
1226
|
-
}
|
|
1227
|
-
if (removeResult.status !== 0) {
|
|
1228
|
-
throw new Error(
|
|
1229
|
-
(removeResult.stderr || removeResult.stdout || 'failed to remove sandbox worktree').trim(),
|
|
1230
|
-
);
|
|
1231
|
-
}
|
|
1232
|
-
result.worktree = 'removed';
|
|
1233
|
-
} else {
|
|
1234
|
-
result.worktree = 'missing';
|
|
1235
|
-
}
|
|
1236
|
-
|
|
1237
|
-
if (gitRefExists(repoRoot, `refs/heads/${metadata.branch}`)) {
|
|
1238
|
-
const branchDeleteResult = run(
|
|
1239
|
-
'git',
|
|
1240
|
-
['-C', repoRoot, 'branch', '-D', metadata.branch],
|
|
1241
|
-
{ timeout: 20_000 },
|
|
1242
|
-
);
|
|
1243
|
-
if (isSpawnFailure(branchDeleteResult)) {
|
|
1244
|
-
throw branchDeleteResult.error;
|
|
1245
|
-
}
|
|
1246
|
-
if (branchDeleteResult.status !== 0) {
|
|
1247
|
-
throw new Error(
|
|
1248
|
-
(branchDeleteResult.stderr || branchDeleteResult.stdout || 'failed to delete sandbox branch').trim(),
|
|
1249
|
-
);
|
|
1250
|
-
}
|
|
1251
|
-
result.branch = 'deleted';
|
|
1252
|
-
} else {
|
|
1253
|
-
result.branch = 'missing';
|
|
1254
|
-
}
|
|
1255
|
-
|
|
1256
|
-
result.note = 'sandbox worktree pruned';
|
|
1257
|
-
return result;
|
|
1258
|
-
}
|
|
1259
|
-
|
|
1260
|
-
function parseGitPathList(output) {
|
|
1261
|
-
return String(output || '')
|
|
1262
|
-
.split('\n')
|
|
1263
|
-
.map((line) => line.trim())
|
|
1264
|
-
.filter((line) => line && line !== LOCK_FILE_RELATIVE);
|
|
1265
|
-
}
|
|
1266
|
-
|
|
1267
|
-
function collectDoctorChangedPaths(worktreePath) {
|
|
1268
|
-
const changed = new Set();
|
|
1269
|
-
const commands = [
|
|
1270
|
-
['diff', '--name-only'],
|
|
1271
|
-
['diff', '--cached', '--name-only'],
|
|
1272
|
-
['ls-files', '--others', '--exclude-standard'],
|
|
1273
|
-
];
|
|
1274
|
-
for (const gitArgs of commands) {
|
|
1275
|
-
const result = run('git', ['-C', worktreePath, ...gitArgs], { timeout: 20_000 });
|
|
1276
|
-
for (const filePath of parseGitPathList(result.stdout)) {
|
|
1277
|
-
changed.add(filePath);
|
|
1278
|
-
}
|
|
1279
|
-
}
|
|
1280
|
-
return Array.from(changed);
|
|
1281
|
-
}
|
|
1282
|
-
|
|
1283
|
-
function collectDoctorDeletedPaths(worktreePath) {
|
|
1284
|
-
const deleted = new Set();
|
|
1285
|
-
const commands = [
|
|
1286
|
-
['diff', '--name-only', '--diff-filter=D'],
|
|
1287
|
-
['diff', '--cached', '--name-only', '--diff-filter=D'],
|
|
1288
|
-
];
|
|
1289
|
-
for (const gitArgs of commands) {
|
|
1290
|
-
const result = run('git', ['-C', worktreePath, ...gitArgs], { timeout: 20_000 });
|
|
1291
|
-
for (const filePath of parseGitPathList(result.stdout)) {
|
|
1292
|
-
deleted.add(filePath);
|
|
1293
|
-
}
|
|
1294
|
-
}
|
|
1295
|
-
return Array.from(deleted);
|
|
1296
|
-
}
|
|
1297
|
-
|
|
1298
|
-
function collectWorktreeDirtyPaths(worktreePath) {
|
|
1299
|
-
const dirty = new Set();
|
|
1300
|
-
const commands = [
|
|
1301
|
-
['diff', '--name-only'],
|
|
1302
|
-
['diff', '--cached', '--name-only'],
|
|
1303
|
-
['ls-files', '--others', '--exclude-standard'],
|
|
1304
|
-
];
|
|
1305
|
-
for (const gitArgs of commands) {
|
|
1306
|
-
const result = run('git', ['-C', worktreePath, ...gitArgs], { timeout: 20_000 });
|
|
1307
|
-
for (const filePath of parseGitPathList(result.stdout)) {
|
|
1308
|
-
dirty.add(filePath);
|
|
1309
|
-
}
|
|
1310
|
-
}
|
|
1311
|
-
return Array.from(dirty);
|
|
1312
|
-
}
|
|
1313
|
-
|
|
1314
|
-
function collectDoctorForceAddPaths(worktreePath) {
|
|
1315
|
-
return REQUIRED_MANAGED_REPO_FILES
|
|
1316
|
-
.filter((relativePath) => relativePath.startsWith('scripts/') || relativePath.startsWith('.githooks/'))
|
|
1317
|
-
.filter((relativePath) => fs.existsSync(path.join(worktreePath, relativePath)));
|
|
1318
|
-
}
|
|
1319
|
-
|
|
1320
|
-
function stripDoctorSandboxLocks(rawContent, branchName) {
|
|
1321
|
-
if (!rawContent || !branchName) {
|
|
1322
|
-
return rawContent;
|
|
1323
|
-
}
|
|
1324
|
-
try {
|
|
1325
|
-
const parsed = JSON.parse(rawContent);
|
|
1326
|
-
const locks = parsed && typeof parsed === 'object' && parsed.locks && typeof parsed.locks === 'object'
|
|
1327
|
-
? parsed.locks
|
|
1328
|
-
: null;
|
|
1329
|
-
if (!locks) {
|
|
1330
|
-
return rawContent;
|
|
1331
|
-
}
|
|
1332
|
-
let changed = false;
|
|
1333
|
-
const filteredLocks = {};
|
|
1334
|
-
for (const [filePath, lockInfo] of Object.entries(locks)) {
|
|
1335
|
-
if (lockInfo && lockInfo.branch === branchName) {
|
|
1336
|
-
changed = true;
|
|
1337
|
-
continue;
|
|
1338
|
-
}
|
|
1339
|
-
filteredLocks[filePath] = lockInfo;
|
|
1340
|
-
}
|
|
1341
|
-
if (!changed) {
|
|
1342
|
-
return rawContent;
|
|
1343
|
-
}
|
|
1344
|
-
return `${JSON.stringify({ ...parsed, locks: filteredLocks }, null, 2)}\n`;
|
|
1345
|
-
} catch {
|
|
1346
|
-
return rawContent;
|
|
1347
|
-
}
|
|
1348
|
-
}
|
|
1349
|
-
|
|
1350
|
-
function claimDoctorChangedLocks(metadata) {
|
|
1351
|
-
if (!metadata.branch) {
|
|
1352
|
-
return {
|
|
1353
|
-
status: 'skipped',
|
|
1354
|
-
note: 'missing sandbox branch metadata',
|
|
1355
|
-
changedCount: 0,
|
|
1356
|
-
deletedCount: 0,
|
|
1357
|
-
};
|
|
1358
|
-
}
|
|
1359
|
-
|
|
1360
|
-
const changedPaths = Array.from(new Set([
|
|
1361
|
-
...collectDoctorChangedPaths(metadata.worktreePath),
|
|
1362
|
-
...collectDoctorForceAddPaths(metadata.worktreePath),
|
|
1363
|
-
]));
|
|
1364
|
-
const deletedPaths = collectDoctorDeletedPaths(metadata.worktreePath);
|
|
1365
|
-
if (changedPaths.length > 0) {
|
|
1366
|
-
runPackageAsset('lockTool', ['claim', '--branch', metadata.branch, ...changedPaths], {
|
|
1367
|
-
cwd: metadata.worktreePath,
|
|
1368
|
-
timeout: 30_000,
|
|
1369
|
-
});
|
|
1370
|
-
}
|
|
1371
|
-
if (deletedPaths.length > 0) {
|
|
1372
|
-
runPackageAsset('lockTool', ['allow-delete', '--branch', metadata.branch, ...deletedPaths], {
|
|
1373
|
-
cwd: metadata.worktreePath,
|
|
1374
|
-
timeout: 30_000,
|
|
1375
|
-
});
|
|
1376
|
-
}
|
|
1377
|
-
|
|
1378
|
-
return {
|
|
1379
|
-
status: 'claimed',
|
|
1380
|
-
note: 'claimed locks for doctor auto-commit',
|
|
1381
|
-
changedCount: changedPaths.length,
|
|
1382
|
-
deletedCount: deletedPaths.length,
|
|
1383
|
-
};
|
|
1384
|
-
}
|
|
1385
|
-
|
|
1386
|
-
function autoCommitDoctorSandboxChanges(metadata) {
|
|
1387
|
-
if (!metadata.worktreePath || !metadata.branch) {
|
|
1388
|
-
return {
|
|
1389
|
-
status: 'skipped',
|
|
1390
|
-
note: 'missing sandbox branch metadata',
|
|
1391
|
-
};
|
|
1392
|
-
}
|
|
1393
|
-
|
|
1394
|
-
claimDoctorChangedLocks(metadata);
|
|
1395
|
-
run(
|
|
1396
|
-
'git',
|
|
1397
|
-
['-C', metadata.worktreePath, 'add', '-A', '--', '.', `:(exclude)${LOCK_FILE_RELATIVE}`],
|
|
1398
|
-
{ timeout: 20_000 },
|
|
1399
|
-
);
|
|
1400
|
-
const forceAddPaths = collectDoctorForceAddPaths(metadata.worktreePath);
|
|
1401
|
-
if (forceAddPaths.length > 0) {
|
|
1402
|
-
run(
|
|
1403
|
-
'git',
|
|
1404
|
-
['-C', metadata.worktreePath, 'add', '-f', '--', ...forceAddPaths],
|
|
1405
|
-
{ timeout: 20_000 },
|
|
1406
|
-
);
|
|
1407
|
-
}
|
|
1408
|
-
const staged = run(
|
|
1409
|
-
'git',
|
|
1410
|
-
['-C', metadata.worktreePath, 'diff', '--cached', '--name-only', '--', '.', `:(exclude)${LOCK_FILE_RELATIVE}`],
|
|
1411
|
-
{ timeout: 20_000 },
|
|
1412
|
-
);
|
|
1413
|
-
const stagedFiles = parseGitPathList(staged.stdout);
|
|
1414
|
-
if (stagedFiles.length === 0) {
|
|
1415
|
-
return {
|
|
1416
|
-
status: 'no-changes',
|
|
1417
|
-
note: 'no committable doctor changes found in sandbox',
|
|
1418
|
-
};
|
|
1419
|
-
}
|
|
1420
|
-
|
|
1421
|
-
const commitResult = run(
|
|
1422
|
-
'git',
|
|
1423
|
-
['-C', metadata.worktreePath, 'commit', '-m', 'Auto-finish: gx doctor repairs'],
|
|
1424
|
-
{ timeout: 30_000 },
|
|
1425
|
-
);
|
|
1426
|
-
if (commitResult.status !== 0) {
|
|
1427
|
-
return {
|
|
1428
|
-
status: 'failed',
|
|
1429
|
-
note: 'doctor sandbox auto-commit failed',
|
|
1430
|
-
stdout: commitResult.stdout || '',
|
|
1431
|
-
stderr: commitResult.stderr || '',
|
|
1432
|
-
};
|
|
1433
|
-
}
|
|
1434
|
-
|
|
1435
|
-
return {
|
|
1436
|
-
status: 'committed',
|
|
1437
|
-
note: 'doctor sandbox repairs committed',
|
|
1438
|
-
commitMessage: 'Auto-finish: gx doctor repairs',
|
|
1439
|
-
stagedFiles,
|
|
1440
|
-
};
|
|
1441
|
-
}
|
|
1442
|
-
|
|
1443
|
-
function hasOriginRemote(repoRoot) {
|
|
1444
|
-
return run('git', ['-C', repoRoot, 'remote', 'get-url', 'origin']).status === 0;
|
|
1445
|
-
}
|
|
1446
|
-
|
|
1447
|
-
function originRemoteLooksLikeGithub(repoRoot) {
|
|
1448
|
-
const originUrl = readGitConfig(repoRoot, 'remote.origin.url');
|
|
1449
|
-
if (!originUrl) {
|
|
1450
|
-
return false;
|
|
1451
|
-
}
|
|
1452
|
-
return /github\.com[:/]/i.test(originUrl);
|
|
1453
|
-
}
|
|
1454
|
-
|
|
1455
|
-
function isCommandAvailable(commandName) {
|
|
1456
|
-
return run('which', [commandName]).status === 0;
|
|
1457
|
-
}
|
|
1458
|
-
|
|
1459
|
-
function extractAgentBranchFinishPrUrl(output) {
|
|
1460
|
-
const match = String(output || '').match(/\[agent-branch-finish\] PR:\s*(\S+)/);
|
|
1461
|
-
return match ? match[1] : '';
|
|
1462
|
-
}
|
|
1463
|
-
|
|
1464
|
-
function doctorFinishFlowIsPending(output) {
|
|
1465
|
-
return (
|
|
1466
|
-
/\[agent-branch-finish\] PR merge not completed yet; leaving PR open\./.test(output) ||
|
|
1467
|
-
/\[agent-branch-finish\] Merge pending review\/check policy\. Branch cleanup skipped for now\./.test(output) ||
|
|
1468
|
-
/\[agent-branch-finish\] PR auto-merge enabled; waiting for required checks\/reviews\./.test(output)
|
|
1469
|
-
);
|
|
1470
|
-
}
|
|
1471
|
-
|
|
1472
|
-
function finishDoctorSandboxBranch(blocked, metadata, options = {}) {
|
|
1473
|
-
if (!hasOriginRemote(blocked.repoRoot)) {
|
|
1474
|
-
return {
|
|
1475
|
-
status: 'skipped',
|
|
1476
|
-
note: 'origin remote missing; skipped auto-finish',
|
|
1477
|
-
};
|
|
1478
|
-
}
|
|
1479
|
-
const explicitGhBin = Boolean(String(process.env.GUARDEX_GH_BIN || '').trim());
|
|
1480
|
-
if (!explicitGhBin && !originRemoteLooksLikeGithub(blocked.repoRoot)) {
|
|
1481
|
-
return {
|
|
1482
|
-
status: 'skipped',
|
|
1483
|
-
note: 'origin remote is not GitHub; skipped auto-finish PR flow',
|
|
1484
|
-
};
|
|
1485
|
-
}
|
|
1486
|
-
|
|
1487
|
-
const ghBin = process.env.GUARDEX_GH_BIN || 'gh';
|
|
1488
|
-
if (!isCommandAvailable(ghBin)) {
|
|
1489
|
-
return {
|
|
1490
|
-
status: 'skipped',
|
|
1491
|
-
note: `'${ghBin}' not available; skipped auto-finish PR flow`,
|
|
1492
|
-
};
|
|
1493
|
-
}
|
|
1494
|
-
const ghAuthStatus = run(ghBin, ['auth', 'status'], { timeout: 20_000 });
|
|
1495
|
-
if (ghAuthStatus.status !== 0) {
|
|
1496
|
-
return {
|
|
1497
|
-
status: 'skipped',
|
|
1498
|
-
note: `'${ghBin}' auth unavailable; skipped auto-finish PR flow`,
|
|
1499
|
-
stderr: ghAuthStatus.stderr || '',
|
|
1500
|
-
};
|
|
1501
|
-
}
|
|
1502
|
-
|
|
1503
|
-
const rawWaitTimeoutSeconds = Number.parseInt(process.env.GUARDEX_FINISH_WAIT_TIMEOUT_SECONDS || '1800', 10);
|
|
1504
|
-
const waitTimeoutSeconds =
|
|
1505
|
-
Number.isFinite(rawWaitTimeoutSeconds) && rawWaitTimeoutSeconds >= 30 ? rawWaitTimeoutSeconds : 1800;
|
|
1506
|
-
const finishTimeoutMs = Math.max(180_000, (waitTimeoutSeconds + 60) * 1000);
|
|
1507
|
-
const waitForMergeArg = options.waitForMerge === false ? '--no-wait-for-merge' : '--wait-for-merge';
|
|
1508
|
-
|
|
1509
|
-
const finishResult = runPackageAsset(
|
|
1510
|
-
'branchFinish',
|
|
1511
|
-
['--branch', metadata.branch, '--base', blocked.branch, '--via-pr', waitForMergeArg, '--cleanup'],
|
|
1512
|
-
{ cwd: metadata.worktreePath, timeout: finishTimeoutMs },
|
|
1513
|
-
);
|
|
1514
|
-
if (isSpawnFailure(finishResult)) {
|
|
1515
|
-
return {
|
|
1516
|
-
status: 'failed',
|
|
1517
|
-
note: 'doctor sandbox finish flow errored',
|
|
1518
|
-
stdout: finishResult.stdout || '',
|
|
1519
|
-
stderr: finishResult.stderr || '',
|
|
1520
|
-
};
|
|
1521
|
-
}
|
|
1522
|
-
if (finishResult.status !== 0) {
|
|
1523
|
-
return {
|
|
1524
|
-
status: 'failed',
|
|
1525
|
-
note: 'doctor sandbox finish flow failed',
|
|
1526
|
-
stdout: finishResult.stdout || '',
|
|
1527
|
-
stderr: finishResult.stderr || '',
|
|
1528
|
-
};
|
|
1529
|
-
}
|
|
1530
|
-
|
|
1531
|
-
const combinedOutput = `${finishResult.stdout || ''}\n${finishResult.stderr || ''}`;
|
|
1532
|
-
if (doctorFinishFlowIsPending(combinedOutput)) {
|
|
1533
|
-
return {
|
|
1534
|
-
status: 'pending',
|
|
1535
|
-
note: 'PR created and waiting for merge policy/checks',
|
|
1536
|
-
prUrl: extractAgentBranchFinishPrUrl(combinedOutput),
|
|
1537
|
-
stdout: finishResult.stdout || '',
|
|
1538
|
-
stderr: finishResult.stderr || '',
|
|
1539
|
-
};
|
|
1540
|
-
}
|
|
1541
|
-
|
|
1542
|
-
return {
|
|
1543
|
-
status: 'completed',
|
|
1544
|
-
note: 'doctor sandbox finish flow completed',
|
|
1545
|
-
stdout: finishResult.stdout || '',
|
|
1546
|
-
stderr: finishResult.stderr || '',
|
|
1547
|
-
};
|
|
1548
|
-
}
|
|
1549
|
-
|
|
1550
|
-
function mergeDoctorSandboxRepairsBackToProtectedBase(options, blocked, metadata, autoCommitResult, finishResult) {
|
|
1551
|
-
if (options.dryRun) {
|
|
1552
|
-
return {
|
|
1553
|
-
status: autoCommitResult.status === 'committed' ? 'would-merge' : 'skipped',
|
|
1554
|
-
note: autoCommitResult.status === 'committed'
|
|
1555
|
-
? 'dry run: would fast-forward tracked doctor repairs into the protected base workspace'
|
|
1556
|
-
: 'dry run skips tracked repair merge',
|
|
1557
|
-
};
|
|
1558
|
-
}
|
|
1559
|
-
|
|
1560
|
-
if (autoCommitResult.status !== 'committed') {
|
|
1561
|
-
return {
|
|
1562
|
-
status: autoCommitResult.status === 'no-changes' ? 'unchanged' : 'skipped',
|
|
1563
|
-
note: autoCommitResult.status === 'no-changes'
|
|
1564
|
-
? 'no tracked doctor repairs needed in the protected base workspace'
|
|
1565
|
-
: 'tracked doctor repair merge skipped',
|
|
1566
|
-
};
|
|
1567
|
-
}
|
|
1568
|
-
|
|
1569
|
-
if (finishResult.status !== 'skipped') {
|
|
1570
|
-
return {
|
|
1571
|
-
status: 'skipped',
|
|
1572
|
-
note: finishResult.status === 'failed'
|
|
1573
|
-
? 'tracked doctor repairs remain in the sandbox after finish failure'
|
|
1574
|
-
: 'tracked doctor repairs are being delivered through the sandbox finish flow',
|
|
1575
|
-
};
|
|
1576
|
-
}
|
|
1577
|
-
|
|
1578
|
-
const allowedPaths = new Set([
|
|
1579
|
-
...(autoCommitResult.stagedFiles || []),
|
|
1580
|
-
...OMX_SCAFFOLD_DIRECTORIES,
|
|
1581
|
-
...Array.from(OMX_SCAFFOLD_FILES.keys()),
|
|
1582
|
-
...REQUIRED_MANAGED_REPO_FILES,
|
|
1583
|
-
'bin',
|
|
1584
|
-
'package.json',
|
|
1585
|
-
'.gitignore',
|
|
1586
|
-
'AGENTS.md',
|
|
1587
|
-
]);
|
|
1588
|
-
const dirtyPaths = collectWorktreeDirtyPaths(blocked.repoRoot);
|
|
1589
|
-
let stashRef = '';
|
|
1590
|
-
if (dirtyPaths.length > 0) {
|
|
1591
|
-
const unexpectedPaths = dirtyPaths.filter((filePath) => {
|
|
1592
|
-
if (allowedPaths.has(filePath)) {
|
|
1593
|
-
return false;
|
|
1594
|
-
}
|
|
1595
|
-
return !AGENT_WORKTREE_RELATIVE_DIRS.some(
|
|
1596
|
-
(relativeDir) => filePath === relativeDir || filePath.startsWith(`${relativeDir}/`),
|
|
1597
|
-
);
|
|
1598
|
-
});
|
|
1599
|
-
if (unexpectedPaths.length > 0) {
|
|
1600
|
-
return {
|
|
1601
|
-
status: 'failed',
|
|
1602
|
-
note: `protected branch workspace has unrelated local changes: ${unexpectedPaths.join(', ')}`,
|
|
1603
|
-
};
|
|
1604
|
-
}
|
|
1605
|
-
const stashMessage = `guardex-doctor-merge-${Date.now()}`;
|
|
1606
|
-
const stashResult = run(
|
|
1607
|
-
'git',
|
|
1608
|
-
['-C', blocked.repoRoot, 'stash', 'push', '--all', '--message', stashMessage],
|
|
1609
|
-
{ timeout: 30_000 },
|
|
1610
|
-
);
|
|
1611
|
-
if (isSpawnFailure(stashResult)) {
|
|
1612
|
-
return {
|
|
1613
|
-
status: 'failed',
|
|
1614
|
-
note: 'could not stash protected branch doctor drift before merge',
|
|
1615
|
-
stdout: stashResult.stdout || '',
|
|
1616
|
-
stderr: stashResult.stderr || '',
|
|
1617
|
-
};
|
|
1618
|
-
}
|
|
1619
|
-
if (stashResult.status !== 0) {
|
|
1620
|
-
return {
|
|
1621
|
-
status: 'failed',
|
|
1622
|
-
note: 'stashing protected branch doctor drift failed',
|
|
1623
|
-
stdout: stashResult.stdout || '',
|
|
1624
|
-
stderr: stashResult.stderr || '',
|
|
1625
|
-
};
|
|
1626
|
-
}
|
|
1627
|
-
|
|
1628
|
-
const stashLookup = run(
|
|
1629
|
-
'git',
|
|
1630
|
-
['-C', blocked.repoRoot, 'stash', 'list'],
|
|
1631
|
-
{ timeout: 20_000 },
|
|
1632
|
-
);
|
|
1633
|
-
stashRef = String(stashLookup.stdout || '')
|
|
1634
|
-
.split('\n')
|
|
1635
|
-
.find((line) => line.includes(stashMessage))
|
|
1636
|
-
?.split(':')[0]
|
|
1637
|
-
?.trim() || '';
|
|
1638
|
-
}
|
|
1639
|
-
|
|
1640
|
-
const restoreResult = ensureRepoBranch(blocked.repoRoot, blocked.branch);
|
|
1641
|
-
if (!restoreResult.ok) {
|
|
1642
|
-
if (stashRef) {
|
|
1643
|
-
run('git', ['-C', blocked.repoRoot, 'stash', 'apply', stashRef], { timeout: 30_000 });
|
|
1644
|
-
}
|
|
1645
|
-
return {
|
|
1646
|
-
status: 'failed',
|
|
1647
|
-
note: `could not restore protected branch '${blocked.branch}' before applying sandbox repairs`,
|
|
1648
|
-
stdout: restoreResult.stdout || '',
|
|
1649
|
-
stderr: restoreResult.stderr || '',
|
|
1650
|
-
};
|
|
1651
|
-
}
|
|
1652
|
-
|
|
1653
|
-
const mergeResult = run(
|
|
1654
|
-
'git',
|
|
1655
|
-
['-C', blocked.repoRoot, 'merge', '--ff-only', metadata.branch],
|
|
1656
|
-
{ timeout: 30_000 },
|
|
1657
|
-
);
|
|
1658
|
-
if (isSpawnFailure(mergeResult)) {
|
|
1659
|
-
if (stashRef) {
|
|
1660
|
-
run('git', ['-C', blocked.repoRoot, 'stash', 'apply', stashRef], { timeout: 30_000 });
|
|
1661
|
-
}
|
|
1662
|
-
return {
|
|
1663
|
-
status: 'failed',
|
|
1664
|
-
note: 'tracked doctor repair merge errored',
|
|
1665
|
-
stdout: mergeResult.stdout || '',
|
|
1666
|
-
stderr: mergeResult.stderr || '',
|
|
1667
|
-
};
|
|
1668
|
-
}
|
|
1669
|
-
if (mergeResult.status !== 0) {
|
|
1670
|
-
if (stashRef) {
|
|
1671
|
-
run('git', ['-C', blocked.repoRoot, 'stash', 'apply', stashRef], { timeout: 30_000 });
|
|
1672
|
-
}
|
|
1673
|
-
return {
|
|
1674
|
-
status: 'failed',
|
|
1675
|
-
note: 'tracked doctor repair merge failed',
|
|
1676
|
-
stdout: mergeResult.stdout || '',
|
|
1677
|
-
stderr: mergeResult.stderr || '',
|
|
1678
|
-
};
|
|
1679
|
-
}
|
|
1680
|
-
|
|
1681
|
-
let cleanupResult;
|
|
1682
|
-
try {
|
|
1683
|
-
cleanupResult = cleanupProtectedBaseSandbox(blocked.repoRoot, metadata);
|
|
1684
|
-
} catch (error) {
|
|
1685
|
-
return {
|
|
1686
|
-
status: 'failed',
|
|
1687
|
-
note: `tracked doctor repair merge succeeded but sandbox cleanup failed: ${error.message}`,
|
|
1688
|
-
stdout: mergeResult.stdout || '',
|
|
1689
|
-
stderr: mergeResult.stderr || '',
|
|
1690
|
-
};
|
|
1691
|
-
}
|
|
1692
|
-
|
|
1693
|
-
let hookRefreshResult;
|
|
1694
|
-
try {
|
|
1695
|
-
hookRefreshResult = configureHooks(blocked.repoRoot, false);
|
|
1696
|
-
} catch (error) {
|
|
1697
|
-
return {
|
|
1698
|
-
status: 'failed',
|
|
1699
|
-
note: `tracked doctor repair merge succeeded but local hook refresh failed: ${error.message}`,
|
|
1700
|
-
stdout: mergeResult.stdout || '',
|
|
1701
|
-
stderr: mergeResult.stderr || '',
|
|
1702
|
-
};
|
|
1703
|
-
}
|
|
1704
|
-
|
|
1705
|
-
if (stashRef) {
|
|
1706
|
-
run('git', ['-C', blocked.repoRoot, 'stash', 'drop', stashRef], { timeout: 20_000 });
|
|
1707
|
-
}
|
|
1708
|
-
|
|
1709
|
-
return {
|
|
1710
|
-
status: 'merged',
|
|
1711
|
-
note: 'fast-forwarded tracked doctor repairs into the protected base workspace',
|
|
1712
|
-
stdout: mergeResult.stdout || '',
|
|
1713
|
-
stderr: mergeResult.stderr || '',
|
|
1714
|
-
cleanup: cleanupResult,
|
|
1715
|
-
hookRefresh: hookRefreshResult,
|
|
1716
|
-
};
|
|
1717
|
-
}
|
|
1718
|
-
|
|
1719
|
-
/**
|
|
1720
|
-
* @param {string} [note]
|
|
1721
|
-
* @returns {OperationResult}
|
|
1722
|
-
*/
|
|
1723
|
-
function createDoctorSkippedOperation(note = 'sandbox doctor did not complete successfully') {
|
|
1724
|
-
return {
|
|
1725
|
-
status: 'skipped',
|
|
1726
|
-
note,
|
|
1727
|
-
};
|
|
1728
|
-
}
|
|
1729
|
-
|
|
1730
|
-
/**
|
|
1731
|
-
* @param {string} [note]
|
|
1732
|
-
* @returns {AutoFinishSummary}
|
|
1733
|
-
*/
|
|
1734
|
-
function createSkippedDoctorAutoFinishSummary(note = 'sandbox doctor did not complete successfully') {
|
|
1735
|
-
return {
|
|
1736
|
-
enabled: false,
|
|
1737
|
-
attempted: 0,
|
|
1738
|
-
completed: 0,
|
|
1739
|
-
skipped: 0,
|
|
1740
|
-
failed: 0,
|
|
1741
|
-
details: [`Skipped auto-finish sweep (${note}).`],
|
|
1742
|
-
};
|
|
1743
|
-
}
|
|
1744
|
-
|
|
1745
|
-
/**
|
|
1746
|
-
* Default the lifecycle to skipped states until the nested doctor run succeeds.
|
|
1747
|
-
*
|
|
1748
|
-
* @param {string} [note]
|
|
1749
|
-
* @returns {DoctorSandboxExecution}
|
|
1750
|
-
*/
|
|
1751
|
-
function createDoctorSandboxExecutionState(note = 'sandbox doctor did not complete successfully') {
|
|
1752
|
-
return {
|
|
1753
|
-
autoCommit: createDoctorSkippedOperation(note),
|
|
1754
|
-
finish: createDoctorSkippedOperation(note),
|
|
1755
|
-
protectedBaseRepairSync: createDoctorSkippedOperation(note),
|
|
1756
|
-
lockSync: createDoctorSkippedOperation(note),
|
|
1757
|
-
omxScaffoldSync: createDoctorSkippedOperation(note),
|
|
1758
|
-
autoFinish: createSkippedDoctorAutoFinishSummary(note),
|
|
1759
|
-
sandboxLockContent: null,
|
|
1760
|
-
};
|
|
1761
|
-
}
|
|
1762
|
-
|
|
1763
|
-
/**
|
|
1764
|
-
* @param {string} repoRoot
|
|
1765
|
-
* @param {boolean} dryRun
|
|
1766
|
-
* @returns {OperationResult}
|
|
1767
|
-
*/
|
|
1768
|
-
function summarizeDoctorOmxScaffoldSync(repoRoot, dryRun) {
|
|
1769
|
-
const omxScaffoldOps = ensureOmxScaffold(repoRoot, dryRun);
|
|
1770
|
-
const changedOmxPaths = omxScaffoldOps.filter((operation) => operation.status !== 'unchanged');
|
|
1771
|
-
if (changedOmxPaths.length === 0) {
|
|
1772
|
-
return {
|
|
1773
|
-
status: 'unchanged',
|
|
1774
|
-
note: '.omx scaffold already in sync',
|
|
1775
|
-
operations: omxScaffoldOps,
|
|
1776
|
-
};
|
|
1777
|
-
}
|
|
1778
|
-
return {
|
|
1779
|
-
status: dryRun ? 'would-sync' : 'synced',
|
|
1780
|
-
note: `${dryRun ? 'would sync' : 'synced'} ${changedOmxPaths.length} .omx path(s)`,
|
|
1781
|
-
operations: omxScaffoldOps,
|
|
1782
|
-
};
|
|
1783
|
-
}
|
|
1784
|
-
|
|
1785
|
-
/**
|
|
1786
|
-
* @param {string} repoRoot
|
|
1787
|
-
* @param {SandboxMetadata} metadata
|
|
1788
|
-
* @returns {DoctorLockSyncState}
|
|
1789
|
-
*/
|
|
1790
|
-
function syncDoctorLockRegistryBeforeMerge(repoRoot, metadata) {
|
|
1791
|
-
const sandboxLockPath = path.join(metadata.worktreePath, LOCK_FILE_RELATIVE);
|
|
1792
|
-
const baseLockPath = path.join(repoRoot, LOCK_FILE_RELATIVE);
|
|
1793
|
-
if (!fs.existsSync(baseLockPath)) {
|
|
1794
|
-
return {
|
|
1795
|
-
result: {
|
|
1796
|
-
status: 'skipped',
|
|
1797
|
-
note: `${LOCK_FILE_RELATIVE} missing in protected base workspace`,
|
|
1798
|
-
},
|
|
1799
|
-
sandboxLockContent: null,
|
|
1800
|
-
};
|
|
1801
|
-
}
|
|
1802
|
-
if (!fs.existsSync(sandboxLockPath)) {
|
|
1803
|
-
return {
|
|
1804
|
-
result: {
|
|
1805
|
-
status: 'skipped',
|
|
1806
|
-
note: `${LOCK_FILE_RELATIVE} missing in sandbox worktree`,
|
|
1807
|
-
},
|
|
1808
|
-
sandboxLockContent: null,
|
|
1809
|
-
};
|
|
1810
|
-
}
|
|
1811
|
-
|
|
1812
|
-
const sourceContent = stripDoctorSandboxLocks(
|
|
1813
|
-
fs.readFileSync(sandboxLockPath, 'utf8'),
|
|
1814
|
-
metadata.branch,
|
|
1815
|
-
);
|
|
1816
|
-
const destinationContent = fs.readFileSync(baseLockPath, 'utf8');
|
|
1817
|
-
if (sourceContent === destinationContent) {
|
|
1818
|
-
return {
|
|
1819
|
-
result: {
|
|
1820
|
-
status: 'unchanged',
|
|
1821
|
-
note: `${LOCK_FILE_RELATIVE} already in sync`,
|
|
1822
|
-
},
|
|
1823
|
-
sandboxLockContent: sourceContent,
|
|
1824
|
-
};
|
|
1825
|
-
}
|
|
1826
|
-
|
|
1827
|
-
fs.mkdirSync(path.dirname(baseLockPath), { recursive: true });
|
|
1828
|
-
fs.writeFileSync(baseLockPath, sourceContent, 'utf8');
|
|
1829
|
-
return {
|
|
1830
|
-
result: {
|
|
1831
|
-
status: 'synced',
|
|
1832
|
-
note: `${LOCK_FILE_RELATIVE} synced from sandbox`,
|
|
1833
|
-
},
|
|
1834
|
-
sandboxLockContent: sourceContent,
|
|
1835
|
-
};
|
|
1836
|
-
}
|
|
1837
|
-
|
|
1838
|
-
/**
|
|
1839
|
-
* @param {string} repoRoot
|
|
1840
|
-
* @param {string | null} sandboxLockContent
|
|
1841
|
-
* @returns {OperationResult}
|
|
1842
|
-
*/
|
|
1843
|
-
function syncDoctorLockRegistryAfterMerge(repoRoot, sandboxLockContent) {
|
|
1844
|
-
if (sandboxLockContent === null) {
|
|
1845
|
-
return {
|
|
1846
|
-
status: 'skipped',
|
|
1847
|
-
note: `${LOCK_FILE_RELATIVE} missing in sandbox worktree`,
|
|
1848
|
-
};
|
|
1849
|
-
}
|
|
1850
|
-
|
|
1851
|
-
const baseLockPath = path.join(repoRoot, LOCK_FILE_RELATIVE);
|
|
1852
|
-
if (!fs.existsSync(baseLockPath)) {
|
|
1853
|
-
fs.mkdirSync(path.dirname(baseLockPath), { recursive: true });
|
|
1854
|
-
fs.writeFileSync(baseLockPath, sandboxLockContent, 'utf8');
|
|
1855
|
-
return {
|
|
1856
|
-
status: 'synced',
|
|
1857
|
-
note: `${LOCK_FILE_RELATIVE} recreated from sandbox`,
|
|
1858
|
-
};
|
|
1859
|
-
}
|
|
1860
|
-
|
|
1861
|
-
const destinationContent = fs.readFileSync(baseLockPath, 'utf8');
|
|
1862
|
-
if (sandboxLockContent === destinationContent) {
|
|
1863
|
-
return {
|
|
1864
|
-
status: 'unchanged',
|
|
1865
|
-
note: `${LOCK_FILE_RELATIVE} already in sync`,
|
|
1866
|
-
};
|
|
1867
|
-
}
|
|
1868
|
-
|
|
1869
|
-
fs.mkdirSync(path.dirname(baseLockPath), { recursive: true });
|
|
1870
|
-
fs.writeFileSync(baseLockPath, sandboxLockContent, 'utf8');
|
|
1871
|
-
return {
|
|
1872
|
-
status: 'synced',
|
|
1873
|
-
note: `${LOCK_FILE_RELATIVE} synced from sandbox`,
|
|
1874
|
-
};
|
|
1875
|
-
}
|
|
1876
|
-
|
|
1877
|
-
/**
|
|
1878
|
-
* @param {object} options
|
|
1879
|
-
* @param {{ repoRoot: string, branch: string }} blocked
|
|
1880
|
-
* @param {SandboxMetadata} metadata
|
|
1881
|
-
* @returns {DoctorSandboxExecution}
|
|
1882
|
-
*/
|
|
1883
|
-
function executeDoctorSandboxLifecycle(options, blocked, metadata) {
|
|
1884
|
-
const execution = createDoctorSandboxExecutionState();
|
|
1885
|
-
const dryRun = Boolean(options.dryRun);
|
|
1886
|
-
|
|
1887
|
-
execution.omxScaffoldSync = summarizeDoctorOmxScaffoldSync(blocked.repoRoot, dryRun);
|
|
1888
|
-
|
|
1889
|
-
if (!dryRun) {
|
|
1890
|
-
execution.autoCommit = autoCommitDoctorSandboxChanges(metadata);
|
|
1891
|
-
if (execution.autoCommit.status === 'committed') {
|
|
1892
|
-
execution.finish = finishDoctorSandboxBranch(blocked, metadata, options);
|
|
1893
|
-
} else if (execution.autoCommit.status === 'no-changes') {
|
|
1894
|
-
execution.finish = createDoctorSkippedOperation('no doctor changes to auto-finish');
|
|
1895
|
-
} else if (execution.autoCommit.status !== 'failed') {
|
|
1896
|
-
execution.finish = createDoctorSkippedOperation('auto-commit did not run');
|
|
1897
|
-
}
|
|
1898
|
-
} else {
|
|
1899
|
-
execution.autoCommit = createDoctorSkippedOperation('dry-run skips doctor sandbox auto-commit');
|
|
1900
|
-
execution.finish = createDoctorSkippedOperation('dry-run skips doctor sandbox finish flow');
|
|
1901
|
-
}
|
|
1902
|
-
|
|
1903
|
-
const lockSyncState = syncDoctorLockRegistryBeforeMerge(blocked.repoRoot, metadata);
|
|
1904
|
-
execution.lockSync = lockSyncState.result;
|
|
1905
|
-
execution.sandboxLockContent = lockSyncState.sandboxLockContent;
|
|
1906
|
-
|
|
1907
|
-
execution.protectedBaseRepairSync = mergeDoctorSandboxRepairsBackToProtectedBase(
|
|
1908
|
-
options,
|
|
1909
|
-
blocked,
|
|
1910
|
-
metadata,
|
|
1911
|
-
execution.autoCommit,
|
|
1912
|
-
execution.finish,
|
|
1913
|
-
);
|
|
1914
|
-
|
|
1915
|
-
execution.omxScaffoldSync = summarizeDoctorOmxScaffoldSync(blocked.repoRoot, dryRun);
|
|
1916
|
-
execution.lockSync = syncDoctorLockRegistryAfterMerge(
|
|
1917
|
-
blocked.repoRoot,
|
|
1918
|
-
execution.sandboxLockContent,
|
|
1919
|
-
);
|
|
1920
|
-
execution.autoFinish = autoFinishReadyAgentBranches(blocked.repoRoot, {
|
|
1921
|
-
baseBranch: blocked.branch,
|
|
1922
|
-
dryRun: options.dryRun,
|
|
1923
|
-
waitForMerge: options.waitForMerge,
|
|
1924
|
-
excludeBranches: [metadata.branch],
|
|
1925
|
-
});
|
|
1926
|
-
|
|
1927
|
-
return execution;
|
|
1928
|
-
}
|
|
1929
|
-
|
|
1930
|
-
function emitDoctorSandboxJsonOutput(nestedResult, execution) {
|
|
1931
|
-
if (nestedResult.stdout) {
|
|
1932
|
-
if (nestedResult.status === 0) {
|
|
1933
|
-
try {
|
|
1934
|
-
const parsed = JSON.parse(nestedResult.stdout);
|
|
1935
|
-
process.stdout.write(
|
|
1936
|
-
JSON.stringify(
|
|
1937
|
-
{
|
|
1938
|
-
...parsed,
|
|
1939
|
-
protectedBaseRepairSync: execution.protectedBaseRepairSync,
|
|
1940
|
-
sandboxOmxScaffoldSync: execution.omxScaffoldSync,
|
|
1941
|
-
sandboxLockSync: execution.lockSync,
|
|
1942
|
-
sandboxAutoCommit: execution.autoCommit,
|
|
1943
|
-
sandboxFinish: execution.finish,
|
|
1944
|
-
autoFinish: execution.autoFinish,
|
|
1945
|
-
},
|
|
1946
|
-
null,
|
|
1947
|
-
2,
|
|
1948
|
-
) + '\n',
|
|
1949
|
-
);
|
|
1950
|
-
} catch {
|
|
1951
|
-
process.stdout.write(nestedResult.stdout);
|
|
1952
|
-
}
|
|
1953
|
-
} else {
|
|
1954
|
-
process.stdout.write(nestedResult.stdout);
|
|
1955
|
-
}
|
|
1956
|
-
}
|
|
1957
|
-
if (nestedResult.stderr) process.stderr.write(nestedResult.stderr);
|
|
1958
|
-
}
|
|
1959
|
-
|
|
1960
|
-
/**
|
|
1961
|
-
* @param {object} options
|
|
1962
|
-
* @param {{ branch: string }} blocked
|
|
1963
|
-
* @param {SandboxMetadata} metadata
|
|
1964
|
-
* @param {SandboxStartResult} startResult
|
|
1965
|
-
* @param {any} nestedResult
|
|
1966
|
-
* @param {DoctorSandboxExecution} execution
|
|
1967
|
-
*/
|
|
1968
|
-
function emitDoctorSandboxConsoleOutput(options, blocked, metadata, startResult, nestedResult, execution) {
|
|
1969
|
-
console.log(
|
|
1970
|
-
`[${TOOL_NAME}] doctor detected protected branch '${blocked.branch}'. ` +
|
|
1971
|
-
`Running repairs in sandbox branch '${metadata.branch || 'agent/<auto>'}'.`,
|
|
1972
|
-
);
|
|
1973
|
-
if (startResult.stdout) process.stdout.write(startResult.stdout);
|
|
1974
|
-
if (startResult.stderr) process.stderr.write(startResult.stderr);
|
|
1975
|
-
if (nestedResult.stdout) process.stdout.write(nestedResult.stdout);
|
|
1976
|
-
if (nestedResult.stderr) process.stderr.write(nestedResult.stderr);
|
|
1977
|
-
if (nestedResult.status !== 0) {
|
|
1978
|
-
return;
|
|
1979
|
-
}
|
|
1980
|
-
|
|
1981
|
-
if (execution.autoCommit.status === 'committed') {
|
|
1982
|
-
console.log(
|
|
1983
|
-
`[${TOOL_NAME}] Auto-committed doctor repairs in sandbox branch '${metadata.branch}'.`,
|
|
1984
|
-
);
|
|
1985
|
-
} else if (execution.autoCommit.status === 'failed') {
|
|
1986
|
-
console.log(`[${TOOL_NAME}] Doctor sandbox auto-commit failed; branch left for manual follow-up.`);
|
|
1987
|
-
if (execution.autoCommit.stdout) process.stdout.write(execution.autoCommit.stdout);
|
|
1988
|
-
if (execution.autoCommit.stderr) process.stderr.write(execution.autoCommit.stderr);
|
|
1989
|
-
} else {
|
|
1990
|
-
console.log(`[${TOOL_NAME}] Doctor sandbox auto-commit skipped: ${execution.autoCommit.note}.`);
|
|
1991
|
-
}
|
|
1992
|
-
|
|
1993
|
-
if (execution.protectedBaseRepairSync.status === 'merged') {
|
|
1994
|
-
console.log(`[${TOOL_NAME}] Fast-forwarded tracked doctor repairs into the protected branch workspace.`);
|
|
1995
|
-
} else if (execution.protectedBaseRepairSync.status === 'unchanged') {
|
|
1996
|
-
console.log(`[${TOOL_NAME}] Protected branch workspace already had the tracked doctor repairs.`);
|
|
1997
|
-
} else if (execution.protectedBaseRepairSync.status === 'would-merge') {
|
|
1998
|
-
console.log(`[${TOOL_NAME}] Dry run: would fast-forward tracked doctor repairs into the protected branch workspace.`);
|
|
1999
|
-
} else if (execution.protectedBaseRepairSync.status === 'failed') {
|
|
2000
|
-
console.log(`[${TOOL_NAME}] Protected branch tracked repair merge failed: ${execution.protectedBaseRepairSync.note}.`);
|
|
2001
|
-
if (execution.protectedBaseRepairSync.stdout) process.stdout.write(execution.protectedBaseRepairSync.stdout);
|
|
2002
|
-
if (execution.protectedBaseRepairSync.stderr) process.stderr.write(execution.protectedBaseRepairSync.stderr);
|
|
2003
|
-
} else {
|
|
2004
|
-
console.log(`[${TOOL_NAME}] Protected branch tracked repair merge skipped: ${execution.protectedBaseRepairSync.note}.`);
|
|
2005
|
-
}
|
|
2006
|
-
|
|
2007
|
-
if (execution.lockSync.status === 'synced') {
|
|
2008
|
-
console.log(
|
|
2009
|
-
`[${TOOL_NAME}] Synced repaired lock registry back to protected branch workspace (${LOCK_FILE_RELATIVE}).`,
|
|
2010
|
-
);
|
|
2011
|
-
} else if (execution.lockSync.status === 'unchanged') {
|
|
2012
|
-
console.log(`[${TOOL_NAME}] Lock registry already synced in protected branch workspace.`);
|
|
2013
|
-
} else {
|
|
2014
|
-
console.log(`[${TOOL_NAME}] Lock registry sync skipped: ${execution.lockSync.note}.`);
|
|
2015
|
-
}
|
|
2016
|
-
|
|
2017
|
-
if (execution.finish.status === 'completed') {
|
|
2018
|
-
console.log(`[${TOOL_NAME}] Auto-finish flow completed for sandbox branch '${metadata.branch}'.`);
|
|
2019
|
-
if (execution.finish.stdout) process.stdout.write(execution.finish.stdout);
|
|
2020
|
-
if (execution.finish.stderr) process.stderr.write(execution.finish.stderr);
|
|
2021
|
-
} else if (execution.finish.status === 'pending') {
|
|
2022
|
-
console.log(
|
|
2023
|
-
`[${TOOL_NAME}] Auto-finish pending for sandbox branch '${metadata.branch}': ${execution.finish.note}.`,
|
|
2024
|
-
);
|
|
2025
|
-
if (execution.finish.prUrl) {
|
|
2026
|
-
console.log(`[${TOOL_NAME}] PR: ${execution.finish.prUrl}`);
|
|
2027
|
-
}
|
|
2028
|
-
if (execution.finish.stdout) process.stdout.write(execution.finish.stdout);
|
|
2029
|
-
if (execution.finish.stderr) process.stderr.write(execution.finish.stderr);
|
|
2030
|
-
} else if (execution.finish.status === 'failed') {
|
|
2031
|
-
console.log(`[${TOOL_NAME}] Auto-finish flow failed for sandbox branch '${metadata.branch}'.`);
|
|
2032
|
-
if (execution.finish.stdout) process.stdout.write(execution.finish.stdout);
|
|
2033
|
-
if (execution.finish.stderr) process.stderr.write(execution.finish.stderr);
|
|
2034
|
-
} else {
|
|
2035
|
-
console.log(`[${TOOL_NAME}] Auto-finish skipped: ${execution.finish.note}.`);
|
|
2036
|
-
}
|
|
2037
|
-
|
|
2038
|
-
printAutoFinishSummary(execution.autoFinish, {
|
|
2039
|
-
baseBranch: blocked.branch,
|
|
2040
|
-
verbose: options.verboseAutoFinish,
|
|
2041
|
-
});
|
|
2042
|
-
if (execution.omxScaffoldSync.status === 'synced') {
|
|
2043
|
-
console.log(`[${TOOL_NAME}] Synced .omx scaffold back to protected branch workspace.`);
|
|
2044
|
-
} else if (execution.omxScaffoldSync.status === 'unchanged') {
|
|
2045
|
-
console.log(`[${TOOL_NAME}] .omx scaffold already aligned in protected branch workspace.`);
|
|
2046
|
-
} else if (execution.omxScaffoldSync.status === 'would-sync') {
|
|
2047
|
-
console.log(`[${TOOL_NAME}] Dry run: would sync .omx scaffold back to protected branch workspace.`);
|
|
2048
|
-
} else {
|
|
2049
|
-
console.log(`[${TOOL_NAME}] .omx scaffold sync skipped: ${execution.omxScaffoldSync.note}.`);
|
|
2050
|
-
}
|
|
2051
|
-
}
|
|
2052
|
-
|
|
2053
|
-
function setDoctorSandboxExitCode(nestedResult, execution) {
|
|
2054
|
-
if (typeof nestedResult.status === 'number') {
|
|
2055
|
-
let exitCode = nestedResult.status;
|
|
2056
|
-
if (exitCode === 0 && execution.autoCommit.status === 'failed') {
|
|
2057
|
-
exitCode = 1;
|
|
2058
|
-
}
|
|
2059
|
-
if (
|
|
2060
|
-
exitCode === 0 &&
|
|
2061
|
-
execution.autoCommit.status === 'committed' &&
|
|
2062
|
-
(execution.finish.status === 'failed' || execution.finish.status === 'pending')
|
|
2063
|
-
) {
|
|
2064
|
-
exitCode = 1;
|
|
2065
|
-
}
|
|
2066
|
-
if (exitCode === 0 && execution.protectedBaseRepairSync.status === 'failed') {
|
|
2067
|
-
exitCode = 1;
|
|
2068
|
-
}
|
|
2069
|
-
process.exitCode = exitCode;
|
|
2070
|
-
return;
|
|
2071
|
-
}
|
|
2072
|
-
process.exitCode = 1;
|
|
2073
|
-
}
|
|
2074
|
-
|
|
2075
|
-
function runDoctorInSandbox(options, blocked) {
|
|
2076
|
-
/** @type {SandboxStartResult} */
|
|
2077
|
-
const startResult = startProtectedBaseSandbox(blocked, {
|
|
2078
|
-
taskName: `${SHORT_TOOL_NAME}-doctor`,
|
|
2079
|
-
sandboxSuffix: 'gx-doctor',
|
|
2080
|
-
});
|
|
2081
|
-
const metadata = startResult.metadata;
|
|
2082
|
-
|
|
2083
|
-
const sandboxTarget = resolveSandboxTarget(blocked.repoRoot, metadata.worktreePath, options.target);
|
|
2084
|
-
const nestedResult = run(
|
|
2085
|
-
process.execPath,
|
|
2086
|
-
[__filename, ...buildSandboxDoctorArgs(options, sandboxTarget)],
|
|
2087
|
-
{ cwd: metadata.worktreePath },
|
|
2088
|
-
);
|
|
2089
|
-
if (isSpawnFailure(nestedResult)) {
|
|
2090
|
-
throw nestedResult.error;
|
|
2091
|
-
}
|
|
2092
|
-
|
|
2093
|
-
const execution = nestedResult.status === 0
|
|
2094
|
-
? executeDoctorSandboxLifecycle(options, blocked, metadata)
|
|
2095
|
-
: createDoctorSandboxExecutionState();
|
|
2096
|
-
|
|
2097
|
-
if (options.json) {
|
|
2098
|
-
emitDoctorSandboxJsonOutput(nestedResult, execution);
|
|
2099
|
-
} else {
|
|
2100
|
-
emitDoctorSandboxConsoleOutput(options, blocked, metadata, startResult, nestedResult, execution);
|
|
2101
|
-
}
|
|
2102
|
-
|
|
2103
|
-
setDoctorSandboxExitCode(nestedResult, execution);
|
|
2104
|
-
}
|
|
2105
|
-
|
|
2106
|
-
function runSetupInSandbox(options, blocked, repoLabel = '') {
|
|
2107
|
-
const startResult = startProtectedBaseSandbox(blocked, {
|
|
2108
|
-
taskName: `${SHORT_TOOL_NAME}-setup`,
|
|
2109
|
-
sandboxSuffix: 'gx-setup',
|
|
2110
|
-
});
|
|
2111
|
-
const metadata = startResult.metadata;
|
|
2112
|
-
|
|
2113
|
-
if (startResult.stdout) process.stdout.write(startResult.stdout);
|
|
2114
|
-
if (startResult.stderr) process.stderr.write(startResult.stderr);
|
|
2115
|
-
console.log(
|
|
2116
|
-
`[${TOOL_NAME}] setup blocked on protected branch '${blocked.branch}' in an initialized repo; ` +
|
|
2117
|
-
'refreshing through a sandbox worktree and syncing managed bootstrap files back locally.',
|
|
2118
|
-
);
|
|
2119
|
-
|
|
2120
|
-
const sandboxTarget = resolveSandboxTarget(blocked.repoRoot, metadata.worktreePath, options.target);
|
|
2121
|
-
const nestedResult = run(
|
|
2122
|
-
process.execPath,
|
|
2123
|
-
[__filename, ...buildSandboxSetupArgs(options, sandboxTarget)],
|
|
2124
|
-
{ cwd: metadata.worktreePath },
|
|
2125
|
-
);
|
|
2126
|
-
if (isSpawnFailure(nestedResult)) {
|
|
2127
|
-
throw nestedResult.error;
|
|
2128
|
-
}
|
|
2129
|
-
if (nestedResult.status !== 0) {
|
|
2130
|
-
if (nestedResult.stdout) process.stdout.write(nestedResult.stdout);
|
|
2131
|
-
if (nestedResult.stderr) process.stderr.write(nestedResult.stderr);
|
|
2132
|
-
throw new Error(
|
|
2133
|
-
`sandboxed setup failed for protected branch '${blocked.branch}'. ` +
|
|
2134
|
-
`Inspect sandbox at ${metadata.worktreePath}`,
|
|
2135
|
-
);
|
|
2136
|
-
}
|
|
2137
|
-
|
|
2138
|
-
const syncOptions = {
|
|
2139
|
-
...options,
|
|
2140
|
-
target: blocked.repoRoot,
|
|
2141
|
-
recursive: false,
|
|
2142
|
-
allowProtectedBaseWrite: true,
|
|
2143
|
-
};
|
|
2144
|
-
const { installPayload, fixPayload, parentWorkspace } = runSetupBootstrapInternal(syncOptions);
|
|
2145
|
-
printOperations(`Setup/install${repoLabel}`, installPayload, syncOptions.dryRun);
|
|
2146
|
-
printOperations(`Setup/fix${repoLabel}`, fixPayload, syncOptions.dryRun);
|
|
2147
|
-
if (!syncOptions.dryRun && parentWorkspace) {
|
|
2148
|
-
console.log(`[${TOOL_NAME}] Parent workspace view: ${parentWorkspace.workspacePath}`);
|
|
2149
|
-
}
|
|
2150
|
-
|
|
2151
|
-
const scanResult = runScanInternal({ target: blocked.repoRoot, json: false });
|
|
2152
|
-
const currentBaseBranch = currentBranchName(scanResult.repoRoot);
|
|
2153
|
-
const autoFinishSummary = autoFinishReadyAgentBranches(scanResult.repoRoot, {
|
|
2154
|
-
baseBranch: currentBaseBranch,
|
|
2155
|
-
dryRun: syncOptions.dryRun,
|
|
2156
|
-
});
|
|
2157
|
-
printScanResult(scanResult, false);
|
|
2158
|
-
if (autoFinishSummary.enabled) {
|
|
2159
|
-
console.log(
|
|
2160
|
-
`[${TOOL_NAME}] Auto-finish sweep (base=${currentBaseBranch}): attempted=${autoFinishSummary.attempted}, completed=${autoFinishSummary.completed}, skipped=${autoFinishSummary.skipped}, failed=${autoFinishSummary.failed}`,
|
|
2161
|
-
);
|
|
2162
|
-
for (const detail of autoFinishSummary.details) {
|
|
2163
|
-
console.log(`[${TOOL_NAME}] ${detail}`);
|
|
2164
|
-
}
|
|
2165
|
-
} else if (autoFinishSummary.details.length > 0) {
|
|
2166
|
-
console.log(`[${TOOL_NAME}] ${autoFinishSummary.details[0]}`);
|
|
2167
|
-
}
|
|
2168
|
-
|
|
2169
|
-
const cleanupResult = cleanupProtectedBaseSandbox(blocked.repoRoot, metadata);
|
|
2170
|
-
console.log(
|
|
2171
|
-
`[${TOOL_NAME}] Protected-base setup sandbox cleanup: ${cleanupResult.note} ` +
|
|
2172
|
-
`(worktree=${cleanupResult.worktree}, branch=${cleanupResult.branch}).`,
|
|
2173
|
-
);
|
|
2174
|
-
|
|
2175
|
-
return {
|
|
2176
|
-
scanResult,
|
|
2177
|
-
};
|
|
2178
|
-
}
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
function todayDateStamp() {
|
|
2182
|
-
return new Date().toISOString().slice(0, 10);
|
|
2183
|
-
}
|
|
2184
|
-
|
|
2185
|
-
function inferGithubRepoFromOrigin(repoRoot) {
|
|
2186
|
-
const rawOrigin = readGitConfig(repoRoot, 'remote.origin.url');
|
|
2187
|
-
if (!rawOrigin) return '';
|
|
2188
|
-
|
|
2189
|
-
const httpsMatch = rawOrigin.match(/github\.com[:/](.+?)(?:\.git)?$/i);
|
|
2190
|
-
if (!httpsMatch) return '';
|
|
2191
|
-
const slug = (httpsMatch[1] || '').replace(/^\/+/, '').trim();
|
|
2192
|
-
if (!slug || !slug.includes('/')) return '';
|
|
2193
|
-
return `github.com/${slug}`;
|
|
2194
|
-
}
|
|
2195
|
-
|
|
2196
|
-
function inferGithubRepoSlug(rawValue) {
|
|
2197
|
-
const raw = String(rawValue || '').trim();
|
|
2198
|
-
if (!raw) return '';
|
|
2199
|
-
const match = raw.match(/github\.com[:/](.+?)(?:\.git)?$/i);
|
|
2200
|
-
if (!match) return '';
|
|
2201
|
-
const slug = String(match[1] || '')
|
|
2202
|
-
.replace(/^\/+/, '')
|
|
2203
|
-
.replace(/^github\.com\//i, '')
|
|
2204
|
-
.trim();
|
|
2205
|
-
if (!slug || !slug.includes('/')) return '';
|
|
2206
|
-
return slug;
|
|
2207
|
-
}
|
|
2208
|
-
|
|
2209
|
-
function resolveScorecardRepo(repoRoot, explicitRepo) {
|
|
2210
|
-
if (explicitRepo) {
|
|
2211
|
-
return explicitRepo.trim();
|
|
2212
|
-
}
|
|
2213
|
-
const inferred = inferGithubRepoFromOrigin(repoRoot);
|
|
2214
|
-
if (inferred) return inferred;
|
|
2215
|
-
throw new Error(
|
|
2216
|
-
'Unable to infer GitHub repo from origin remote. Pass --repo github.com/<owner>/<repo>.',
|
|
2217
|
-
);
|
|
2218
|
-
}
|
|
2219
|
-
|
|
2220
|
-
function runScorecardJson(repo) {
|
|
2221
|
-
const result = run(SCORECARD_BIN, ['--repo', repo, '--format', 'json'], { allowFailure: true });
|
|
2222
|
-
if (result.status !== 0) {
|
|
2223
|
-
const details = (result.stderr || result.stdout || '').trim();
|
|
2224
|
-
throw new Error(
|
|
2225
|
-
`Failed to run scorecard CLI ('${SCORECARD_BIN} --repo ${repo} --format json').${details ? `\n${details}` : ''}`,
|
|
2226
|
-
);
|
|
2227
|
-
}
|
|
2228
|
-
|
|
2229
|
-
try {
|
|
2230
|
-
return JSON.parse(result.stdout || '{}');
|
|
2231
|
-
} catch (error) {
|
|
2232
|
-
throw new Error(`Unable to parse scorecard JSON output: ${error.message}`);
|
|
2233
|
-
}
|
|
2234
|
-
}
|
|
2235
|
-
|
|
2236
|
-
function readScorecardJsonFile(filePath) {
|
|
2237
|
-
const absolute = path.resolve(filePath);
|
|
2238
|
-
if (!fs.existsSync(absolute)) {
|
|
2239
|
-
throw new Error(`scorecard JSON file not found: ${absolute}`);
|
|
2240
|
-
}
|
|
2241
|
-
try {
|
|
2242
|
-
return JSON.parse(fs.readFileSync(absolute, 'utf8'));
|
|
2243
|
-
} catch (error) {
|
|
2244
|
-
throw new Error(`Unable to parse scorecard JSON file: ${error.message}`);
|
|
2245
|
-
}
|
|
2246
|
-
}
|
|
2247
|
-
|
|
2248
|
-
function normalizeScorecardChecks(payload) {
|
|
2249
|
-
const rawChecks = Array.isArray(payload?.checks) ? payload.checks : [];
|
|
2250
|
-
return rawChecks.map((check) => {
|
|
2251
|
-
const name = String(check?.name || 'Unknown');
|
|
2252
|
-
const rawScore = Number(check?.score);
|
|
2253
|
-
const score = Number.isFinite(rawScore) ? rawScore : 0;
|
|
2254
|
-
return {
|
|
2255
|
-
name,
|
|
2256
|
-
score,
|
|
2257
|
-
risk: SCORECARD_RISK_BY_CHECK[name] || 'Unknown',
|
|
2258
|
-
};
|
|
2259
|
-
});
|
|
2260
|
-
}
|
|
2261
|
-
|
|
2262
|
-
function renderScorecardBaselineMarkdown({ repo, score, checks, capturedAt, scorecardVersion, reportDate }) {
|
|
2263
|
-
const rows = checks
|
|
2264
|
-
.map((item) => `| ${item.name} | ${item.score} | ${item.risk} |`)
|
|
2265
|
-
.join('\n');
|
|
2266
|
-
|
|
2267
|
-
return [
|
|
2268
|
-
'# OpenSSF Scorecard Baseline Report',
|
|
2269
|
-
'',
|
|
2270
|
-
`- **Repository:** \`${repo}\``,
|
|
2271
|
-
'- **Source:** generated by `gx report scorecard`',
|
|
2272
|
-
`- **Captured at:** ${capturedAt}`,
|
|
2273
|
-
`- **Scorecard version:** \`${scorecardVersion}\``,
|
|
2274
|
-
`- **Overall score:** **${score} / 10**`,
|
|
2275
|
-
'',
|
|
2276
|
-
'## Check breakdown',
|
|
2277
|
-
'',
|
|
2278
|
-
'| Check | Score | Risk |',
|
|
2279
|
-
'|---|---:|---|',
|
|
2280
|
-
rows || '| (none) | 0 | Unknown |',
|
|
2281
|
-
'',
|
|
2282
|
-
`## Report date`,
|
|
2283
|
-
'',
|
|
2284
|
-
`- ${reportDate}`,
|
|
2285
|
-
'',
|
|
2286
|
-
].join('\n');
|
|
2287
|
-
}
|
|
2288
|
-
|
|
2289
|
-
function renderScorecardRemediationPlanMarkdown({ baselineRelativePath, checks }) {
|
|
2290
|
-
const failing = checks.filter((item) => item.score < 10);
|
|
2291
|
-
const failingRows = failing
|
|
2292
|
-
.sort((a, b) => a.score - b.score || a.name.localeCompare(b.name))
|
|
2293
|
-
.map((item) => `| ${item.name} | ${item.score} | ${item.risk} |`)
|
|
2294
|
-
.join('\n');
|
|
2295
|
-
|
|
2296
|
-
return [
|
|
2297
|
-
'# OpenSSF Scorecard Remediation Plan',
|
|
2298
|
-
'',
|
|
2299
|
-
`Based on baseline report: \`${baselineRelativePath}\`.`,
|
|
2300
|
-
'',
|
|
2301
|
-
'## Failing checks',
|
|
2302
|
-
'',
|
|
2303
|
-
'| Check | Score | Risk |',
|
|
2304
|
-
'|---|---:|---|',
|
|
2305
|
-
(failingRows || '| None | 10 | N/A |'),
|
|
2306
|
-
'',
|
|
2307
|
-
'## Priority order',
|
|
2308
|
-
'',
|
|
2309
|
-
'1. Fix **High** risk checks first (especially score 0 items).',
|
|
2310
|
-
'2. Then close **Medium** risk checks with score < 10.',
|
|
2311
|
-
'3. Finally address **Low** risk ecosystem/process checks.',
|
|
2312
|
-
'',
|
|
2313
|
-
'## Verification loop',
|
|
2314
|
-
'',
|
|
2315
|
-
'1. Run scorecard again.',
|
|
2316
|
-
'2. Re-generate baseline + remediation files.',
|
|
2317
|
-
'3. Compare score deltas and track improved checks.',
|
|
2318
|
-
'',
|
|
2319
|
-
].join('\n');
|
|
2320
|
-
}
|
|
2321
|
-
|
|
2322
|
-
function parseBranchList(rawValue) {
|
|
2323
|
-
return String(rawValue || '')
|
|
2324
|
-
.split(/[\s,]+/)
|
|
2325
|
-
.map((item) => item.trim())
|
|
2326
|
-
.filter(Boolean);
|
|
2327
|
-
}
|
|
2328
|
-
|
|
2329
|
-
function uniquePreserveOrder(items) {
|
|
2330
|
-
const seen = new Set();
|
|
2331
|
-
const result = [];
|
|
2332
|
-
for (const item of items) {
|
|
2333
|
-
if (seen.has(item)) continue;
|
|
2334
|
-
seen.add(item);
|
|
2335
|
-
result.push(item);
|
|
2336
|
-
}
|
|
2337
|
-
return result;
|
|
2338
|
-
}
|
|
2339
|
-
|
|
2340
|
-
function readConfiguredProtectedBranches(repoRoot) {
|
|
2341
|
-
const result = gitRun(repoRoot, ['config', '--get', GIT_PROTECTED_BRANCHES_KEY], { allowFailure: true });
|
|
2342
|
-
if (result.status !== 0) {
|
|
2343
|
-
return null;
|
|
2344
|
-
}
|
|
2345
|
-
const parsed = uniquePreserveOrder(parseBranchList(result.stdout.trim()));
|
|
2346
|
-
if (parsed.length === 0) {
|
|
2347
|
-
return null;
|
|
2348
|
-
}
|
|
2349
|
-
return parsed;
|
|
2350
|
-
}
|
|
2351
|
-
|
|
2352
|
-
function listLocalUserBranches(repoRoot) {
|
|
2353
|
-
const result = gitRun(repoRoot, ['for-each-ref', '--format=%(refname:short)', 'refs/heads'], { allowFailure: true });
|
|
2354
|
-
const branchNames = result.status === 0
|
|
2355
|
-
? uniquePreserveOrder(
|
|
2356
|
-
String(result.stdout || '')
|
|
2357
|
-
.split('\n')
|
|
2358
|
-
.map((item) => item.trim())
|
|
2359
|
-
.filter(Boolean),
|
|
2360
|
-
)
|
|
2361
|
-
: [];
|
|
2362
|
-
|
|
2363
|
-
const additionalUserBranches = branchNames.filter(
|
|
2364
|
-
(branchName) =>
|
|
2365
|
-
!branchName.startsWith('agent/') &&
|
|
2366
|
-
!DEFAULT_PROTECTED_BRANCHES.includes(branchName),
|
|
2367
|
-
);
|
|
2368
|
-
if (additionalUserBranches.length > 0) {
|
|
2369
|
-
return additionalUserBranches;
|
|
2370
|
-
}
|
|
2371
|
-
|
|
2372
|
-
const current = gitRun(repoRoot, ['branch', '--show-current'], { allowFailure: true });
|
|
2373
|
-
if (current.status !== 0) {
|
|
2374
|
-
return [];
|
|
2375
|
-
}
|
|
2376
|
-
|
|
2377
|
-
const branchName = String(current.stdout || '').trim();
|
|
2378
|
-
if (
|
|
2379
|
-
!branchName ||
|
|
2380
|
-
branchName.startsWith('agent/') ||
|
|
2381
|
-
DEFAULT_PROTECTED_BRANCHES.includes(branchName)
|
|
2382
|
-
) {
|
|
2383
|
-
return [];
|
|
2384
|
-
}
|
|
2385
|
-
|
|
2386
|
-
return [branchName];
|
|
2387
|
-
}
|
|
2388
|
-
|
|
2389
|
-
function listLocalAgentBranches(repoRoot) {
|
|
2390
|
-
const result = gitRun(
|
|
2391
|
-
repoRoot,
|
|
2392
|
-
['for-each-ref', '--format=%(refname:short)', 'refs/heads/agent/'],
|
|
2393
|
-
{ allowFailure: true },
|
|
2394
|
-
);
|
|
2395
|
-
if (result.status !== 0) {
|
|
2396
|
-
return [];
|
|
2397
|
-
}
|
|
2398
|
-
return uniquePreserveOrder(
|
|
2399
|
-
String(result.stdout || '')
|
|
2400
|
-
.split('\n')
|
|
2401
|
-
.map((item) => item.trim())
|
|
2402
|
-
.filter(Boolean),
|
|
2403
|
-
);
|
|
2404
|
-
}
|
|
2405
|
-
|
|
2406
|
-
function mapWorktreePathsByBranch(repoRoot) {
|
|
2407
|
-
const result = gitRun(repoRoot, ['worktree', 'list', '--porcelain'], { allowFailure: true });
|
|
2408
|
-
const map = new Map();
|
|
2409
|
-
if (result.status !== 0) {
|
|
2410
|
-
return map;
|
|
2411
|
-
}
|
|
470
|
+
function startProtectedBaseSandboxFallback(blocked, sandboxSuffix) {
|
|
471
|
+
const branchPrefix = protectedBaseSandboxBranchPrefix();
|
|
472
|
+
let selectedBranch = '';
|
|
473
|
+
let selectedWorktreePath = '';
|
|
2412
474
|
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
475
|
+
for (let attempt = 0; attempt < 30; attempt += 1) {
|
|
476
|
+
const suffix = attempt === 0 ? sandboxSuffix : `${attempt + 1}-${sandboxSuffix}`;
|
|
477
|
+
const candidateBranch = `${branchPrefix}-${suffix}`;
|
|
478
|
+
const candidateWorktreePath = protectedBaseSandboxWorktreePath(blocked.repoRoot, candidateBranch);
|
|
479
|
+
if (gitRefExists(blocked.repoRoot, `refs/heads/${candidateBranch}`)) {
|
|
2418
480
|
continue;
|
|
2419
481
|
}
|
|
2420
|
-
if (
|
|
2421
|
-
|
|
2422
|
-
if (currentWorktree && branchName) {
|
|
2423
|
-
map.set(branchName, currentWorktree);
|
|
2424
|
-
}
|
|
482
|
+
if (fs.existsSync(candidateWorktreePath)) {
|
|
483
|
+
continue;
|
|
2425
484
|
}
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
function hasSignificantWorkingTreeChanges(worktreePath) {
|
|
2431
|
-
const result = run('git', [
|
|
2432
|
-
'-C',
|
|
2433
|
-
worktreePath,
|
|
2434
|
-
'status',
|
|
2435
|
-
'--porcelain',
|
|
2436
|
-
'--untracked-files=normal',
|
|
2437
|
-
'--',
|
|
2438
|
-
]);
|
|
2439
|
-
if (result.status !== 0) {
|
|
2440
|
-
return true;
|
|
2441
|
-
}
|
|
2442
|
-
|
|
2443
|
-
const lines = String(result.stdout || '')
|
|
2444
|
-
.split('\n')
|
|
2445
|
-
.map((line) => line.trimEnd())
|
|
2446
|
-
.filter((line) => line.length > 0);
|
|
2447
|
-
|
|
2448
|
-
for (const line of lines) {
|
|
2449
|
-
const pathPart = (line.length > 3 ? line.slice(3) : '').trim();
|
|
2450
|
-
if (!pathPart) continue;
|
|
2451
|
-
if (pathPart === LOCK_FILE_RELATIVE) continue;
|
|
2452
|
-
if (pathPart.startsWith(`${LOCK_FILE_RELATIVE} -> `)) continue;
|
|
2453
|
-
if (pathPart.endsWith(` -> ${LOCK_FILE_RELATIVE}`)) continue;
|
|
2454
|
-
return true;
|
|
2455
|
-
}
|
|
2456
|
-
return false;
|
|
2457
|
-
}
|
|
2458
|
-
|
|
2459
|
-
function autoFinishReadyAgentBranches(repoRoot, options = {}) {
|
|
2460
|
-
const baseBranch = String(options.baseBranch || '').trim();
|
|
2461
|
-
const dryRun = Boolean(options.dryRun);
|
|
2462
|
-
const waitForMerge = options.waitForMerge !== false;
|
|
2463
|
-
const excludedBranches = new Set(
|
|
2464
|
-
Array.isArray(options.excludeBranches)
|
|
2465
|
-
? options.excludeBranches.map((branch) => String(branch || '').trim()).filter(Boolean)
|
|
2466
|
-
: [],
|
|
2467
|
-
);
|
|
2468
|
-
|
|
2469
|
-
const summary = {
|
|
2470
|
-
enabled: true,
|
|
2471
|
-
baseBranch,
|
|
2472
|
-
attempted: 0,
|
|
2473
|
-
completed: 0,
|
|
2474
|
-
skipped: 0,
|
|
2475
|
-
failed: 0,
|
|
2476
|
-
details: [],
|
|
2477
|
-
};
|
|
2478
|
-
|
|
2479
|
-
if (!baseBranch || baseBranch === 'HEAD' || baseBranch.startsWith('agent/')) {
|
|
2480
|
-
summary.enabled = false;
|
|
2481
|
-
summary.details.push('Skipped auto-finish sweep (base branch is missing or not a non-agent local branch).');
|
|
2482
|
-
return summary;
|
|
2483
|
-
}
|
|
2484
|
-
|
|
2485
|
-
if (String(process.env.GUARDEX_DOCTOR_SANDBOX || '') === '1') {
|
|
2486
|
-
summary.enabled = false;
|
|
2487
|
-
summary.details.push('Skipped auto-finish sweep inside doctor sandbox pass.');
|
|
2488
|
-
return summary;
|
|
2489
|
-
}
|
|
2490
|
-
|
|
2491
|
-
if (String(process.env.GUARDEX_SKIP_AUTO_FINISH_READY_BRANCHES || '') === '1') {
|
|
2492
|
-
summary.enabled = false;
|
|
2493
|
-
summary.details.push('Skipped auto-finish sweep (GUARDEX_SKIP_AUTO_FINISH_READY_BRANCHES=1).');
|
|
2494
|
-
return summary;
|
|
2495
|
-
}
|
|
2496
|
-
|
|
2497
|
-
if (dryRun) {
|
|
2498
|
-
summary.enabled = false;
|
|
2499
|
-
summary.details.push('Skipped auto-finish sweep in dry-run mode.');
|
|
2500
|
-
return summary;
|
|
485
|
+
selectedBranch = candidateBranch;
|
|
486
|
+
selectedWorktreePath = candidateWorktreePath;
|
|
487
|
+
break;
|
|
2501
488
|
}
|
|
2502
489
|
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
summary.enabled = false;
|
|
2506
|
-
summary.details.push('Skipped auto-finish sweep (origin remote missing).');
|
|
2507
|
-
return summary;
|
|
2508
|
-
}
|
|
2509
|
-
const explicitGhBin = Boolean(String(process.env.GUARDEX_GH_BIN || '').trim());
|
|
2510
|
-
if (!explicitGhBin && !originRemoteLooksLikeGithub(repoRoot)) {
|
|
2511
|
-
summary.enabled = false;
|
|
2512
|
-
summary.details.push('Skipped auto-finish sweep (origin remote is not GitHub).');
|
|
2513
|
-
return summary;
|
|
490
|
+
if (!selectedBranch || !selectedWorktreePath) {
|
|
491
|
+
throw new Error('Unable to allocate unique sandbox branch/worktree');
|
|
2514
492
|
}
|
|
2515
493
|
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
494
|
+
fs.mkdirSync(path.dirname(selectedWorktreePath), { recursive: true });
|
|
495
|
+
const startRef = resolveProtectedBaseSandboxStartRef(blocked.repoRoot, blocked.branch);
|
|
496
|
+
const addArgs = startRef
|
|
497
|
+
? ['-C', blocked.repoRoot, 'worktree', 'add', '-b', selectedBranch, selectedWorktreePath, startRef]
|
|
498
|
+
: ['-C', blocked.repoRoot, 'worktree', 'add', '--orphan', selectedWorktreePath];
|
|
499
|
+
const addResult = run('git', addArgs);
|
|
500
|
+
if (isSpawnFailure(addResult)) {
|
|
501
|
+
throw addResult.error;
|
|
2521
502
|
}
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
const agentBranches = listLocalAgentBranches(repoRoot);
|
|
2525
|
-
if (agentBranches.length === 0) {
|
|
2526
|
-
summary.enabled = false;
|
|
2527
|
-
summary.details.push('No local agent branches found for auto-finish sweep.');
|
|
2528
|
-
return summary;
|
|
503
|
+
if (addResult.status !== 0) {
|
|
504
|
+
throw new Error((addResult.stderr || addResult.stdout || 'failed to create sandbox').trim());
|
|
2529
505
|
}
|
|
2530
506
|
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
summary.skipped += 1;
|
|
2540
|
-
summary.details.push(`[skip] ${branch}: source branch equals base branch.`);
|
|
2541
|
-
continue;
|
|
2542
|
-
}
|
|
2543
|
-
|
|
2544
|
-
let counts;
|
|
2545
|
-
try {
|
|
2546
|
-
counts = aheadBehind(repoRoot, branch, baseBranch);
|
|
2547
|
-
} catch (error) {
|
|
2548
|
-
summary.failed += 1;
|
|
2549
|
-
summary.details.push(`[fail] ${branch}: unable to compute ahead/behind (${error.message}).`);
|
|
2550
|
-
continue;
|
|
2551
|
-
}
|
|
2552
|
-
|
|
2553
|
-
if (counts.ahead <= 0) {
|
|
2554
|
-
summary.skipped += 1;
|
|
2555
|
-
summary.details.push(`[skip] ${branch}: already merged into ${baseBranch}.`);
|
|
2556
|
-
continue;
|
|
2557
|
-
}
|
|
2558
|
-
|
|
2559
|
-
const branchWorktree = branchWorktrees.get(branch) || '';
|
|
2560
|
-
if (branchWorktree && hasSignificantWorkingTreeChanges(branchWorktree)) {
|
|
2561
|
-
summary.skipped += 1;
|
|
2562
|
-
summary.details.push(`[skip] ${branch}: dirty worktree (${branchWorktree}).`);
|
|
2563
|
-
continue;
|
|
2564
|
-
}
|
|
2565
|
-
|
|
2566
|
-
summary.attempted += 1;
|
|
2567
|
-
const finishArgs = [
|
|
2568
|
-
'--branch',
|
|
2569
|
-
branch,
|
|
2570
|
-
'--base',
|
|
2571
|
-
baseBranch,
|
|
2572
|
-
'--via-pr',
|
|
2573
|
-
waitForMerge ? '--wait-for-merge' : '--no-wait-for-merge',
|
|
2574
|
-
'--cleanup',
|
|
2575
|
-
];
|
|
2576
|
-
const finishResult = runPackageAsset('branchFinish', finishArgs, { cwd: repoRoot });
|
|
2577
|
-
const combinedOutput = [finishResult.stdout || '', finishResult.stderr || ''].join('\n').trim();
|
|
2578
|
-
|
|
2579
|
-
if (finishResult.status === 0) {
|
|
2580
|
-
summary.completed += 1;
|
|
2581
|
-
summary.details.push(`[done] ${branch}: auto-finish completed.`);
|
|
2582
|
-
continue;
|
|
507
|
+
if (!startRef) {
|
|
508
|
+
const renameResult = run(
|
|
509
|
+
'git',
|
|
510
|
+
['-C', selectedWorktreePath, 'branch', '-m', selectedBranch],
|
|
511
|
+
{ timeout: 20_000 },
|
|
512
|
+
);
|
|
513
|
+
if (isSpawnFailure(renameResult)) {
|
|
514
|
+
throw renameResult.error;
|
|
2583
515
|
}
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
const tail = combinedOutput ? ` ${combinedOutput.split('\n').slice(-2).join(' | ')}` : '';
|
|
2589
|
-
summary.details.push(`[skip] ${branch}: ${recoverableConflict.rawLabel}${tail}`);
|
|
2590
|
-
continue;
|
|
516
|
+
if (renameResult.status !== 0) {
|
|
517
|
+
throw new Error(
|
|
518
|
+
(renameResult.stderr || renameResult.stdout || 'failed to name orphan sandbox branch').trim(),
|
|
519
|
+
);
|
|
2591
520
|
}
|
|
2592
|
-
|
|
2593
|
-
summary.failed += 1;
|
|
2594
|
-
const tail = combinedOutput ? ` ${combinedOutput.split('\n').slice(-2).join(' | ')}` : '';
|
|
2595
|
-
summary.details.push(`[fail] ${branch}: auto-finish failed.${tail}`);
|
|
2596
|
-
}
|
|
2597
|
-
|
|
2598
|
-
return summary;
|
|
2599
|
-
}
|
|
2600
|
-
|
|
2601
|
-
function ensureSetupProtectedBranches(repoRoot, dryRun) {
|
|
2602
|
-
const localUserBranches = listLocalUserBranches(repoRoot);
|
|
2603
|
-
if (localUserBranches.length === 0) {
|
|
2604
|
-
return {
|
|
2605
|
-
status: 'unchanged',
|
|
2606
|
-
file: `git config ${GIT_PROTECTED_BRANCHES_KEY}`,
|
|
2607
|
-
note: 'no additional local user branches detected',
|
|
2608
|
-
};
|
|
2609
|
-
}
|
|
2610
|
-
|
|
2611
|
-
const configured = readConfiguredProtectedBranches(repoRoot);
|
|
2612
|
-
const currentBranches = configured || [...DEFAULT_PROTECTED_BRANCHES];
|
|
2613
|
-
const missingBranches = localUserBranches.filter((branchName) => !currentBranches.includes(branchName));
|
|
2614
|
-
if (missingBranches.length === 0) {
|
|
2615
|
-
return {
|
|
2616
|
-
status: 'unchanged',
|
|
2617
|
-
file: `git config ${GIT_PROTECTED_BRANCHES_KEY}`,
|
|
2618
|
-
note: 'local user branches already protected',
|
|
2619
|
-
};
|
|
2620
|
-
}
|
|
2621
|
-
|
|
2622
|
-
const nextBranches = uniquePreserveOrder([...currentBranches, ...missingBranches]);
|
|
2623
|
-
if (!dryRun) {
|
|
2624
|
-
writeProtectedBranches(repoRoot, nextBranches);
|
|
2625
521
|
}
|
|
2626
522
|
|
|
2627
523
|
return {
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
524
|
+
metadata: {
|
|
525
|
+
branch: selectedBranch,
|
|
526
|
+
worktreePath: selectedWorktreePath,
|
|
527
|
+
},
|
|
528
|
+
stdout:
|
|
529
|
+
`[agent-branch-start] Created branch: ${selectedBranch}\n` +
|
|
530
|
+
`[agent-branch-start] Worktree: ${selectedWorktreePath}\n`,
|
|
531
|
+
stderr: addResult.stderr || '',
|
|
2631
532
|
};
|
|
2632
533
|
}
|
|
2633
534
|
|
|
2634
|
-
function
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
return [...DEFAULT_PROTECTED_BRANCHES];
|
|
2638
|
-
}
|
|
2639
|
-
|
|
2640
|
-
const parsed = uniquePreserveOrder(parseBranchList(result.stdout.trim()));
|
|
2641
|
-
if (parsed.length === 0) {
|
|
2642
|
-
return [...DEFAULT_PROTECTED_BRANCHES];
|
|
2643
|
-
}
|
|
2644
|
-
return parsed;
|
|
2645
|
-
}
|
|
2646
|
-
|
|
2647
|
-
function writeProtectedBranches(repoRoot, branches) {
|
|
2648
|
-
if (branches.length === 0) {
|
|
2649
|
-
gitRun(repoRoot, ['config', '--unset-all', GIT_PROTECTED_BRANCHES_KEY], { allowFailure: true });
|
|
2650
|
-
return;
|
|
2651
|
-
}
|
|
2652
|
-
gitRun(repoRoot, ['config', GIT_PROTECTED_BRANCHES_KEY, branches.join(' ')]);
|
|
2653
|
-
}
|
|
2654
|
-
|
|
2655
|
-
function readGitConfig(repoRoot, key) {
|
|
2656
|
-
const result = gitRun(repoRoot, ['config', '--get', key], { allowFailure: true });
|
|
2657
|
-
if (result.status !== 0) {
|
|
2658
|
-
return '';
|
|
2659
|
-
}
|
|
2660
|
-
return (result.stdout || '').trim();
|
|
2661
|
-
}
|
|
2662
|
-
|
|
2663
|
-
function resolveBaseBranch(repoRoot, explicitBase) {
|
|
2664
|
-
if (explicitBase) {
|
|
2665
|
-
return explicitBase;
|
|
2666
|
-
}
|
|
2667
|
-
const configured = readGitConfig(repoRoot, GIT_BASE_BRANCH_KEY);
|
|
2668
|
-
return configured || DEFAULT_BASE_BRANCH;
|
|
2669
|
-
}
|
|
2670
|
-
|
|
2671
|
-
function resolveSyncStrategy(repoRoot, explicitStrategy) {
|
|
2672
|
-
const strategy = (explicitStrategy || readGitConfig(repoRoot, GIT_SYNC_STRATEGY_KEY) || DEFAULT_SYNC_STRATEGY)
|
|
2673
|
-
.trim()
|
|
2674
|
-
.toLowerCase();
|
|
2675
|
-
if (strategy !== 'rebase' && strategy !== 'merge') {
|
|
2676
|
-
throw new Error(`Invalid sync strategy '${strategy}' (expected: rebase or merge)`);
|
|
535
|
+
function startProtectedBaseSandbox(blocked, { taskName, sandboxSuffix }) {
|
|
536
|
+
if (sandboxSuffix === 'gx-doctor') {
|
|
537
|
+
return startProtectedBaseSandboxFallback(blocked, sandboxSuffix);
|
|
2677
538
|
}
|
|
2678
|
-
return strategy;
|
|
2679
|
-
}
|
|
2680
539
|
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
540
|
+
const startResult = runPackageAsset('branchStart', [
|
|
541
|
+
'--task',
|
|
542
|
+
taskName,
|
|
543
|
+
'--agent',
|
|
544
|
+
SHORT_TOOL_NAME,
|
|
545
|
+
'--base',
|
|
546
|
+
blocked.branch,
|
|
547
|
+
], { cwd: blocked.repoRoot });
|
|
548
|
+
if (isSpawnFailure(startResult)) {
|
|
549
|
+
throw startResult.error;
|
|
2685
550
|
}
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
throw new Error('Detached HEAD is not supported for sync operations');
|
|
551
|
+
if (startResult.status !== 0) {
|
|
552
|
+
return startProtectedBaseSandboxFallback(blocked, sandboxSuffix);
|
|
2689
553
|
}
|
|
2690
|
-
return branch;
|
|
2691
|
-
}
|
|
2692
554
|
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
555
|
+
const metadata = extractAgentBranchStartMetadata(startResult.stdout);
|
|
556
|
+
const currentBranch = currentBranchName(blocked.repoRoot);
|
|
557
|
+
const worktreePath = metadata.worktreePath ? path.resolve(metadata.worktreePath) : '';
|
|
558
|
+
const repoRootPath = path.resolve(blocked.repoRoot);
|
|
559
|
+
const hasSafeWorktree = Boolean(worktreePath) && worktreePath !== repoRootPath;
|
|
560
|
+
const branchChanged = Boolean(currentBranch) && currentBranch !== blocked.branch;
|
|
2696
561
|
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
562
|
+
if (!hasSafeWorktree || branchChanged) {
|
|
563
|
+
const restoreResult = ensureRepoBranch(blocked.repoRoot, blocked.branch);
|
|
564
|
+
if (!restoreResult.ok) {
|
|
565
|
+
const detail = [restoreResult.stderr, restoreResult.stdout].filter(Boolean).join('\n').trim();
|
|
566
|
+
throw new Error(
|
|
567
|
+
`sandbox startup switched protected base checkout and could not restore '${blocked.branch}'.` +
|
|
568
|
+
(detail ? `\n${detail}` : ''),
|
|
569
|
+
);
|
|
2703
570
|
}
|
|
2704
|
-
return
|
|
2705
|
-
}
|
|
2706
|
-
|
|
2707
|
-
const detached = gitRun(repoRoot, ['rev-parse', '--short', 'HEAD'], { allowFailure: true });
|
|
2708
|
-
if (detached.status === 0) {
|
|
2709
|
-
return `(detached at ${String(detached.stdout || '').trim()})`;
|
|
571
|
+
return startProtectedBaseSandboxFallback(blocked, sandboxSuffix);
|
|
2710
572
|
}
|
|
2711
|
-
return '(unknown)';
|
|
2712
|
-
}
|
|
2713
573
|
|
|
2714
|
-
|
|
2715
|
-
|
|
574
|
+
return {
|
|
575
|
+
metadata,
|
|
576
|
+
stdout: startResult.stdout || '',
|
|
577
|
+
stderr: startResult.stderr || '',
|
|
578
|
+
};
|
|
2716
579
|
}
|
|
2717
580
|
|
|
2718
|
-
function
|
|
2719
|
-
|
|
2720
|
-
|
|
581
|
+
function cleanupProtectedBaseSandbox(repoRoot, metadata) {
|
|
582
|
+
const result = {
|
|
583
|
+
worktree: 'skipped',
|
|
584
|
+
branch: 'skipped',
|
|
585
|
+
note: 'missing sandbox metadata',
|
|
586
|
+
};
|
|
2721
587
|
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
const hasHeadCommit = repoHasHeadCommit(repoRoot);
|
|
2725
|
-
const hasOrigin = repoHasOriginRemote(repoRoot);
|
|
2726
|
-
const composeFiles = detectComposeHintFiles(repoRoot);
|
|
2727
|
-
if (hasHeadCommit && hasOrigin && composeFiles.length === 0) {
|
|
2728
|
-
return;
|
|
588
|
+
if (!metadata?.worktreePath || !metadata?.branch) {
|
|
589
|
+
return result;
|
|
2729
590
|
}
|
|
2730
591
|
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
`[${TOOL_NAME}] First agent flow${label}: ` +
|
|
2737
|
-
`gx branch start "<task>" "codex" -> ` +
|
|
2738
|
-
`gx locks claim --branch "$(git branch --show-current)" <file...> -> ` +
|
|
2739
|
-
`gx branch finish --branch "$(git branch --show-current)" --base ${baseBranch} --via-pr --wait-for-merge`,
|
|
592
|
+
if (fs.existsSync(metadata.worktreePath)) {
|
|
593
|
+
const removeResult = run(
|
|
594
|
+
'git',
|
|
595
|
+
['-C', repoRoot, 'worktree', 'remove', '--force', metadata.worktreePath],
|
|
596
|
+
{ timeout: 30_000 },
|
|
2740
597
|
);
|
|
598
|
+
if (isSpawnFailure(removeResult)) {
|
|
599
|
+
throw removeResult.error;
|
|
600
|
+
}
|
|
601
|
+
if (removeResult.status !== 0) {
|
|
602
|
+
throw new Error(
|
|
603
|
+
(removeResult.stderr || removeResult.stdout || 'failed to remove sandbox worktree').trim(),
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
result.worktree = 'removed';
|
|
607
|
+
} else {
|
|
608
|
+
result.worktree = 'missing';
|
|
2741
609
|
}
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
`Set GUARDEX_DOCKER_SERVICE and run 'bash scripts/guardex-docker-loader.sh -- <command...>'.`,
|
|
610
|
+
|
|
611
|
+
if (gitRefExists(repoRoot, `refs/heads/${metadata.branch}`)) {
|
|
612
|
+
const branchDeleteResult = run(
|
|
613
|
+
'git',
|
|
614
|
+
['-C', repoRoot, 'branch', '-D', metadata.branch],
|
|
615
|
+
{ timeout: 20_000 },
|
|
2749
616
|
);
|
|
617
|
+
if (isSpawnFailure(branchDeleteResult)) {
|
|
618
|
+
throw branchDeleteResult.error;
|
|
619
|
+
}
|
|
620
|
+
if (branchDeleteResult.status !== 0) {
|
|
621
|
+
throw new Error(
|
|
622
|
+
(branchDeleteResult.stderr || branchDeleteResult.stdout || 'failed to delete sandbox branch').trim(),
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
result.branch = 'deleted';
|
|
626
|
+
} else {
|
|
627
|
+
result.branch = 'missing';
|
|
2750
628
|
}
|
|
629
|
+
|
|
630
|
+
result.note = 'sandbox worktree pruned';
|
|
631
|
+
return result;
|
|
2751
632
|
}
|
|
2752
633
|
|
|
2753
|
-
function
|
|
2754
|
-
const
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
}
|
|
2758
|
-
const lines = (result.stdout || '').split('\n').filter((line) => line.length > 0);
|
|
2759
|
-
const significant = lines.filter((line) => {
|
|
2760
|
-
const pathPart = (line.length > 3 ? line.slice(3) : '').trim();
|
|
2761
|
-
if (!pathPart) return false;
|
|
2762
|
-
if (pathPart === LOCK_FILE_RELATIVE) return false;
|
|
2763
|
-
if (pathPart.startsWith(`${LOCK_FILE_RELATIVE} -> `)) return false;
|
|
2764
|
-
if (pathPart.endsWith(` -> ${LOCK_FILE_RELATIVE}`)) return false;
|
|
2765
|
-
return true;
|
|
634
|
+
function runSetupInSandbox(options, blocked, repoLabel = '') {
|
|
635
|
+
const startResult = startProtectedBaseSandbox(blocked, {
|
|
636
|
+
taskName: `${SHORT_TOOL_NAME}-setup`,
|
|
637
|
+
sandboxSuffix: 'gx-setup',
|
|
2766
638
|
});
|
|
2767
|
-
|
|
2768
|
-
|
|
639
|
+
const metadata = startResult.metadata;
|
|
640
|
+
|
|
641
|
+
if (startResult.stdout) process.stdout.write(startResult.stdout);
|
|
642
|
+
if (startResult.stderr) process.stderr.write(startResult.stderr);
|
|
643
|
+
console.log(
|
|
644
|
+
`[${TOOL_NAME}] setup blocked on protected branch '${blocked.branch}' in an initialized repo; ` +
|
|
645
|
+
'refreshing through a sandbox worktree and syncing managed bootstrap files back locally.',
|
|
646
|
+
);
|
|
2769
647
|
|
|
2770
|
-
|
|
2771
|
-
const
|
|
2772
|
-
|
|
648
|
+
const sandboxTarget = resolveSandboxTarget(blocked.repoRoot, metadata.worktreePath, options.target);
|
|
649
|
+
const nestedResult = run(
|
|
650
|
+
process.execPath,
|
|
651
|
+
[__filename, ...buildSandboxSetupArgs(options, sandboxTarget)],
|
|
652
|
+
{ cwd: metadata.worktreePath },
|
|
653
|
+
);
|
|
654
|
+
if (isSpawnFailure(nestedResult)) {
|
|
655
|
+
throw nestedResult.error;
|
|
656
|
+
}
|
|
657
|
+
if (nestedResult.status !== 0) {
|
|
658
|
+
if (nestedResult.stdout) process.stdout.write(nestedResult.stdout);
|
|
659
|
+
if (nestedResult.stderr) process.stderr.write(nestedResult.stderr);
|
|
2773
660
|
throw new Error(
|
|
2774
|
-
`
|
|
661
|
+
`sandboxed setup failed for protected branch '${blocked.branch}'. ` +
|
|
662
|
+
`Inspect sandbox at ${metadata.worktreePath}`,
|
|
2775
663
|
);
|
|
2776
664
|
}
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
665
|
+
|
|
666
|
+
const syncOptions = {
|
|
667
|
+
...options,
|
|
668
|
+
target: blocked.repoRoot,
|
|
669
|
+
recursive: false,
|
|
670
|
+
allowProtectedBaseWrite: true,
|
|
671
|
+
};
|
|
672
|
+
const { installPayload, fixPayload, parentWorkspace } = runSetupBootstrapInternal(syncOptions);
|
|
673
|
+
printOperations(`Setup/install${repoLabel}`, installPayload, syncOptions.dryRun);
|
|
674
|
+
printOperations(`Setup/fix${repoLabel}`, fixPayload, syncOptions.dryRun);
|
|
675
|
+
if (!syncOptions.dryRun && parentWorkspace) {
|
|
676
|
+
console.log(`[${TOOL_NAME}] Parent workspace view: ${parentWorkspace.workspacePath}`);
|
|
2782
677
|
}
|
|
2783
|
-
}
|
|
2784
678
|
|
|
2785
|
-
|
|
2786
|
-
const
|
|
2787
|
-
|
|
679
|
+
const scanResult = runScanInternal({ target: blocked.repoRoot, json: false });
|
|
680
|
+
const currentBaseBranch = currentBranchName(scanResult.repoRoot);
|
|
681
|
+
const autoFinishSummary = doctorModule.autoFinishReadyAgentBranches(scanResult.repoRoot, {
|
|
682
|
+
baseBranch: currentBaseBranch,
|
|
683
|
+
dryRun: syncOptions.dryRun,
|
|
2788
684
|
});
|
|
2789
|
-
|
|
2790
|
-
|
|
685
|
+
printScanResult(scanResult, false);
|
|
686
|
+
if (autoFinishSummary.enabled) {
|
|
687
|
+
console.log(
|
|
688
|
+
`[${TOOL_NAME}] Auto-finish sweep (base=${currentBaseBranch}): attempted=${autoFinishSummary.attempted}, completed=${autoFinishSummary.completed}, skipped=${autoFinishSummary.skipped}, failed=${autoFinishSummary.failed}`,
|
|
689
|
+
);
|
|
690
|
+
for (const detail of autoFinishSummary.details) {
|
|
691
|
+
console.log(`[${TOOL_NAME}] ${detail}`);
|
|
692
|
+
}
|
|
693
|
+
} else if (autoFinishSummary.details.length > 0) {
|
|
694
|
+
console.log(`[${TOOL_NAME}] ${autoFinishSummary.details[0]}`);
|
|
2791
695
|
}
|
|
2792
|
-
const parts = (result.stdout || '').trim().split(/\s+/).filter(Boolean);
|
|
2793
|
-
const ahead = Number.parseInt(parts[0] || '0', 10);
|
|
2794
|
-
const behind = Number.parseInt(parts[1] || '0', 10);
|
|
2795
|
-
return { ahead: Number.isFinite(ahead) ? ahead : 0, behind: Number.isFinite(behind) ? behind : 0 };
|
|
2796
|
-
}
|
|
2797
696
|
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
}
|
|
2807
|
-
const untracked = lines.some((line) => line.startsWith('??'));
|
|
2808
|
-
return { dirty: true, untracked };
|
|
697
|
+
const cleanupResult = cleanupProtectedBaseSandbox(blocked.repoRoot, metadata);
|
|
698
|
+
console.log(
|
|
699
|
+
`[${TOOL_NAME}] Protected-base setup sandbox cleanup: ${cleanupResult.note} ` +
|
|
700
|
+
`(worktree=${cleanupResult.worktree}, branch=${cleanupResult.branch}).`,
|
|
701
|
+
);
|
|
702
|
+
|
|
703
|
+
return {
|
|
704
|
+
scanResult,
|
|
705
|
+
};
|
|
2809
706
|
}
|
|
2810
707
|
|
|
2811
708
|
|
|
2812
|
-
function
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
throw new Error('Unable to list git worktrees for finish command');
|
|
2816
|
-
}
|
|
709
|
+
function todayDateStamp() {
|
|
710
|
+
return new Date().toISOString().slice(0, 10);
|
|
711
|
+
}
|
|
2817
712
|
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
const lines = String(result.stdout || '').split('\n');
|
|
2822
|
-
for (const line of lines) {
|
|
2823
|
-
if (!line.trim()) {
|
|
2824
|
-
if (currentPath && currentBranchRef.startsWith('refs/heads/agent/')) {
|
|
2825
|
-
entries.push({
|
|
2826
|
-
worktreePath: currentPath,
|
|
2827
|
-
branch: currentBranchRef.replace(/^refs\/heads\//, ''),
|
|
2828
|
-
});
|
|
2829
|
-
}
|
|
2830
|
-
currentPath = '';
|
|
2831
|
-
currentBranchRef = '';
|
|
2832
|
-
continue;
|
|
2833
|
-
}
|
|
2834
|
-
if (line.startsWith('worktree ')) {
|
|
2835
|
-
currentPath = line.slice('worktree '.length).trim();
|
|
2836
|
-
continue;
|
|
2837
|
-
}
|
|
2838
|
-
if (line.startsWith('branch ')) {
|
|
2839
|
-
currentBranchRef = line.slice('branch '.length).trim();
|
|
2840
|
-
continue;
|
|
2841
|
-
}
|
|
2842
|
-
}
|
|
2843
|
-
if (currentPath && currentBranchRef.startsWith('refs/heads/agent/')) {
|
|
2844
|
-
entries.push({
|
|
2845
|
-
worktreePath: currentPath,
|
|
2846
|
-
branch: currentBranchRef.replace(/^refs\/heads\//, ''),
|
|
2847
|
-
});
|
|
2848
|
-
}
|
|
713
|
+
function inferGithubRepoFromOrigin(repoRoot) {
|
|
714
|
+
const rawOrigin = readGitConfig(repoRoot, 'remote.origin.url');
|
|
715
|
+
if (!rawOrigin) return '';
|
|
2849
716
|
|
|
2850
|
-
|
|
717
|
+
const httpsMatch = rawOrigin.match(/github\.com[:/](.+?)(?:\.git)?$/i);
|
|
718
|
+
if (!httpsMatch) return '';
|
|
719
|
+
const slug = (httpsMatch[1] || '').replace(/^\/+/, '').trim();
|
|
720
|
+
if (!slug || !slug.includes('/')) return '';
|
|
721
|
+
return `github.com/${slug}`;
|
|
2851
722
|
}
|
|
2852
723
|
|
|
2853
|
-
function
|
|
2854
|
-
const
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
)
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
return
|
|
2863
|
-
|
|
2864
|
-
.split('\n')
|
|
2865
|
-
.map((line) => line.trim())
|
|
2866
|
-
.filter((line) => line.startsWith('agent/')),
|
|
2867
|
-
);
|
|
724
|
+
function inferGithubRepoSlug(rawValue) {
|
|
725
|
+
const raw = String(rawValue || '').trim();
|
|
726
|
+
if (!raw) return '';
|
|
727
|
+
const match = raw.match(/github\.com[:/](.+?)(?:\.git)?$/i);
|
|
728
|
+
if (!match) return '';
|
|
729
|
+
const slug = String(match[1] || '')
|
|
730
|
+
.replace(/^\/+/, '')
|
|
731
|
+
.replace(/^github\.com\//i, '')
|
|
732
|
+
.trim();
|
|
733
|
+
if (!slug || !slug.includes('/')) return '';
|
|
734
|
+
return slug;
|
|
2868
735
|
}
|
|
2869
736
|
|
|
2870
|
-
function
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
return false;
|
|
2874
|
-
}
|
|
2875
|
-
if (result.status === 1) {
|
|
2876
|
-
return true;
|
|
737
|
+
function resolveScorecardRepo(repoRoot, explicitRepo) {
|
|
738
|
+
if (explicitRepo) {
|
|
739
|
+
return explicitRepo.trim();
|
|
2877
740
|
}
|
|
741
|
+
const inferred = inferGithubRepoFromOrigin(repoRoot);
|
|
742
|
+
if (inferred) return inferred;
|
|
2878
743
|
throw new Error(
|
|
2879
|
-
|
|
2880
|
-
result.stderr || result.stdout || ''
|
|
2881
|
-
).trim()}`,
|
|
744
|
+
'Unable to infer GitHub repo from origin remote. Pass --repo github.com/<owner>/<repo>.',
|
|
2882
745
|
);
|
|
2883
746
|
}
|
|
2884
747
|
|
|
2885
|
-
function
|
|
2886
|
-
const
|
|
2887
|
-
'diff',
|
|
2888
|
-
'--quiet',
|
|
2889
|
-
'--',
|
|
2890
|
-
'.',
|
|
2891
|
-
':(exclude).omx/state/agent-file-locks.json',
|
|
2892
|
-
]);
|
|
2893
|
-
if (hasUnstaged) {
|
|
2894
|
-
return true;
|
|
2895
|
-
}
|
|
2896
|
-
|
|
2897
|
-
const hasStaged = gitQuietChangeResult(worktreePath, [
|
|
2898
|
-
'diff',
|
|
2899
|
-
'--cached',
|
|
2900
|
-
'--quiet',
|
|
2901
|
-
'--',
|
|
2902
|
-
'.',
|
|
2903
|
-
':(exclude).omx/state/agent-file-locks.json',
|
|
2904
|
-
]);
|
|
2905
|
-
if (hasStaged) {
|
|
2906
|
-
return true;
|
|
2907
|
-
}
|
|
2908
|
-
|
|
2909
|
-
const untracked = run('git', ['-C', worktreePath, 'ls-files', '--others', '--exclude-standard'], {
|
|
2910
|
-
stdio: 'pipe',
|
|
2911
|
-
});
|
|
2912
|
-
if (untracked.status !== 0) {
|
|
2913
|
-
throw new Error(`Unable to inspect untracked files in ${worktreePath}`);
|
|
2914
|
-
}
|
|
2915
|
-
return String(untracked.stdout || '').trim().length > 0;
|
|
2916
|
-
}
|
|
2917
|
-
|
|
2918
|
-
function gitOutputLines(worktreePath, args) {
|
|
2919
|
-
const result = run('git', ['-C', worktreePath, ...args], { stdio: 'pipe' });
|
|
748
|
+
function runScorecardJson(repo) {
|
|
749
|
+
const result = run(SCORECARD_BIN, ['--repo', repo, '--format', 'json'], { allowFailure: true });
|
|
2920
750
|
if (result.status !== 0) {
|
|
751
|
+
const details = (result.stderr || result.stdout || '').trim();
|
|
2921
752
|
throw new Error(
|
|
2922
|
-
`
|
|
2923
|
-
result.stderr || result.stdout || ''
|
|
2924
|
-
).trim()}`,
|
|
753
|
+
`Failed to run scorecard CLI ('${SCORECARD_BIN} --repo ${repo} --format json').${details ? `\n${details}` : ''}`,
|
|
2925
754
|
);
|
|
2926
755
|
}
|
|
2927
|
-
return String(result.stdout || '')
|
|
2928
|
-
.split('\n')
|
|
2929
|
-
.map((line) => line.trim())
|
|
2930
|
-
.filter(Boolean);
|
|
2931
|
-
}
|
|
2932
|
-
|
|
2933
|
-
function claimLocksForAutoCommit(repoRoot, worktreePath, branch) {
|
|
2934
|
-
const changedFiles = uniquePreserveOrder([
|
|
2935
|
-
...gitOutputLines(worktreePath, ['diff', '--name-only', '--', '.', ':(exclude).omx/state/agent-file-locks.json']),
|
|
2936
|
-
...gitOutputLines(worktreePath, ['diff', '--cached', '--name-only', '--', '.', ':(exclude).omx/state/agent-file-locks.json']),
|
|
2937
|
-
...gitOutputLines(worktreePath, ['ls-files', '--others', '--exclude-standard']),
|
|
2938
|
-
]);
|
|
2939
|
-
|
|
2940
|
-
if (changedFiles.length > 0) {
|
|
2941
|
-
const claim = runPackageAsset('lockTool', ['claim', '--branch', branch, ...changedFiles], {
|
|
2942
|
-
cwd: repoRoot,
|
|
2943
|
-
stdio: 'pipe',
|
|
2944
|
-
});
|
|
2945
|
-
if (claim.status !== 0) {
|
|
2946
|
-
throw new Error(
|
|
2947
|
-
`Lock claim failed for ${branch}: ${(
|
|
2948
|
-
claim.stderr || claim.stdout || ''
|
|
2949
|
-
).trim()}`,
|
|
2950
|
-
);
|
|
2951
|
-
}
|
|
2952
|
-
}
|
|
2953
756
|
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
'--diff-filter=D',
|
|
2959
|
-
'--',
|
|
2960
|
-
'.',
|
|
2961
|
-
':(exclude).omx/state/agent-file-locks.json',
|
|
2962
|
-
]),
|
|
2963
|
-
...gitOutputLines(worktreePath, [
|
|
2964
|
-
'diff',
|
|
2965
|
-
'--cached',
|
|
2966
|
-
'--name-only',
|
|
2967
|
-
'--diff-filter=D',
|
|
2968
|
-
'--',
|
|
2969
|
-
'.',
|
|
2970
|
-
':(exclude).omx/state/agent-file-locks.json',
|
|
2971
|
-
]),
|
|
2972
|
-
]);
|
|
2973
|
-
|
|
2974
|
-
if (deletedFiles.length > 0) {
|
|
2975
|
-
const allowDelete = runPackageAsset('lockTool', ['allow-delete', '--branch', branch, ...deletedFiles], {
|
|
2976
|
-
cwd: repoRoot,
|
|
2977
|
-
stdio: 'pipe',
|
|
2978
|
-
});
|
|
2979
|
-
if (allowDelete.status !== 0) {
|
|
2980
|
-
throw new Error(
|
|
2981
|
-
`Delete-lock grant failed for ${branch}: ${(
|
|
2982
|
-
allowDelete.stderr || allowDelete.stdout || ''
|
|
2983
|
-
).trim()}`,
|
|
2984
|
-
);
|
|
2985
|
-
}
|
|
757
|
+
try {
|
|
758
|
+
return JSON.parse(result.stdout || '{}');
|
|
759
|
+
} catch (error) {
|
|
760
|
+
throw new Error(`Unable to parse scorecard JSON output: ${error.message}`);
|
|
2986
761
|
}
|
|
2987
762
|
}
|
|
2988
763
|
|
|
2989
|
-
function
|
|
2990
|
-
const
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
return result.status === 0;
|
|
2994
|
-
}
|
|
2995
|
-
|
|
2996
|
-
function resolveFinishBaseBranch(repoRoot, _sourceBranch, explicitBase) {
|
|
2997
|
-
if (explicitBase) {
|
|
2998
|
-
return explicitBase;
|
|
764
|
+
function readScorecardJsonFile(filePath) {
|
|
765
|
+
const absolute = path.resolve(filePath);
|
|
766
|
+
if (!fs.existsSync(absolute)) {
|
|
767
|
+
throw new Error(`scorecard JSON file not found: ${absolute}`);
|
|
2999
768
|
}
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
3003
|
-
|
|
769
|
+
try {
|
|
770
|
+
return JSON.parse(fs.readFileSync(absolute, 'utf8'));
|
|
771
|
+
} catch (error) {
|
|
772
|
+
throw new Error(`Unable to parse scorecard JSON file: ${error.message}`);
|
|
3004
773
|
}
|
|
3005
|
-
|
|
3006
|
-
return DEFAULT_BASE_BRANCH;
|
|
3007
774
|
}
|
|
3008
775
|
|
|
3009
|
-
function
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
776
|
+
function normalizeScorecardChecks(payload) {
|
|
777
|
+
const rawChecks = Array.isArray(payload?.checks) ? payload.checks : [];
|
|
778
|
+
return rawChecks.map((check) => {
|
|
779
|
+
const name = String(check?.name || 'Unknown');
|
|
780
|
+
const rawScore = Number(check?.score);
|
|
781
|
+
const score = Number.isFinite(rawScore) ? rawScore : 0;
|
|
782
|
+
return {
|
|
783
|
+
name,
|
|
784
|
+
score,
|
|
785
|
+
risk: SCORECARD_RISK_BY_CHECK[name] || 'Unknown',
|
|
786
|
+
};
|
|
3015
787
|
});
|
|
3016
|
-
if (result.status === 0) {
|
|
3017
|
-
return true;
|
|
3018
|
-
}
|
|
3019
|
-
if (result.status === 1) {
|
|
3020
|
-
return false;
|
|
3021
|
-
}
|
|
3022
|
-
throw new Error(`Unable to determine merge status for ${branch} -> ${baseBranch}`);
|
|
3023
788
|
}
|
|
3024
789
|
|
|
3025
|
-
function
|
|
3026
|
-
const
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
}
|
|
3030
|
-
|
|
3031
|
-
if (options.noAutoCommit) {
|
|
3032
|
-
throw new Error(
|
|
3033
|
-
`Branch '${branch}' has local changes in ${worktreePath}. Re-run without --no-auto-commit or commit manually first.`,
|
|
3034
|
-
);
|
|
3035
|
-
}
|
|
3036
|
-
|
|
3037
|
-
if (options.dryRun) {
|
|
3038
|
-
return { changed: true, committed: false, dryRun: true };
|
|
3039
|
-
}
|
|
3040
|
-
|
|
3041
|
-
claimLocksForAutoCommit(repoRoot, worktreePath, branch);
|
|
3042
|
-
|
|
3043
|
-
const addResult = run('git', ['-C', worktreePath, 'add', '-A'], { stdio: 'pipe' });
|
|
3044
|
-
if (addResult.status !== 0) {
|
|
3045
|
-
throw new Error(`git add failed in ${worktreePath}: ${(addResult.stderr || addResult.stdout || '').trim()}`);
|
|
3046
|
-
}
|
|
790
|
+
function renderScorecardBaselineMarkdown({ repo, score, checks, capturedAt, scorecardVersion, reportDate }) {
|
|
791
|
+
const rows = checks
|
|
792
|
+
.map((item) => `| ${item.name} | ${item.score} | ${item.risk} |`)
|
|
793
|
+
.join('\n');
|
|
3047
794
|
|
|
3048
|
-
|
|
3049
|
-
'
|
|
3050
|
-
'
|
|
3051
|
-
|
|
3052
|
-
'
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
795
|
+
return [
|
|
796
|
+
'# OpenSSF Scorecard Baseline Report',
|
|
797
|
+
'',
|
|
798
|
+
`- **Repository:** \`${repo}\``,
|
|
799
|
+
'- **Source:** generated by `gx report scorecard`',
|
|
800
|
+
`- **Captured at:** ${capturedAt}`,
|
|
801
|
+
`- **Scorecard version:** \`${scorecardVersion}\``,
|
|
802
|
+
`- **Overall score:** **${score} / 10**`,
|
|
803
|
+
'',
|
|
804
|
+
'## Check breakdown',
|
|
805
|
+
'',
|
|
806
|
+
'| Check | Score | Risk |',
|
|
807
|
+
'|---|---:|---|',
|
|
808
|
+
rows || '| (none) | 0 | Unknown |',
|
|
809
|
+
'',
|
|
810
|
+
`## Report date`,
|
|
811
|
+
'',
|
|
812
|
+
`- ${reportDate}`,
|
|
813
|
+
'',
|
|
814
|
+
].join('\n');
|
|
815
|
+
}
|
|
3059
816
|
|
|
3060
|
-
|
|
3061
|
-
const
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
).trim()}`,
|
|
3067
|
-
);
|
|
3068
|
-
}
|
|
817
|
+
function renderScorecardRemediationPlanMarkdown({ baselineRelativePath, checks }) {
|
|
818
|
+
const failing = checks.filter((item) => item.score < 10);
|
|
819
|
+
const failingRows = failing
|
|
820
|
+
.sort((a, b) => a.score - b.score || a.name.localeCompare(b.name))
|
|
821
|
+
.map((item) => `| ${item.name} | ${item.score} | ${item.risk} |`)
|
|
822
|
+
.join('\n');
|
|
3069
823
|
|
|
3070
|
-
return
|
|
824
|
+
return [
|
|
825
|
+
'# OpenSSF Scorecard Remediation Plan',
|
|
826
|
+
'',
|
|
827
|
+
`Based on baseline report: \`${baselineRelativePath}\`.`,
|
|
828
|
+
'',
|
|
829
|
+
'## Failing checks',
|
|
830
|
+
'',
|
|
831
|
+
'| Check | Score | Risk |',
|
|
832
|
+
'|---|---:|---|',
|
|
833
|
+
(failingRows || '| None | 10 | N/A |'),
|
|
834
|
+
'',
|
|
835
|
+
'## Priority order',
|
|
836
|
+
'',
|
|
837
|
+
'1. Fix **High** risk checks first (especially score 0 items).',
|
|
838
|
+
'2. Then close **Medium** risk checks with score < 10.',
|
|
839
|
+
'3. Finally address **Low** risk ecosystem/process checks.',
|
|
840
|
+
'',
|
|
841
|
+
'## Verification loop',
|
|
842
|
+
'',
|
|
843
|
+
'1. Run scorecard again.',
|
|
844
|
+
'2. Re-generate baseline + remediation files.',
|
|
845
|
+
'3. Compare score deltas and track improved checks.',
|
|
846
|
+
'',
|
|
847
|
+
].join('\n');
|
|
3071
848
|
}
|
|
3072
849
|
|
|
3073
|
-
function
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
if (rebased.status !== 0) {
|
|
3080
|
-
const details = (rebased.stderr || rebased.stdout || '').trim();
|
|
3081
|
-
const gitDir = path.join(repoRoot, '.git');
|
|
3082
|
-
const rebaseActive = fs.existsSync(path.join(gitDir, 'rebase-merge')) || fs.existsSync(path.join(gitDir, 'rebase-apply'));
|
|
3083
|
-
const help = rebaseActive
|
|
3084
|
-
? '\nResolve conflicts, then run: git rebase --continue\nOr abort: git rebase --abort'
|
|
3085
|
-
: '';
|
|
3086
|
-
throw new Error(`Sync failed during rebase onto ${baseRef}.${details ? `\n${details}` : ''}${help}`);
|
|
3087
|
-
}
|
|
3088
|
-
return;
|
|
3089
|
-
}
|
|
850
|
+
function parseBranchList(rawValue) {
|
|
851
|
+
return String(rawValue || '')
|
|
852
|
+
.split(/[\s,]+/)
|
|
853
|
+
.map((item) => item.trim())
|
|
854
|
+
.filter(Boolean);
|
|
855
|
+
}
|
|
3090
856
|
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
|
|
3095
|
-
mergeArgs.push(baseRef);
|
|
3096
|
-
const merged = run('git', mergeArgs, { stdio: 'pipe' });
|
|
3097
|
-
if (merged.status !== 0) {
|
|
3098
|
-
const details = (merged.stderr || merged.stdout || '').trim();
|
|
3099
|
-
const gitDir = path.join(repoRoot, '.git');
|
|
3100
|
-
const mergeActive = fs.existsSync(path.join(gitDir, 'MERGE_HEAD'));
|
|
3101
|
-
const help = mergeActive ? '\nResolve conflicts, then run: git commit\nOr abort: git merge --abort' : '';
|
|
3102
|
-
throw new Error(`Sync failed during merge from ${baseRef}.${details ? `\n${details}` : ''}${help}`);
|
|
857
|
+
function originRemoteLooksLikeGithub(repoRoot) {
|
|
858
|
+
const originUrl = readGitConfig(repoRoot, 'remote.origin.url');
|
|
859
|
+
if (!originUrl) {
|
|
860
|
+
return false;
|
|
3103
861
|
}
|
|
862
|
+
return /github\.com[:/]/i.test(originUrl);
|
|
3104
863
|
}
|
|
3105
864
|
|
|
3106
865
|
function isInteractiveTerminal() {
|
|
@@ -3308,7 +1067,7 @@ function printUpdateAvailableBanner(current, latest) {
|
|
|
3308
1067
|
}
|
|
3309
1068
|
|
|
3310
1069
|
function maybeSelfUpdateBeforeStatus() {
|
|
3311
|
-
return
|
|
1070
|
+
return toolchainModule.maybeSelfUpdateBeforeStatus();
|
|
3312
1071
|
}
|
|
3313
1072
|
|
|
3314
1073
|
function readInstalledGuardexVersion() {
|
|
@@ -3443,7 +1202,7 @@ function printOpenSpecUpdateAvailableBanner(current, latest) {
|
|
|
3443
1202
|
}
|
|
3444
1203
|
|
|
3445
1204
|
function maybeOpenSpecUpdateBeforeStatus() {
|
|
3446
|
-
return
|
|
1205
|
+
return toolchainModule.maybeOpenSpecUpdateBeforeStatus();
|
|
3447
1206
|
}
|
|
3448
1207
|
|
|
3449
1208
|
function promptYesNoStrict(question) {
|
|
@@ -3622,7 +1381,7 @@ function askGlobalInstallForMissing(options, missingPackages, missingLocalTools)
|
|
|
3622
1381
|
}
|
|
3623
1382
|
|
|
3624
1383
|
function installGlobalToolchain(options) {
|
|
3625
|
-
return
|
|
1384
|
+
return toolchainModule.installGlobalToolchain(options);
|
|
3626
1385
|
}
|
|
3627
1386
|
|
|
3628
1387
|
function findStaleLockPaths(repoRoot, locks) {
|
|
@@ -3982,9 +1741,9 @@ function status(rawArgs) {
|
|
|
3982
1741
|
json: false,
|
|
3983
1742
|
});
|
|
3984
1743
|
|
|
3985
|
-
const toolchain = detectGlobalToolchainPackages();
|
|
1744
|
+
const toolchain = toolchainModule.detectGlobalToolchainPackages();
|
|
3986
1745
|
const npmServices = GLOBAL_TOOLCHAIN_PACKAGES.map((pkg) => {
|
|
3987
|
-
const service = getGlobalToolchainService(pkg);
|
|
1746
|
+
const service = toolchainModule.getGlobalToolchainService(pkg);
|
|
3988
1747
|
if (!toolchain.ok) {
|
|
3989
1748
|
return {
|
|
3990
1749
|
name: service.name,
|
|
@@ -4002,12 +1761,12 @@ function status(rawArgs) {
|
|
|
4002
1761
|
status: toolchain.installed.includes(pkg) ? 'active' : 'inactive',
|
|
4003
1762
|
};
|
|
4004
1763
|
});
|
|
4005
|
-
const localCompanionServices = detectOptionalLocalCompanionTools().map((tool) => ({
|
|
1764
|
+
const localCompanionServices = toolchainModule.detectOptionalLocalCompanionTools().map((tool) => ({
|
|
4006
1765
|
name: tool.name,
|
|
4007
1766
|
displayName: tool.displayName || tool.name,
|
|
4008
1767
|
status: tool.status,
|
|
4009
1768
|
}));
|
|
4010
|
-
const requiredSystemTools = detectRequiredSystemTools();
|
|
1769
|
+
const requiredSystemTools = toolchainModule.detectRequiredSystemTools();
|
|
4011
1770
|
const services = [
|
|
4012
1771
|
...npmServices,
|
|
4013
1772
|
...localCompanionServices,
|
|
@@ -4076,7 +1835,7 @@ function status(rawArgs) {
|
|
|
4076
1835
|
console.log(
|
|
4077
1836
|
`[${TOOL_NAME}] Optional companion tools inactive: ${inactiveOptionalCompanions.join(', ')}`,
|
|
4078
1837
|
);
|
|
4079
|
-
for (const warning of describeMissingGlobalDependencyWarnings(
|
|
1838
|
+
for (const warning of toolchainModule.describeMissingGlobalDependencyWarnings(
|
|
4080
1839
|
npmServices
|
|
4081
1840
|
.filter((service) => service.status === 'inactive')
|
|
4082
1841
|
.map((service) => service.packageName),
|
|
@@ -4333,7 +2092,13 @@ function doctor(rawArgs) {
|
|
|
4333
2092
|
|
|
4334
2093
|
const blocked = protectedBaseWriteBlock(singleRepoOptions, { requireBootstrap: false });
|
|
4335
2094
|
if (blocked) {
|
|
4336
|
-
runDoctorInSandbox(singleRepoOptions, blocked
|
|
2095
|
+
doctorModule.runDoctorInSandbox(singleRepoOptions, blocked, {
|
|
2096
|
+
startProtectedBaseSandbox,
|
|
2097
|
+
cleanupProtectedBaseSandbox,
|
|
2098
|
+
ensureOmxScaffold,
|
|
2099
|
+
configureHooks,
|
|
2100
|
+
autoFinishReadyAgentBranches: doctorModule.autoFinishReadyAgentBranches,
|
|
2101
|
+
});
|
|
4337
2102
|
return;
|
|
4338
2103
|
}
|
|
4339
2104
|
|
|
@@ -4350,7 +2115,7 @@ function doctor(rawArgs) {
|
|
|
4350
2115
|
failed: 0,
|
|
4351
2116
|
details: [],
|
|
4352
2117
|
}
|
|
4353
|
-
: autoFinishReadyAgentBranches(scanResult.repoRoot, {
|
|
2118
|
+
: doctorModule.autoFinishReadyAgentBranches(scanResult.repoRoot, {
|
|
4354
2119
|
baseBranch: currentBaseBranch,
|
|
4355
2120
|
dryRun: singleRepoOptions.dryRun,
|
|
4356
2121
|
waitForMerge: singleRepoOptions.waitForMerge,
|
|
@@ -4806,7 +2571,7 @@ function setup(rawArgs) {
|
|
|
4806
2571
|
allowProtectedBaseWrite: false,
|
|
4807
2572
|
});
|
|
4808
2573
|
|
|
4809
|
-
const globalInstallStatus = installGlobalToolchain(options);
|
|
2574
|
+
const globalInstallStatus = toolchainModule.installGlobalToolchain(options);
|
|
4810
2575
|
if (globalInstallStatus.status === 'installed') {
|
|
4811
2576
|
console.log(
|
|
4812
2577
|
`[${TOOL_NAME}] ✅ Companion tools installed (${(globalInstallStatus.packages || []).join(', ')}).`,
|
|
@@ -4814,7 +2579,7 @@ function setup(rawArgs) {
|
|
|
4814
2579
|
} else if (globalInstallStatus.status === 'already-installed') {
|
|
4815
2580
|
console.log(`[${TOOL_NAME}] ✅ Companion tools already installed. Skipping.`);
|
|
4816
2581
|
} else if (globalInstallStatus.status === 'failed') {
|
|
4817
|
-
const installCommands = describeCompanionInstallCommands(
|
|
2582
|
+
const installCommands = toolchainModule.describeCompanionInstallCommands(
|
|
4818
2583
|
GLOBAL_TOOLCHAIN_PACKAGES,
|
|
4819
2584
|
OPTIONAL_LOCAL_COMPANION_TOOLS,
|
|
4820
2585
|
);
|
|
@@ -4830,13 +2595,13 @@ function setup(rawArgs) {
|
|
|
4830
2595
|
);
|
|
4831
2596
|
} else if (globalInstallStatus.status === 'skipped') {
|
|
4832
2597
|
console.log(`[${TOOL_NAME}] ⚠️ Companion installs skipped by user choice.`);
|
|
4833
|
-
for (const warning of describeMissingGlobalDependencyWarnings(
|
|
2598
|
+
for (const warning of toolchainModule.describeMissingGlobalDependencyWarnings(
|
|
4834
2599
|
globalInstallStatus.missingPackages || [],
|
|
4835
2600
|
)) {
|
|
4836
2601
|
console.log(`[${TOOL_NAME}] ⚠️ ${warning}`);
|
|
4837
2602
|
}
|
|
4838
2603
|
}
|
|
4839
|
-
const requiredSystemTools = detectRequiredSystemTools();
|
|
2604
|
+
const requiredSystemTools = toolchainModule.detectRequiredSystemTools();
|
|
4840
2605
|
const missingSystemTools = requiredSystemTools.filter((tool) => tool.status !== 'active');
|
|
4841
2606
|
if (missingSystemTools.length === 0) {
|
|
4842
2607
|
console.log(`[${TOOL_NAME}] ✅ Required system tools available (${requiredSystemTools.map((tool) => tool.name).join(', ')}).`);
|
|
@@ -4904,7 +2669,7 @@ function setup(rawArgs) {
|
|
|
4904
2669
|
|
|
4905
2670
|
const scanResult = runScanInternal({ target: repoPath, json: false });
|
|
4906
2671
|
const currentBaseBranch = currentBranchName(scanResult.repoRoot);
|
|
4907
|
-
const autoFinishSummary = autoFinishReadyAgentBranches(scanResult.repoRoot, {
|
|
2672
|
+
const autoFinishSummary = doctorModule.autoFinishReadyAgentBranches(scanResult.repoRoot, {
|
|
4908
2673
|
baseBranch: currentBaseBranch,
|
|
4909
2674
|
dryRun: perRepoOptions.dryRun,
|
|
4910
2675
|
});
|
|
@@ -5226,26 +2991,59 @@ function copyCommands() {
|
|
|
5226
2991
|
function prompt(rawArgs) {
|
|
5227
2992
|
const args = Array.isArray(rawArgs) ? rawArgs : [];
|
|
5228
2993
|
let variant = 'prompt';
|
|
5229
|
-
|
|
2994
|
+
let listParts = false;
|
|
2995
|
+
const selectedParts = [];
|
|
2996
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
2997
|
+
const arg = args[index];
|
|
5230
2998
|
if (arg === '--exec' || arg === '--commands') variant = 'exec';
|
|
5231
2999
|
else if (arg === '--snippet' || arg === '--agents') variant = 'snippet';
|
|
5232
3000
|
else if (arg === '--prompt' || arg === '--full') variant = 'prompt';
|
|
3001
|
+
else if (arg === '--list-parts') listParts = true;
|
|
3002
|
+
else if (arg === '--part' || arg === '--parts') {
|
|
3003
|
+
const rawValue = args[index + 1];
|
|
3004
|
+
if (!rawValue || rawValue.startsWith('--')) {
|
|
3005
|
+
throw new Error(`${arg} requires a value`);
|
|
3006
|
+
}
|
|
3007
|
+
selectedParts.push(...parseAiSetupPartNames(rawValue));
|
|
3008
|
+
index += 1;
|
|
3009
|
+
} else if (arg.startsWith('--part=')) {
|
|
3010
|
+
selectedParts.push(...parseAiSetupPartNames(arg.slice('--part='.length)));
|
|
3011
|
+
} else if (arg.startsWith('--parts=')) {
|
|
3012
|
+
selectedParts.push(...parseAiSetupPartNames(arg.slice('--parts='.length)));
|
|
3013
|
+
}
|
|
5233
3014
|
else if (arg === '-h' || arg === '--help') variant = 'help';
|
|
5234
3015
|
else throw new Error(`Unknown option: ${arg}`);
|
|
5235
3016
|
}
|
|
5236
3017
|
if (variant === 'help') {
|
|
5237
3018
|
console.log(
|
|
5238
3019
|
`${SHORT_TOOL_NAME} prompt commands:\n` +
|
|
5239
|
-
` ${SHORT_TOOL_NAME} prompt
|
|
5240
|
-
` ${SHORT_TOOL_NAME} prompt --exec
|
|
5241
|
-
` ${SHORT_TOOL_NAME} prompt --
|
|
3020
|
+
` ${SHORT_TOOL_NAME} prompt Print AI setup checklist\n` +
|
|
3021
|
+
` ${SHORT_TOOL_NAME} prompt --exec Print setup commands only (shell-ready)\n` +
|
|
3022
|
+
` ${SHORT_TOOL_NAME} prompt --part <name> Print only the named checklist slice(s)\n` +
|
|
3023
|
+
` ${SHORT_TOOL_NAME} prompt --exec --part <name> Print only the named exec-capable slice(s)\n` +
|
|
3024
|
+
` ${SHORT_TOOL_NAME} prompt --list-parts List prompt part names\n` +
|
|
3025
|
+
` ${SHORT_TOOL_NAME} prompt --exec --list-parts List exec-capable prompt part names\n` +
|
|
3026
|
+
` ${SHORT_TOOL_NAME} prompt --snippet Print the AGENTS.md managed-block template`,
|
|
5242
3027
|
);
|
|
5243
3028
|
process.exitCode = 0;
|
|
5244
3029
|
return;
|
|
5245
3030
|
}
|
|
5246
|
-
if (variant === '
|
|
5247
|
-
|
|
5248
|
-
|
|
3031
|
+
if (variant === 'snippet') {
|
|
3032
|
+
if (listParts || selectedParts.length > 0) {
|
|
3033
|
+
throw new Error('--snippet does not support --list-parts or --part');
|
|
3034
|
+
}
|
|
3035
|
+
return printAgentsSnippet();
|
|
3036
|
+
}
|
|
3037
|
+
if (listParts) {
|
|
3038
|
+
if (selectedParts.length > 0) {
|
|
3039
|
+
throw new Error('--list-parts does not support --part');
|
|
3040
|
+
}
|
|
3041
|
+
process.stdout.write(`${listAiSetupPartNames({ execOnly: variant === 'exec' }).join('\n')}\n`);
|
|
3042
|
+
process.exitCode = 0;
|
|
3043
|
+
return;
|
|
3044
|
+
}
|
|
3045
|
+
process.stdout.write(renderAiSetupPrompt({ exec: variant === 'exec', parts: selectedParts }));
|
|
3046
|
+
process.exitCode = 0;
|
|
5249
3047
|
}
|
|
5250
3048
|
|
|
5251
3049
|
function branch(rawArgs) {
|
|
@@ -5376,19 +3174,19 @@ function migrate(rawArgs) {
|
|
|
5376
3174
|
}
|
|
5377
3175
|
|
|
5378
3176
|
function cleanup(rawArgs) {
|
|
5379
|
-
return
|
|
3177
|
+
return finishCommands.cleanup(rawArgs);
|
|
5380
3178
|
}
|
|
5381
3179
|
|
|
5382
3180
|
function merge(rawArgs) {
|
|
5383
|
-
return
|
|
3181
|
+
return finishCommands.merge(rawArgs);
|
|
5384
3182
|
}
|
|
5385
3183
|
|
|
5386
3184
|
function finish(rawArgs, defaults = {}) {
|
|
5387
|
-
return
|
|
3185
|
+
return finishCommands.finish(rawArgs, defaults);
|
|
5388
3186
|
}
|
|
5389
3187
|
|
|
5390
3188
|
function sync(rawArgs) {
|
|
5391
|
-
return
|
|
3189
|
+
return finishCommands.sync(rawArgs);
|
|
5392
3190
|
}
|
|
5393
3191
|
|
|
5394
3192
|
function protect(rawArgs) {
|
|
@@ -5473,8 +3271,8 @@ function main() {
|
|
|
5473
3271
|
const args = process.argv.slice(2);
|
|
5474
3272
|
|
|
5475
3273
|
if (args.length === 0) {
|
|
5476
|
-
maybeSelfUpdateBeforeStatus();
|
|
5477
|
-
maybeOpenSpecUpdateBeforeStatus();
|
|
3274
|
+
toolchainModule.maybeSelfUpdateBeforeStatus();
|
|
3275
|
+
toolchainModule.maybeOpenSpecUpdateBeforeStatus();
|
|
5478
3276
|
status([]);
|
|
5479
3277
|
return;
|
|
5480
3278
|
}
|
|
@@ -5488,7 +3286,7 @@ function main() {
|
|
|
5488
3286
|
}
|
|
5489
3287
|
|
|
5490
3288
|
if (command === '--version' || command === '-v' || command === 'version') {
|
|
5491
|
-
maybeSelfUpdateBeforeStatus();
|
|
3289
|
+
toolchainModule.maybeSelfUpdateBeforeStatus();
|
|
5492
3290
|
console.log(packageJson.version);
|
|
5493
3291
|
return;
|
|
5494
3292
|
}
|