@imdeadpool/guardex 7.0.14 → 7.0.15
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 +31 -4
- package/bin/multiagent-safety.js +914 -104
- package/package.json +2 -2
- package/templates/scripts/agent-branch-finish.sh +35 -6
- package/templates/scripts/agent-branch-start.sh +50 -12
- package/templates/scripts/agent-worktree-prune.sh +78 -44
- package/templates/scripts/codex-agent.sh +49 -2
- package/templates/scripts/guardex-docker-loader.sh +123 -0
package/bin/multiagent-safety.js
CHANGED
|
@@ -64,7 +64,7 @@ const REQUIRED_SYSTEM_TOOLS = [
|
|
|
64
64
|
},
|
|
65
65
|
];
|
|
66
66
|
const MAINTAINER_RELEASE_REPO = path.resolve(
|
|
67
|
-
process.env.GUARDEX_RELEASE_REPO || '
|
|
67
|
+
process.env.GUARDEX_RELEASE_REPO || path.resolve(__dirname, '..'),
|
|
68
68
|
);
|
|
69
69
|
const NPM_BIN = process.env.GUARDEX_NPM_BIN || 'npm';
|
|
70
70
|
const OPENSPEC_BIN = process.env.GUARDEX_OPENSPEC_BIN || 'openspec';
|
|
@@ -77,6 +77,12 @@ const DEFAULT_PROTECTED_BRANCHES = ['dev', 'main', 'master'];
|
|
|
77
77
|
const DEFAULT_BASE_BRANCH = 'dev';
|
|
78
78
|
const DEFAULT_SYNC_STRATEGY = 'rebase';
|
|
79
79
|
const DEFAULT_SHADOW_CLEANUP_IDLE_MINUTES = 60;
|
|
80
|
+
const COMPOSE_HINT_FILES = [
|
|
81
|
+
'docker-compose.yml',
|
|
82
|
+
'docker-compose.yaml',
|
|
83
|
+
'compose.yml',
|
|
84
|
+
'compose.yaml',
|
|
85
|
+
];
|
|
80
86
|
|
|
81
87
|
const TEMPLATE_ROOT = path.resolve(__dirname, '..', 'templates');
|
|
82
88
|
|
|
@@ -84,6 +90,7 @@ const TEMPLATE_FILES = [
|
|
|
84
90
|
'scripts/agent-branch-start.sh',
|
|
85
91
|
'scripts/agent-branch-finish.sh',
|
|
86
92
|
'scripts/codex-agent.sh',
|
|
93
|
+
'scripts/guardex-docker-loader.sh',
|
|
87
94
|
'scripts/review-bot-watch.sh',
|
|
88
95
|
'scripts/agent-worktree-prune.sh',
|
|
89
96
|
'scripts/agent-file-locks.py',
|
|
@@ -105,6 +112,7 @@ const TEMPLATE_FILES = [
|
|
|
105
112
|
const REQUIRED_WORKFLOW_FILES = [
|
|
106
113
|
'scripts/agent-branch-start.sh',
|
|
107
114
|
'scripts/agent-branch-finish.sh',
|
|
115
|
+
'scripts/guardex-docker-loader.sh',
|
|
108
116
|
'scripts/agent-worktree-prune.sh',
|
|
109
117
|
'scripts/agent-file-locks.py',
|
|
110
118
|
'scripts/guardex-env.sh',
|
|
@@ -133,6 +141,7 @@ const REQUIRED_PACKAGE_SCRIPTS = {
|
|
|
133
141
|
'agent:safety:scan': 'gx status --strict',
|
|
134
142
|
'agent:safety:fix': 'gx setup --repair',
|
|
135
143
|
'agent:safety:doctor': 'gx doctor',
|
|
144
|
+
'agent:docker:load': 'bash ./scripts/guardex-docker-loader.sh',
|
|
136
145
|
'agent:review:watch': 'bash ./scripts/review-bot-watch.sh',
|
|
137
146
|
'agent:finish': 'gx finish --all',
|
|
138
147
|
};
|
|
@@ -141,6 +150,7 @@ const EXECUTABLE_RELATIVE_PATHS = new Set([
|
|
|
141
150
|
'scripts/agent-branch-start.sh',
|
|
142
151
|
'scripts/agent-branch-finish.sh',
|
|
143
152
|
'scripts/codex-agent.sh',
|
|
153
|
+
'scripts/guardex-docker-loader.sh',
|
|
144
154
|
'scripts/review-bot-watch.sh',
|
|
145
155
|
'scripts/agent-worktree-prune.sh',
|
|
146
156
|
'scripts/agent-file-locks.py',
|
|
@@ -173,10 +183,18 @@ const AGENTS_MARKER_START = '<!-- multiagent-safety:START -->';
|
|
|
173
183
|
const AGENTS_MARKER_END = '<!-- multiagent-safety:END -->';
|
|
174
184
|
const GITIGNORE_MARKER_START = '# multiagent-safety:START';
|
|
175
185
|
const GITIGNORE_MARKER_END = '# multiagent-safety:END';
|
|
186
|
+
const CODEX_WORKTREE_RELATIVE_DIR = path.join('.omx', 'agent-worktrees');
|
|
187
|
+
const CLAUDE_WORKTREE_RELATIVE_DIR = path.join('.omc', 'agent-worktrees');
|
|
188
|
+
const AGENT_WORKTREE_RELATIVE_DIRS = [
|
|
189
|
+
CODEX_WORKTREE_RELATIVE_DIR,
|
|
190
|
+
CLAUDE_WORKTREE_RELATIVE_DIR,
|
|
191
|
+
];
|
|
176
192
|
const MANAGED_GITIGNORE_PATHS = [
|
|
177
193
|
'.omx/',
|
|
178
194
|
'.omc/',
|
|
179
195
|
'scripts/*',
|
|
196
|
+
'scripts/agent-branch-start.sh',
|
|
197
|
+
'scripts/agent-file-locks.py',
|
|
180
198
|
'.githooks',
|
|
181
199
|
'oh-my-codex/',
|
|
182
200
|
'.codex/skills/gitguardex/SKILL.md',
|
|
@@ -190,7 +208,9 @@ const OMX_SCAFFOLD_DIRECTORIES = [
|
|
|
190
208
|
'.omx/state',
|
|
191
209
|
'.omx/logs',
|
|
192
210
|
'.omx/plans',
|
|
193
|
-
|
|
211
|
+
CODEX_WORKTREE_RELATIVE_DIR,
|
|
212
|
+
'.omc',
|
|
213
|
+
CLAUDE_WORKTREE_RELATIVE_DIR,
|
|
194
214
|
];
|
|
195
215
|
const OMX_SCAFFOLD_FILES = new Map([
|
|
196
216
|
['.omx/notepad.md', '\n\n## WORKING MEMORY\n'],
|
|
@@ -240,6 +260,7 @@ const CLI_COMMAND_DESCRIPTIONS = [
|
|
|
240
260
|
['sync', 'Sync agent branches with origin/<base>'],
|
|
241
261
|
['finish', 'Commit + PR + merge completed agent branches (--all, --branch)'],
|
|
242
262
|
['cleanup', 'Prune merged/stale agent branches and worktrees'],
|
|
263
|
+
['release', 'Create or update the current GitHub release with README-generated notes'],
|
|
243
264
|
['agents', 'Start/stop repo-scoped review + cleanup bots'],
|
|
244
265
|
['prompt', 'Print AI setup checklist (--exec, --snippet)'],
|
|
245
266
|
['report', 'Security/safety reports (e.g. OpenSSF scorecard)'],
|
|
@@ -260,6 +281,19 @@ const AGENT_BOT_DESCRIPTIONS = [
|
|
|
260
281
|
['agents', 'Start/stop review + cleanup bots for this repo'],
|
|
261
282
|
];
|
|
262
283
|
|
|
284
|
+
function envFlagIsTruthy(raw) {
|
|
285
|
+
const lowered = String(raw || '').trim().toLowerCase();
|
|
286
|
+
return lowered === '1' || lowered === 'true' || lowered === 'yes' || lowered === 'on';
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function isClaudeCodeSession(env = process.env) {
|
|
290
|
+
return envFlagIsTruthy(env.CLAUDECODE) || Boolean(env.CLAUDE_CODE_SESSION_ID);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function defaultAgentWorktreeRelativeDir(env = process.env) {
|
|
294
|
+
return isClaudeCodeSession(env) ? CLAUDE_WORKTREE_RELATIVE_DIR : CODEX_WORKTREE_RELATIVE_DIR;
|
|
295
|
+
}
|
|
296
|
+
|
|
263
297
|
const AI_SETUP_PROMPT = `GitGuardex (gx) setup checklist for Codex/Claude in this repo.
|
|
264
298
|
|
|
265
299
|
1) Install: npm i -g @imdeadpool/guardex && gh --version
|
|
@@ -509,8 +543,6 @@ const NESTED_REPO_DEFAULT_SKIP_DIRS = new Set([
|
|
|
509
543
|
'.venv',
|
|
510
544
|
'.pnpm-store',
|
|
511
545
|
]);
|
|
512
|
-
const NESTED_REPO_WORKTREE_RELATIVE_DIR = path.join('.omx', 'agent-worktrees');
|
|
513
|
-
|
|
514
546
|
function discoverNestedGitRepos(rootPath, opts = {}) {
|
|
515
547
|
const maxDepth = Number.isFinite(opts.maxDepth) ? Math.max(1, opts.maxDepth) : NESTED_REPO_DEFAULT_MAX_DEPTH;
|
|
516
548
|
const extraSkip = new Set(Array.isArray(opts.extraSkip) ? opts.extraSkip : []);
|
|
@@ -525,7 +557,7 @@ function discoverNestedGitRepos(rootPath, opts = {}) {
|
|
|
525
557
|
return path.resolve(resolvedRoot, raw);
|
|
526
558
|
})();
|
|
527
559
|
|
|
528
|
-
const
|
|
560
|
+
const worktreeSkipAbsolutes = AGENT_WORKTREE_RELATIVE_DIRS.map((relativeDir) => path.join(resolvedRoot, relativeDir));
|
|
529
561
|
const found = new Set();
|
|
530
562
|
found.add(resolvedRoot);
|
|
531
563
|
|
|
@@ -557,7 +589,7 @@ function discoverNestedGitRepos(rootPath, opts = {}) {
|
|
|
557
589
|
|
|
558
590
|
if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
|
|
559
591
|
if (shouldSkipDir(entry.name)) continue;
|
|
560
|
-
if (entryPath
|
|
592
|
+
if (worktreeSkipAbsolutes.includes(entryPath)) continue;
|
|
561
593
|
walk(entryPath, depth + 1);
|
|
562
594
|
}
|
|
563
595
|
}
|
|
@@ -1000,6 +1032,14 @@ function parseCommonArgs(rawArgs, defaults) {
|
|
|
1000
1032
|
options.allowProtectedBaseWrite = true;
|
|
1001
1033
|
continue;
|
|
1002
1034
|
}
|
|
1035
|
+
if (Object.prototype.hasOwnProperty.call(options, 'waitForMerge') && arg === '--wait-for-merge') {
|
|
1036
|
+
options.waitForMerge = true;
|
|
1037
|
+
continue;
|
|
1038
|
+
}
|
|
1039
|
+
if (Object.prototype.hasOwnProperty.call(options, 'waitForMerge') && arg === '--no-wait-for-merge') {
|
|
1040
|
+
options.waitForMerge = false;
|
|
1041
|
+
continue;
|
|
1042
|
+
}
|
|
1003
1043
|
|
|
1004
1044
|
throw new Error(`Unknown option: ${arg}`);
|
|
1005
1045
|
}
|
|
@@ -1090,6 +1130,7 @@ function parseDoctorArgs(rawArgs) {
|
|
|
1090
1130
|
dryRun: false,
|
|
1091
1131
|
json: false,
|
|
1092
1132
|
allowProtectedBaseWrite: false,
|
|
1133
|
+
waitForMerge: true,
|
|
1093
1134
|
});
|
|
1094
1135
|
}
|
|
1095
1136
|
|
|
@@ -1102,16 +1143,15 @@ function buildParentWorkspaceView(repoRoot) {
|
|
|
1102
1143
|
const workspaceFileName = `${path.basename(repoRoot)}-branches.code-workspace`;
|
|
1103
1144
|
const workspacePath = path.join(parentDir, workspaceFileName);
|
|
1104
1145
|
const repoRelativePath = normalizeWorkspacePath(path.relative(parentDir, repoRoot) || '.');
|
|
1105
|
-
const worktreesRelativePath = normalizeWorkspacePath(
|
|
1106
|
-
path.join(repoRelativePath === '.' ? '' : repoRelativePath, '.omx', 'agent-worktrees'),
|
|
1107
|
-
);
|
|
1108
1146
|
|
|
1109
1147
|
return {
|
|
1110
1148
|
workspacePath,
|
|
1111
1149
|
payload: {
|
|
1112
1150
|
folders: [
|
|
1113
1151
|
{ path: repoRelativePath },
|
|
1114
|
-
|
|
1152
|
+
...AGENT_WORKTREE_RELATIVE_DIRS.map((relativeDir) => ({
|
|
1153
|
+
path: normalizeWorkspacePath(path.join(repoRelativePath === '.' ? '' : repoRelativePath, relativeDir)),
|
|
1154
|
+
})),
|
|
1115
1155
|
],
|
|
1116
1156
|
settings: {
|
|
1117
1157
|
'scm.alwaysShowRepositories': true,
|
|
@@ -1195,6 +1235,40 @@ function assertProtectedMainWriteAllowed(options, commandName) {
|
|
|
1195
1235
|
);
|
|
1196
1236
|
}
|
|
1197
1237
|
|
|
1238
|
+
function runSetupBootstrapInternal(options) {
|
|
1239
|
+
const installPayload = runInstallInternal(options);
|
|
1240
|
+
installPayload.operations.push(
|
|
1241
|
+
ensureSetupProtectedBranches(installPayload.repoRoot, Boolean(options.dryRun)),
|
|
1242
|
+
);
|
|
1243
|
+
|
|
1244
|
+
let parentWorkspace = null;
|
|
1245
|
+
if (options.parentWorkspaceView) {
|
|
1246
|
+
installPayload.operations.push(
|
|
1247
|
+
ensureParentWorkspaceView(installPayload.repoRoot, Boolean(options.dryRun)),
|
|
1248
|
+
);
|
|
1249
|
+
if (!options.dryRun) {
|
|
1250
|
+
parentWorkspace = buildParentWorkspaceView(installPayload.repoRoot);
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
const fixPayload = runFixInternal({
|
|
1255
|
+
target: installPayload.repoRoot,
|
|
1256
|
+
dryRun: options.dryRun,
|
|
1257
|
+
force: options.force,
|
|
1258
|
+
dropStaleLocks: true,
|
|
1259
|
+
skipAgents: options.skipAgents,
|
|
1260
|
+
skipPackageJson: options.skipPackageJson,
|
|
1261
|
+
skipGitignore: options.skipGitignore,
|
|
1262
|
+
allowProtectedBaseWrite: options.allowProtectedBaseWrite,
|
|
1263
|
+
});
|
|
1264
|
+
|
|
1265
|
+
return {
|
|
1266
|
+
installPayload,
|
|
1267
|
+
fixPayload,
|
|
1268
|
+
parentWorkspace,
|
|
1269
|
+
};
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1198
1272
|
function extractAgentBranchStartMetadata(output) {
|
|
1199
1273
|
const branchMatch = String(output || '').match(/^\[agent-branch-start\] Created branch: (.+)$/m);
|
|
1200
1274
|
const worktreeMatch = String(output || '').match(/^\[agent-branch-start\] Worktree: (.+)$/m);
|
|
@@ -1208,7 +1282,7 @@ function resolveSandboxTarget(repoRoot, worktreePath, targetPath) {
|
|
|
1208
1282
|
const resolvedTarget = path.resolve(targetPath);
|
|
1209
1283
|
const relativeTarget = path.relative(repoRoot, resolvedTarget);
|
|
1210
1284
|
if (relativeTarget.startsWith('..') || path.isAbsolute(relativeTarget)) {
|
|
1211
|
-
throw new Error(`
|
|
1285
|
+
throw new Error(`sandbox target must stay inside repo root: ${resolvedTarget}`);
|
|
1212
1286
|
}
|
|
1213
1287
|
if (!relativeTarget || relativeTarget === '.') {
|
|
1214
1288
|
return worktreePath;
|
|
@@ -1216,6 +1290,16 @@ function resolveSandboxTarget(repoRoot, worktreePath, targetPath) {
|
|
|
1216
1290
|
return path.join(worktreePath, relativeTarget);
|
|
1217
1291
|
}
|
|
1218
1292
|
|
|
1293
|
+
function buildSandboxSetupArgs(options, sandboxTarget) {
|
|
1294
|
+
const args = ['setup', '--target', sandboxTarget, '--no-global-install', '--no-recursive'];
|
|
1295
|
+
if (options.force) args.push('--force');
|
|
1296
|
+
if (options.skipAgents) args.push('--skip-agents');
|
|
1297
|
+
if (options.skipPackageJson) args.push('--skip-package-json');
|
|
1298
|
+
if (options.skipGitignore) args.push('--no-gitignore');
|
|
1299
|
+
if (options.dryRun) args.push('--dry-run');
|
|
1300
|
+
return args;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1219
1303
|
function buildSandboxDoctorArgs(options, sandboxTarget) {
|
|
1220
1304
|
const args = ['doctor', '--target', sandboxTarget];
|
|
1221
1305
|
if (options.dryRun) args.push('--dry-run');
|
|
@@ -1224,6 +1308,7 @@ function buildSandboxDoctorArgs(options, sandboxTarget) {
|
|
|
1224
1308
|
if (options.skipPackageJson) args.push('--skip-package-json');
|
|
1225
1309
|
if (options.skipGitignore) args.push('--no-gitignore');
|
|
1226
1310
|
if (!options.dropStaleLocks) args.push('--keep-stale-locks');
|
|
1311
|
+
args.push(options.waitForMerge ? '--wait-for-merge' : '--no-wait-for-merge');
|
|
1227
1312
|
if (options.json) args.push('--json');
|
|
1228
1313
|
return args;
|
|
1229
1314
|
}
|
|
@@ -1259,7 +1344,7 @@ function ensureRepoBranch(repoRoot, branch) {
|
|
|
1259
1344
|
return { ok: true, changed: true };
|
|
1260
1345
|
}
|
|
1261
1346
|
|
|
1262
|
-
function
|
|
1347
|
+
function protectedBaseSandboxBranchPrefix() {
|
|
1263
1348
|
const now = new Date();
|
|
1264
1349
|
const stamp = [
|
|
1265
1350
|
now.getUTCFullYear(),
|
|
@@ -1273,15 +1358,15 @@ function doctorSandboxBranchPrefix() {
|
|
|
1273
1358
|
return `agent/gx/${stamp}`;
|
|
1274
1359
|
}
|
|
1275
1360
|
|
|
1276
|
-
function
|
|
1277
|
-
return path.join(repoRoot,
|
|
1361
|
+
function protectedBaseSandboxWorktreePath(repoRoot, branchName) {
|
|
1362
|
+
return path.join(repoRoot, defaultAgentWorktreeRelativeDir(), branchName.replace(/\//g, '__'));
|
|
1278
1363
|
}
|
|
1279
1364
|
|
|
1280
1365
|
function gitRefExists(repoRoot, ref) {
|
|
1281
1366
|
return run('git', ['-C', repoRoot, 'show-ref', '--verify', '--quiet', ref]).status === 0;
|
|
1282
1367
|
}
|
|
1283
1368
|
|
|
1284
|
-
function
|
|
1369
|
+
function resolveProtectedBaseSandboxStartRef(repoRoot, baseBranch) {
|
|
1285
1370
|
run('git', ['-C', repoRoot, 'fetch', 'origin', baseBranch, '--quiet'], { timeout: 20_000 });
|
|
1286
1371
|
if (gitRefExists(repoRoot, `refs/remotes/origin/${baseBranch}`)) {
|
|
1287
1372
|
return `origin/${baseBranch}`;
|
|
@@ -1289,18 +1374,21 @@ function resolveDoctorSandboxStartRef(repoRoot, baseBranch) {
|
|
|
1289
1374
|
if (gitRefExists(repoRoot, `refs/heads/${baseBranch}`)) {
|
|
1290
1375
|
return baseBranch;
|
|
1291
1376
|
}
|
|
1292
|
-
|
|
1377
|
+
if (currentBranchName(repoRoot) === baseBranch) {
|
|
1378
|
+
return null;
|
|
1379
|
+
}
|
|
1380
|
+
throw new Error(`Unable to find base ref for sandbox bootstrap: ${baseBranch}`);
|
|
1293
1381
|
}
|
|
1294
1382
|
|
|
1295
|
-
function
|
|
1296
|
-
const branchPrefix =
|
|
1383
|
+
function startProtectedBaseSandboxFallback(blocked, sandboxSuffix) {
|
|
1384
|
+
const branchPrefix = protectedBaseSandboxBranchPrefix();
|
|
1297
1385
|
let selectedBranch = '';
|
|
1298
1386
|
let selectedWorktreePath = '';
|
|
1299
1387
|
|
|
1300
1388
|
for (let attempt = 0; attempt < 30; attempt += 1) {
|
|
1301
|
-
const suffix = attempt === 0 ?
|
|
1389
|
+
const suffix = attempt === 0 ? sandboxSuffix : `${attempt + 1}-${sandboxSuffix}`;
|
|
1302
1390
|
const candidateBranch = `${branchPrefix}-${suffix}`;
|
|
1303
|
-
const candidateWorktreePath =
|
|
1391
|
+
const candidateWorktreePath = protectedBaseSandboxWorktreePath(blocked.repoRoot, candidateBranch);
|
|
1304
1392
|
if (gitRefExists(blocked.repoRoot, `refs/heads/${candidateBranch}`)) {
|
|
1305
1393
|
continue;
|
|
1306
1394
|
}
|
|
@@ -1313,20 +1401,36 @@ function startDoctorSandboxFallback(blocked) {
|
|
|
1313
1401
|
}
|
|
1314
1402
|
|
|
1315
1403
|
if (!selectedBranch || !selectedWorktreePath) {
|
|
1316
|
-
throw new Error('Unable to allocate unique sandbox branch/worktree
|
|
1404
|
+
throw new Error('Unable to allocate unique sandbox branch/worktree');
|
|
1317
1405
|
}
|
|
1318
1406
|
|
|
1319
1407
|
fs.mkdirSync(path.dirname(selectedWorktreePath), { recursive: true });
|
|
1320
|
-
const startRef =
|
|
1321
|
-
const
|
|
1322
|
-
'
|
|
1323
|
-
['-C', blocked.repoRoot, 'worktree', 'add', '
|
|
1324
|
-
);
|
|
1408
|
+
const startRef = resolveProtectedBaseSandboxStartRef(blocked.repoRoot, blocked.branch);
|
|
1409
|
+
const addArgs = startRef
|
|
1410
|
+
? ['-C', blocked.repoRoot, 'worktree', 'add', '-b', selectedBranch, selectedWorktreePath, startRef]
|
|
1411
|
+
: ['-C', blocked.repoRoot, 'worktree', 'add', '--orphan', selectedWorktreePath];
|
|
1412
|
+
const addResult = run('git', addArgs);
|
|
1325
1413
|
if (isSpawnFailure(addResult)) {
|
|
1326
1414
|
throw addResult.error;
|
|
1327
1415
|
}
|
|
1328
1416
|
if (addResult.status !== 0) {
|
|
1329
|
-
throw new Error((addResult.stderr || addResult.stdout || 'failed to create
|
|
1417
|
+
throw new Error((addResult.stderr || addResult.stdout || 'failed to create sandbox').trim());
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
if (!startRef) {
|
|
1421
|
+
const renameResult = run(
|
|
1422
|
+
'git',
|
|
1423
|
+
['-C', selectedWorktreePath, 'branch', '-m', selectedBranch],
|
|
1424
|
+
{ timeout: 20_000 },
|
|
1425
|
+
);
|
|
1426
|
+
if (isSpawnFailure(renameResult)) {
|
|
1427
|
+
throw renameResult.error;
|
|
1428
|
+
}
|
|
1429
|
+
if (renameResult.status !== 0) {
|
|
1430
|
+
throw new Error(
|
|
1431
|
+
(renameResult.stderr || renameResult.stdout || 'failed to name orphan sandbox branch').trim(),
|
|
1432
|
+
);
|
|
1433
|
+
}
|
|
1330
1434
|
}
|
|
1331
1435
|
|
|
1332
1436
|
return {
|
|
@@ -1341,16 +1445,20 @@ function startDoctorSandboxFallback(blocked) {
|
|
|
1341
1445
|
};
|
|
1342
1446
|
}
|
|
1343
1447
|
|
|
1344
|
-
function
|
|
1448
|
+
function startProtectedBaseSandbox(blocked, { taskName, sandboxSuffix }) {
|
|
1449
|
+
if (sandboxSuffix === 'gx-doctor') {
|
|
1450
|
+
return startProtectedBaseSandboxFallback(blocked, sandboxSuffix);
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1345
1453
|
const startScript = path.join(blocked.repoRoot, 'scripts', 'agent-branch-start.sh');
|
|
1346
1454
|
if (!fs.existsSync(startScript)) {
|
|
1347
|
-
return
|
|
1455
|
+
return startProtectedBaseSandboxFallback(blocked, sandboxSuffix);
|
|
1348
1456
|
}
|
|
1349
1457
|
|
|
1350
1458
|
const startResult = run('bash', [
|
|
1351
1459
|
startScript,
|
|
1352
1460
|
'--task',
|
|
1353
|
-
|
|
1461
|
+
taskName,
|
|
1354
1462
|
'--agent',
|
|
1355
1463
|
SHORT_TOOL_NAME,
|
|
1356
1464
|
'--base',
|
|
@@ -1360,7 +1468,7 @@ function startDoctorSandbox(blocked) {
|
|
|
1360
1468
|
throw startResult.error;
|
|
1361
1469
|
}
|
|
1362
1470
|
if (startResult.status !== 0) {
|
|
1363
|
-
return
|
|
1471
|
+
return startProtectedBaseSandboxFallback(blocked, sandboxSuffix);
|
|
1364
1472
|
}
|
|
1365
1473
|
|
|
1366
1474
|
const metadata = extractAgentBranchStartMetadata(startResult.stdout);
|
|
@@ -1375,11 +1483,11 @@ function startDoctorSandbox(blocked) {
|
|
|
1375
1483
|
if (!restoreResult.ok) {
|
|
1376
1484
|
const detail = [restoreResult.stderr, restoreResult.stdout].filter(Boolean).join('\n').trim();
|
|
1377
1485
|
throw new Error(
|
|
1378
|
-
`
|
|
1486
|
+
`sandbox startup switched protected base checkout and could not restore '${blocked.branch}'.` +
|
|
1379
1487
|
(detail ? `\n${detail}` : ''),
|
|
1380
1488
|
);
|
|
1381
1489
|
}
|
|
1382
|
-
return
|
|
1490
|
+
return startProtectedBaseSandboxFallback(blocked, sandboxSuffix);
|
|
1383
1491
|
}
|
|
1384
1492
|
|
|
1385
1493
|
return {
|
|
@@ -1389,6 +1497,59 @@ function startDoctorSandbox(blocked) {
|
|
|
1389
1497
|
};
|
|
1390
1498
|
}
|
|
1391
1499
|
|
|
1500
|
+
function cleanupProtectedBaseSandbox(repoRoot, metadata) {
|
|
1501
|
+
const result = {
|
|
1502
|
+
worktree: 'skipped',
|
|
1503
|
+
branch: 'skipped',
|
|
1504
|
+
note: 'missing sandbox metadata',
|
|
1505
|
+
};
|
|
1506
|
+
|
|
1507
|
+
if (!metadata?.worktreePath || !metadata?.branch) {
|
|
1508
|
+
return result;
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
if (fs.existsSync(metadata.worktreePath)) {
|
|
1512
|
+
const removeResult = run(
|
|
1513
|
+
'git',
|
|
1514
|
+
['-C', repoRoot, 'worktree', 'remove', '--force', metadata.worktreePath],
|
|
1515
|
+
{ timeout: 30_000 },
|
|
1516
|
+
);
|
|
1517
|
+
if (isSpawnFailure(removeResult)) {
|
|
1518
|
+
throw removeResult.error;
|
|
1519
|
+
}
|
|
1520
|
+
if (removeResult.status !== 0) {
|
|
1521
|
+
throw new Error(
|
|
1522
|
+
(removeResult.stderr || removeResult.stdout || 'failed to remove sandbox worktree').trim(),
|
|
1523
|
+
);
|
|
1524
|
+
}
|
|
1525
|
+
result.worktree = 'removed';
|
|
1526
|
+
} else {
|
|
1527
|
+
result.worktree = 'missing';
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
if (gitRefExists(repoRoot, `refs/heads/${metadata.branch}`)) {
|
|
1531
|
+
const branchDeleteResult = run(
|
|
1532
|
+
'git',
|
|
1533
|
+
['-C', repoRoot, 'branch', '-D', metadata.branch],
|
|
1534
|
+
{ timeout: 20_000 },
|
|
1535
|
+
);
|
|
1536
|
+
if (isSpawnFailure(branchDeleteResult)) {
|
|
1537
|
+
throw branchDeleteResult.error;
|
|
1538
|
+
}
|
|
1539
|
+
if (branchDeleteResult.status !== 0) {
|
|
1540
|
+
throw new Error(
|
|
1541
|
+
(branchDeleteResult.stderr || branchDeleteResult.stdout || 'failed to delete sandbox branch').trim(),
|
|
1542
|
+
);
|
|
1543
|
+
}
|
|
1544
|
+
result.branch = 'deleted';
|
|
1545
|
+
} else {
|
|
1546
|
+
result.branch = 'missing';
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
result.note = 'sandbox worktree pruned';
|
|
1550
|
+
return result;
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1392
1553
|
function parseGitPathList(output) {
|
|
1393
1554
|
return String(output || '')
|
|
1394
1555
|
.split('\n')
|
|
@@ -1427,6 +1588,59 @@ function collectDoctorDeletedPaths(worktreePath) {
|
|
|
1427
1588
|
return Array.from(deleted);
|
|
1428
1589
|
}
|
|
1429
1590
|
|
|
1591
|
+
function collectWorktreeDirtyPaths(worktreePath) {
|
|
1592
|
+
const dirty = new Set();
|
|
1593
|
+
const commands = [
|
|
1594
|
+
['diff', '--name-only'],
|
|
1595
|
+
['diff', '--cached', '--name-only'],
|
|
1596
|
+
['ls-files', '--others', '--exclude-standard'],
|
|
1597
|
+
];
|
|
1598
|
+
for (const gitArgs of commands) {
|
|
1599
|
+
const result = run('git', ['-C', worktreePath, ...gitArgs], { timeout: 20_000 });
|
|
1600
|
+
for (const filePath of parseGitPathList(result.stdout)) {
|
|
1601
|
+
dirty.add(filePath);
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
return Array.from(dirty);
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
function collectDoctorForceAddPaths(worktreePath) {
|
|
1608
|
+
return TEMPLATE_FILES
|
|
1609
|
+
.map((entry) => toDestinationPath(entry))
|
|
1610
|
+
.filter((relativePath) => relativePath.startsWith('scripts/') || relativePath.startsWith('.githooks/'))
|
|
1611
|
+
.filter((relativePath) => fs.existsSync(path.join(worktreePath, relativePath)));
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
function stripDoctorSandboxLocks(rawContent, branchName) {
|
|
1615
|
+
if (!rawContent || !branchName) {
|
|
1616
|
+
return rawContent;
|
|
1617
|
+
}
|
|
1618
|
+
try {
|
|
1619
|
+
const parsed = JSON.parse(rawContent);
|
|
1620
|
+
const locks = parsed && typeof parsed === 'object' && parsed.locks && typeof parsed.locks === 'object'
|
|
1621
|
+
? parsed.locks
|
|
1622
|
+
: null;
|
|
1623
|
+
if (!locks) {
|
|
1624
|
+
return rawContent;
|
|
1625
|
+
}
|
|
1626
|
+
let changed = false;
|
|
1627
|
+
const filteredLocks = {};
|
|
1628
|
+
for (const [filePath, lockInfo] of Object.entries(locks)) {
|
|
1629
|
+
if (lockInfo && lockInfo.branch === branchName) {
|
|
1630
|
+
changed = true;
|
|
1631
|
+
continue;
|
|
1632
|
+
}
|
|
1633
|
+
filteredLocks[filePath] = lockInfo;
|
|
1634
|
+
}
|
|
1635
|
+
if (!changed) {
|
|
1636
|
+
return rawContent;
|
|
1637
|
+
}
|
|
1638
|
+
return `${JSON.stringify({ ...parsed, locks: filteredLocks }, null, 2)}\n`;
|
|
1639
|
+
} catch {
|
|
1640
|
+
return rawContent;
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1430
1644
|
function claimDoctorChangedLocks(metadata) {
|
|
1431
1645
|
const lockScript = path.join(metadata.worktreePath, 'scripts', 'agent-file-locks.py');
|
|
1432
1646
|
if (!fs.existsSync(lockScript) || !metadata.branch) {
|
|
@@ -1438,7 +1652,10 @@ function claimDoctorChangedLocks(metadata) {
|
|
|
1438
1652
|
};
|
|
1439
1653
|
}
|
|
1440
1654
|
|
|
1441
|
-
const changedPaths =
|
|
1655
|
+
const changedPaths = Array.from(new Set([
|
|
1656
|
+
...collectDoctorChangedPaths(metadata.worktreePath),
|
|
1657
|
+
...collectDoctorForceAddPaths(metadata.worktreePath),
|
|
1658
|
+
]));
|
|
1442
1659
|
const deletedPaths = collectDoctorDeletedPaths(metadata.worktreePath);
|
|
1443
1660
|
if (changedPaths.length > 0) {
|
|
1444
1661
|
run('python3', [lockScript, 'claim', '--branch', metadata.branch, ...changedPaths], {
|
|
@@ -1470,7 +1687,19 @@ function autoCommitDoctorSandboxChanges(metadata) {
|
|
|
1470
1687
|
}
|
|
1471
1688
|
|
|
1472
1689
|
claimDoctorChangedLocks(metadata);
|
|
1473
|
-
run(
|
|
1690
|
+
run(
|
|
1691
|
+
'git',
|
|
1692
|
+
['-C', metadata.worktreePath, 'add', '-A', '--', '.', `:(exclude)${LOCK_FILE_RELATIVE}`],
|
|
1693
|
+
{ timeout: 20_000 },
|
|
1694
|
+
);
|
|
1695
|
+
const forceAddPaths = collectDoctorForceAddPaths(metadata.worktreePath);
|
|
1696
|
+
if (forceAddPaths.length > 0) {
|
|
1697
|
+
run(
|
|
1698
|
+
'git',
|
|
1699
|
+
['-C', metadata.worktreePath, 'add', '-f', '--', ...forceAddPaths],
|
|
1700
|
+
{ timeout: 20_000 },
|
|
1701
|
+
);
|
|
1702
|
+
}
|
|
1474
1703
|
const staged = run(
|
|
1475
1704
|
'git',
|
|
1476
1705
|
['-C', metadata.worktreePath, 'diff', '--cached', '--name-only', '--', '.', `:(exclude)${LOCK_FILE_RELATIVE}`],
|
|
@@ -1535,7 +1764,7 @@ function doctorFinishFlowIsPending(output) {
|
|
|
1535
1764
|
);
|
|
1536
1765
|
}
|
|
1537
1766
|
|
|
1538
|
-
function finishDoctorSandboxBranch(blocked, metadata) {
|
|
1767
|
+
function finishDoctorSandboxBranch(blocked, metadata, options = {}) {
|
|
1539
1768
|
const finishScript = path.join(metadata.worktreePath, 'scripts', 'agent-branch-finish.sh');
|
|
1540
1769
|
if (!fs.existsSync(finishScript)) {
|
|
1541
1770
|
return {
|
|
@@ -1577,10 +1806,11 @@ function finishDoctorSandboxBranch(blocked, metadata) {
|
|
|
1577
1806
|
const waitTimeoutSeconds =
|
|
1578
1807
|
Number.isFinite(rawWaitTimeoutSeconds) && rawWaitTimeoutSeconds >= 30 ? rawWaitTimeoutSeconds : 1800;
|
|
1579
1808
|
const finishTimeoutMs = Math.max(180_000, (waitTimeoutSeconds + 60) * 1000);
|
|
1809
|
+
const waitForMergeArg = options.waitForMerge === false ? '--no-wait-for-merge' : '--wait-for-merge';
|
|
1580
1810
|
|
|
1581
1811
|
const finishResult = run(
|
|
1582
1812
|
'bash',
|
|
1583
|
-
[finishScript, '--branch', metadata.branch, '--base', blocked.branch, '--via-pr',
|
|
1813
|
+
[finishScript, '--branch', metadata.branch, '--base', blocked.branch, '--via-pr', waitForMergeArg],
|
|
1584
1814
|
{ cwd: metadata.worktreePath, timeout: finishTimeoutMs },
|
|
1585
1815
|
);
|
|
1586
1816
|
if (isSpawnFailure(finishResult)) {
|
|
@@ -1619,35 +1849,186 @@ function finishDoctorSandboxBranch(blocked, metadata) {
|
|
|
1619
1849
|
};
|
|
1620
1850
|
}
|
|
1621
1851
|
|
|
1622
|
-
function
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1852
|
+
function mergeDoctorSandboxRepairsBackToProtectedBase(options, blocked, metadata, autoCommitResult, finishResult) {
|
|
1853
|
+
if (options.dryRun) {
|
|
1854
|
+
return {
|
|
1855
|
+
status: autoCommitResult.status === 'committed' ? 'would-merge' : 'skipped',
|
|
1856
|
+
note: autoCommitResult.status === 'committed'
|
|
1857
|
+
? 'dry run: would fast-forward tracked doctor repairs into the protected base workspace'
|
|
1858
|
+
: 'dry run skips tracked repair merge',
|
|
1859
|
+
};
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
if (autoCommitResult.status !== 'committed') {
|
|
1863
|
+
return {
|
|
1864
|
+
status: autoCommitResult.status === 'no-changes' ? 'unchanged' : 'skipped',
|
|
1865
|
+
note: autoCommitResult.status === 'no-changes'
|
|
1866
|
+
? 'no tracked doctor repairs needed in the protected base workspace'
|
|
1867
|
+
: 'tracked doctor repair merge skipped',
|
|
1868
|
+
};
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
if (finishResult.status !== 'skipped') {
|
|
1872
|
+
return {
|
|
1873
|
+
status: 'skipped',
|
|
1874
|
+
note: finishResult.status === 'failed'
|
|
1875
|
+
? 'tracked doctor repairs remain in the sandbox after finish failure'
|
|
1876
|
+
: 'tracked doctor repairs are being delivered through the sandbox finish flow',
|
|
1877
|
+
};
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
const allowedPaths = new Set([
|
|
1881
|
+
...(autoCommitResult.stagedFiles || []),
|
|
1882
|
+
...OMX_SCAFFOLD_DIRECTORIES,
|
|
1883
|
+
...Array.from(OMX_SCAFFOLD_FILES.keys()),
|
|
1884
|
+
...TEMPLATE_FILES.map((entry) => toDestinationPath(entry)),
|
|
1885
|
+
'bin',
|
|
1886
|
+
'package.json',
|
|
1887
|
+
'.gitignore',
|
|
1888
|
+
'AGENTS.md',
|
|
1889
|
+
]);
|
|
1890
|
+
const dirtyPaths = collectWorktreeDirtyPaths(blocked.repoRoot);
|
|
1891
|
+
let stashRef = '';
|
|
1892
|
+
if (dirtyPaths.length > 0) {
|
|
1893
|
+
const unexpectedPaths = dirtyPaths.filter((filePath) => {
|
|
1894
|
+
if (allowedPaths.has(filePath)) {
|
|
1895
|
+
return false;
|
|
1896
|
+
}
|
|
1897
|
+
return !AGENT_WORKTREE_RELATIVE_DIRS.some(
|
|
1898
|
+
(relativeDir) => filePath === relativeDir || filePath.startsWith(`${relativeDir}/`),
|
|
1899
|
+
);
|
|
1900
|
+
});
|
|
1901
|
+
if (unexpectedPaths.length > 0) {
|
|
1902
|
+
return {
|
|
1903
|
+
status: 'failed',
|
|
1904
|
+
note: `protected branch workspace has unrelated local changes: ${unexpectedPaths.join(', ')}`,
|
|
1905
|
+
};
|
|
1906
|
+
}
|
|
1907
|
+
const stashMessage = `guardex-doctor-merge-${Date.now()}`;
|
|
1908
|
+
const stashResult = run(
|
|
1909
|
+
'git',
|
|
1910
|
+
['-C', blocked.repoRoot, 'stash', 'push', '--all', '--message', stashMessage],
|
|
1911
|
+
{ timeout: 30_000 },
|
|
1912
|
+
);
|
|
1913
|
+
if (isSpawnFailure(stashResult)) {
|
|
1914
|
+
return {
|
|
1915
|
+
status: 'failed',
|
|
1916
|
+
note: 'could not stash protected branch doctor drift before merge',
|
|
1917
|
+
stdout: stashResult.stdout || '',
|
|
1918
|
+
stderr: stashResult.stderr || '',
|
|
1919
|
+
};
|
|
1920
|
+
}
|
|
1921
|
+
if (stashResult.status !== 0) {
|
|
1922
|
+
return {
|
|
1923
|
+
status: 'failed',
|
|
1924
|
+
note: 'stashing protected branch doctor drift failed',
|
|
1925
|
+
stdout: stashResult.stdout || '',
|
|
1926
|
+
stderr: stashResult.stderr || '',
|
|
1927
|
+
};
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
const stashLookup = run(
|
|
1931
|
+
'git',
|
|
1932
|
+
['-C', blocked.repoRoot, 'stash', 'list'],
|
|
1933
|
+
{ timeout: 20_000 },
|
|
1934
|
+
);
|
|
1935
|
+
stashRef = String(stashLookup.stdout || '')
|
|
1936
|
+
.split('\n')
|
|
1937
|
+
.find((line) => line.includes(stashMessage))
|
|
1938
|
+
?.split(':')[0]
|
|
1939
|
+
?.trim() || '';
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
const restoreResult = ensureRepoBranch(blocked.repoRoot, blocked.branch);
|
|
1943
|
+
if (!restoreResult.ok) {
|
|
1944
|
+
if (stashRef) {
|
|
1945
|
+
run('git', ['-C', blocked.repoRoot, 'stash', 'apply', stashRef], { timeout: 30_000 });
|
|
1946
|
+
}
|
|
1947
|
+
return {
|
|
1948
|
+
status: 'failed',
|
|
1949
|
+
note: `could not restore protected branch '${blocked.branch}' before applying sandbox repairs`,
|
|
1950
|
+
stdout: restoreResult.stdout || '',
|
|
1951
|
+
stderr: restoreResult.stderr || '',
|
|
1952
|
+
};
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
const mergeResult = run(
|
|
1956
|
+
'git',
|
|
1957
|
+
['-C', blocked.repoRoot, 'merge', '--ff-only', metadata.branch],
|
|
1958
|
+
{ timeout: 30_000 },
|
|
1630
1959
|
);
|
|
1631
|
-
|
|
1632
|
-
|
|
1960
|
+
if (isSpawnFailure(mergeResult)) {
|
|
1961
|
+
if (stashRef) {
|
|
1962
|
+
run('git', ['-C', blocked.repoRoot, 'stash', 'apply', stashRef], { timeout: 30_000 });
|
|
1963
|
+
}
|
|
1964
|
+
return {
|
|
1965
|
+
status: 'failed',
|
|
1966
|
+
note: 'tracked doctor repair merge errored',
|
|
1967
|
+
stdout: mergeResult.stdout || '',
|
|
1968
|
+
stderr: mergeResult.stderr || '',
|
|
1969
|
+
};
|
|
1970
|
+
}
|
|
1971
|
+
if (mergeResult.status !== 0) {
|
|
1972
|
+
if (stashRef) {
|
|
1973
|
+
run('git', ['-C', blocked.repoRoot, 'stash', 'apply', stashRef], { timeout: 30_000 });
|
|
1974
|
+
}
|
|
1975
|
+
return {
|
|
1976
|
+
status: 'failed',
|
|
1977
|
+
note: 'tracked doctor repair merge failed',
|
|
1978
|
+
stdout: mergeResult.stdout || '',
|
|
1979
|
+
stderr: mergeResult.stderr || '',
|
|
1980
|
+
};
|
|
1981
|
+
}
|
|
1633
1982
|
|
|
1634
|
-
|
|
1983
|
+
let cleanupResult;
|
|
1984
|
+
try {
|
|
1985
|
+
cleanupResult = cleanupProtectedBaseSandbox(blocked.repoRoot, metadata);
|
|
1986
|
+
} catch (error) {
|
|
1635
1987
|
return {
|
|
1636
|
-
status: '
|
|
1637
|
-
note:
|
|
1638
|
-
|
|
1988
|
+
status: 'failed',
|
|
1989
|
+
note: `tracked doctor repair merge succeeded but sandbox cleanup failed: ${error.message}`,
|
|
1990
|
+
stdout: mergeResult.stdout || '',
|
|
1991
|
+
stderr: mergeResult.stderr || '',
|
|
1992
|
+
};
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
let hookRefreshResult;
|
|
1996
|
+
try {
|
|
1997
|
+
hookRefreshResult = configureHooks(blocked.repoRoot, false);
|
|
1998
|
+
} catch (error) {
|
|
1999
|
+
return {
|
|
2000
|
+
status: 'failed',
|
|
2001
|
+
note: `tracked doctor repair merge succeeded but local hook refresh failed: ${error.message}`,
|
|
2002
|
+
stdout: mergeResult.stdout || '',
|
|
2003
|
+
stderr: mergeResult.stderr || '',
|
|
1639
2004
|
};
|
|
1640
2005
|
}
|
|
1641
2006
|
|
|
2007
|
+
if (stashRef) {
|
|
2008
|
+
run('git', ['-C', blocked.repoRoot, 'stash', 'drop', stashRef], { timeout: 20_000 });
|
|
2009
|
+
}
|
|
2010
|
+
|
|
1642
2011
|
return {
|
|
1643
|
-
status:
|
|
1644
|
-
note:
|
|
1645
|
-
|
|
2012
|
+
status: 'merged',
|
|
2013
|
+
note: 'fast-forwarded tracked doctor repairs into the protected base workspace',
|
|
2014
|
+
stdout: mergeResult.stdout || '',
|
|
2015
|
+
stderr: mergeResult.stderr || '',
|
|
2016
|
+
cleanup: cleanupResult,
|
|
2017
|
+
hookRefresh: hookRefreshResult,
|
|
1646
2018
|
};
|
|
1647
2019
|
}
|
|
1648
2020
|
|
|
2021
|
+
function syncDoctorLocalSupportFiles(repoRoot, dryRun) {
|
|
2022
|
+
return TEMPLATE_FILES
|
|
2023
|
+
.filter((entry) => entry.startsWith('codex/') || entry.startsWith('claude/'))
|
|
2024
|
+
.map((entry) => ensureTemplateFilePresent(repoRoot, entry, dryRun));
|
|
2025
|
+
}
|
|
2026
|
+
|
|
1649
2027
|
function runDoctorInSandbox(options, blocked) {
|
|
1650
|
-
const startResult =
|
|
2028
|
+
const startResult = startProtectedBaseSandbox(blocked, {
|
|
2029
|
+
taskName: `${SHORT_TOOL_NAME}-doctor`,
|
|
2030
|
+
sandboxSuffix: 'gx-doctor',
|
|
2031
|
+
});
|
|
1651
2032
|
const metadata = startResult.metadata;
|
|
1652
2033
|
|
|
1653
2034
|
const sandboxTarget = resolveSandboxTarget(blocked.repoRoot, metadata.worktreePath, options.target);
|
|
@@ -1677,6 +2058,7 @@ function runDoctorInSandbox(options, blocked) {
|
|
|
1677
2058
|
status: 'skipped',
|
|
1678
2059
|
note: 'sandbox doctor did not complete successfully',
|
|
1679
2060
|
};
|
|
2061
|
+
let sandboxLockContent = null;
|
|
1680
2062
|
let postSandboxAutoFinishSummary = {
|
|
1681
2063
|
enabled: false,
|
|
1682
2064
|
attempted: 0,
|
|
@@ -1690,7 +2072,6 @@ function runDoctorInSandbox(options, blocked) {
|
|
|
1690
2072
|
note: 'sandbox doctor did not complete successfully',
|
|
1691
2073
|
};
|
|
1692
2074
|
if (nestedResult.status === 0) {
|
|
1693
|
-
protectedBaseRepairSyncResult = syncProtectedBaseDoctorRepairs(options, blocked);
|
|
1694
2075
|
const omxScaffoldOps = ensureOmxScaffold(blocked.repoRoot, Boolean(options.dryRun));
|
|
1695
2076
|
const changedOmxPaths = omxScaffoldOps.filter((operation) => operation.status !== 'unchanged');
|
|
1696
2077
|
if (changedOmxPaths.length === 0) {
|
|
@@ -1710,7 +2091,7 @@ function runDoctorInSandbox(options, blocked) {
|
|
|
1710
2091
|
if (!options.dryRun) {
|
|
1711
2092
|
autoCommitResult = autoCommitDoctorSandboxChanges(metadata);
|
|
1712
2093
|
if (autoCommitResult.status === 'committed') {
|
|
1713
|
-
finishResult = finishDoctorSandboxBranch(blocked, metadata);
|
|
2094
|
+
finishResult = finishDoctorSandboxBranch(blocked, metadata, options);
|
|
1714
2095
|
} else if (autoCommitResult.status === 'no-changes') {
|
|
1715
2096
|
finishResult = {
|
|
1716
2097
|
status: 'skipped',
|
|
@@ -1746,7 +2127,11 @@ function runDoctorInSandbox(options, blocked) {
|
|
|
1746
2127
|
note: `${LOCK_FILE_RELATIVE} missing in sandbox worktree`,
|
|
1747
2128
|
};
|
|
1748
2129
|
} else {
|
|
1749
|
-
const sourceContent =
|
|
2130
|
+
const sourceContent = stripDoctorSandboxLocks(
|
|
2131
|
+
fs.readFileSync(sandboxLockPath, 'utf8'),
|
|
2132
|
+
metadata.branch,
|
|
2133
|
+
);
|
|
2134
|
+
sandboxLockContent = sourceContent;
|
|
1750
2135
|
const destinationContent = fs.readFileSync(baseLockPath, 'utf8');
|
|
1751
2136
|
if (sourceContent === destinationContent) {
|
|
1752
2137
|
lockSyncResult = {
|
|
@@ -1763,6 +2148,62 @@ function runDoctorInSandbox(options, blocked) {
|
|
|
1763
2148
|
}
|
|
1764
2149
|
}
|
|
1765
2150
|
|
|
2151
|
+
protectedBaseRepairSyncResult = mergeDoctorSandboxRepairsBackToProtectedBase(
|
|
2152
|
+
options,
|
|
2153
|
+
blocked,
|
|
2154
|
+
metadata,
|
|
2155
|
+
autoCommitResult,
|
|
2156
|
+
finishResult,
|
|
2157
|
+
);
|
|
2158
|
+
|
|
2159
|
+
syncDoctorLocalSupportFiles(blocked.repoRoot, Boolean(options.dryRun));
|
|
2160
|
+
|
|
2161
|
+
const postMergeOmxScaffoldOps = ensureOmxScaffold(blocked.repoRoot, Boolean(options.dryRun));
|
|
2162
|
+
const postMergeChangedOmxPaths = postMergeOmxScaffoldOps.filter((operation) => operation.status !== 'unchanged');
|
|
2163
|
+
if (postMergeChangedOmxPaths.length === 0) {
|
|
2164
|
+
omxScaffoldSyncResult = {
|
|
2165
|
+
status: 'unchanged',
|
|
2166
|
+
note: '.omx scaffold already in sync',
|
|
2167
|
+
operations: postMergeOmxScaffoldOps,
|
|
2168
|
+
};
|
|
2169
|
+
} else {
|
|
2170
|
+
omxScaffoldSyncResult = {
|
|
2171
|
+
status: options.dryRun ? 'would-sync' : 'synced',
|
|
2172
|
+
note: `${options.dryRun ? 'would sync' : 'synced'} ${postMergeChangedOmxPaths.length} .omx path(s)`,
|
|
2173
|
+
operations: postMergeOmxScaffoldOps,
|
|
2174
|
+
};
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
const postMergeBaseLockPath = path.join(blocked.repoRoot, LOCK_FILE_RELATIVE);
|
|
2178
|
+
if (sandboxLockContent === null) {
|
|
2179
|
+
lockSyncResult = {
|
|
2180
|
+
status: 'skipped',
|
|
2181
|
+
note: `${LOCK_FILE_RELATIVE} missing in sandbox worktree`,
|
|
2182
|
+
};
|
|
2183
|
+
} else if (!fs.existsSync(postMergeBaseLockPath)) {
|
|
2184
|
+
fs.mkdirSync(path.dirname(postMergeBaseLockPath), { recursive: true });
|
|
2185
|
+
fs.writeFileSync(postMergeBaseLockPath, sandboxLockContent, 'utf8');
|
|
2186
|
+
lockSyncResult = {
|
|
2187
|
+
status: 'synced',
|
|
2188
|
+
note: `${LOCK_FILE_RELATIVE} recreated from sandbox`,
|
|
2189
|
+
};
|
|
2190
|
+
} else {
|
|
2191
|
+
const destinationContent = fs.readFileSync(postMergeBaseLockPath, 'utf8');
|
|
2192
|
+
if (sandboxLockContent === destinationContent) {
|
|
2193
|
+
lockSyncResult = {
|
|
2194
|
+
status: 'unchanged',
|
|
2195
|
+
note: `${LOCK_FILE_RELATIVE} already in sync`,
|
|
2196
|
+
};
|
|
2197
|
+
} else {
|
|
2198
|
+
fs.mkdirSync(path.dirname(postMergeBaseLockPath), { recursive: true });
|
|
2199
|
+
fs.writeFileSync(postMergeBaseLockPath, sandboxLockContent, 'utf8');
|
|
2200
|
+
lockSyncResult = {
|
|
2201
|
+
status: 'synced',
|
|
2202
|
+
note: `${LOCK_FILE_RELATIVE} synced from sandbox`,
|
|
2203
|
+
};
|
|
2204
|
+
}
|
|
2205
|
+
}
|
|
2206
|
+
|
|
1766
2207
|
postSandboxAutoFinishSummary = autoFinishReadyAgentBranches(blocked.repoRoot, {
|
|
1767
2208
|
baseBranch: blocked.branch,
|
|
1768
2209
|
dryRun: options.dryRun,
|
|
@@ -1820,14 +2261,28 @@ function runDoctorInSandbox(options, blocked) {
|
|
|
1820
2261
|
console.log(`[${TOOL_NAME}] Doctor sandbox auto-commit skipped: ${autoCommitResult.note}.`);
|
|
1821
2262
|
}
|
|
1822
2263
|
|
|
1823
|
-
if (protectedBaseRepairSyncResult.status === '
|
|
1824
|
-
console.log(`[${TOOL_NAME}]
|
|
2264
|
+
if (protectedBaseRepairSyncResult.status === 'merged') {
|
|
2265
|
+
console.log(`[${TOOL_NAME}] Fast-forwarded tracked doctor repairs into the protected branch workspace.`);
|
|
1825
2266
|
} else if (protectedBaseRepairSyncResult.status === 'unchanged') {
|
|
1826
|
-
console.log(`[${TOOL_NAME}] Protected branch workspace already had the
|
|
1827
|
-
} else if (protectedBaseRepairSyncResult.status === 'would-
|
|
1828
|
-
console.log(`[${TOOL_NAME}] Dry run: would
|
|
2267
|
+
console.log(`[${TOOL_NAME}] Protected branch workspace already had the tracked doctor repairs.`);
|
|
2268
|
+
} else if (protectedBaseRepairSyncResult.status === 'would-merge') {
|
|
2269
|
+
console.log(`[${TOOL_NAME}] Dry run: would fast-forward tracked doctor repairs into the protected branch workspace.`);
|
|
2270
|
+
} else if (protectedBaseRepairSyncResult.status === 'failed') {
|
|
2271
|
+
console.log(`[${TOOL_NAME}] Protected branch tracked repair merge failed: ${protectedBaseRepairSyncResult.note}.`);
|
|
2272
|
+
if (protectedBaseRepairSyncResult.stdout) process.stdout.write(protectedBaseRepairSyncResult.stdout);
|
|
2273
|
+
if (protectedBaseRepairSyncResult.stderr) process.stderr.write(protectedBaseRepairSyncResult.stderr);
|
|
1829
2274
|
} else {
|
|
1830
|
-
console.log(`[${TOOL_NAME}] Protected branch
|
|
2275
|
+
console.log(`[${TOOL_NAME}] Protected branch tracked repair merge skipped: ${protectedBaseRepairSyncResult.note}.`);
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
if (lockSyncResult.status === 'synced') {
|
|
2279
|
+
console.log(
|
|
2280
|
+
`[${TOOL_NAME}] Synced repaired lock registry back to protected branch workspace (${LOCK_FILE_RELATIVE}).`,
|
|
2281
|
+
);
|
|
2282
|
+
} else if (lockSyncResult.status === 'unchanged') {
|
|
2283
|
+
console.log(`[${TOOL_NAME}] Lock registry already synced in protected branch workspace.`);
|
|
2284
|
+
} else {
|
|
2285
|
+
console.log(`[${TOOL_NAME}] Lock registry sync skipped: ${lockSyncResult.note}.`);
|
|
1831
2286
|
}
|
|
1832
2287
|
|
|
1833
2288
|
if (finishResult.status === 'completed') {
|
|
@@ -1845,22 +2300,13 @@ function runDoctorInSandbox(options, blocked) {
|
|
|
1845
2300
|
if (finishResult.stderr) process.stderr.write(finishResult.stderr);
|
|
1846
2301
|
} else if (finishResult.status === 'failed') {
|
|
1847
2302
|
console.log(`[${TOOL_NAME}] Auto-finish flow failed for sandbox branch '${metadata.branch}'.`);
|
|
2303
|
+
console.log(`[guardex] Auto-finish flow failed for sandbox branch '${metadata.branch}'.`);
|
|
1848
2304
|
if (finishResult.stdout) process.stdout.write(finishResult.stdout);
|
|
1849
2305
|
if (finishResult.stderr) process.stderr.write(finishResult.stderr);
|
|
1850
2306
|
} else {
|
|
1851
2307
|
console.log(`[${TOOL_NAME}] Auto-finish skipped: ${finishResult.note}.`);
|
|
1852
2308
|
}
|
|
1853
2309
|
|
|
1854
|
-
if (lockSyncResult.status === 'synced') {
|
|
1855
|
-
console.log(
|
|
1856
|
-
`[${TOOL_NAME}] Synced repaired lock registry back to protected branch workspace (${LOCK_FILE_RELATIVE}).`,
|
|
1857
|
-
);
|
|
1858
|
-
} else if (lockSyncResult.status === 'unchanged') {
|
|
1859
|
-
console.log(`[${TOOL_NAME}] Lock registry already synced in protected branch workspace.`);
|
|
1860
|
-
} else {
|
|
1861
|
-
console.log(`[${TOOL_NAME}] Lock registry sync skipped: ${lockSyncResult.note}.`);
|
|
1862
|
-
}
|
|
1863
|
-
|
|
1864
2310
|
if (postSandboxAutoFinishSummary.enabled) {
|
|
1865
2311
|
console.log(
|
|
1866
2312
|
`[${TOOL_NAME}] Auto-finish sweep (base=${blocked.branch}): attempted=${postSandboxAutoFinishSummary.attempted}, completed=${postSandboxAutoFinishSummary.completed}, skipped=${postSandboxAutoFinishSummary.skipped}, failed=${postSandboxAutoFinishSummary.failed}`,
|
|
@@ -1895,12 +2341,89 @@ function runDoctorInSandbox(options, blocked) {
|
|
|
1895
2341
|
) {
|
|
1896
2342
|
exitCode = 1;
|
|
1897
2343
|
}
|
|
2344
|
+
if (exitCode === 0 && protectedBaseRepairSyncResult.status === 'failed') {
|
|
2345
|
+
exitCode = 1;
|
|
2346
|
+
}
|
|
1898
2347
|
process.exitCode = exitCode;
|
|
1899
2348
|
return;
|
|
1900
2349
|
}
|
|
1901
2350
|
process.exitCode = 1;
|
|
1902
2351
|
}
|
|
1903
2352
|
|
|
2353
|
+
function runSetupInSandbox(options, blocked, repoLabel = '') {
|
|
2354
|
+
const startResult = startProtectedBaseSandbox(blocked, {
|
|
2355
|
+
taskName: `${SHORT_TOOL_NAME}-setup`,
|
|
2356
|
+
sandboxSuffix: 'gx-setup',
|
|
2357
|
+
});
|
|
2358
|
+
const metadata = startResult.metadata;
|
|
2359
|
+
|
|
2360
|
+
if (startResult.stdout) process.stdout.write(startResult.stdout);
|
|
2361
|
+
if (startResult.stderr) process.stderr.write(startResult.stderr);
|
|
2362
|
+
console.log(
|
|
2363
|
+
`[${TOOL_NAME}] setup blocked on protected branch '${blocked.branch}' in an initialized repo; ` +
|
|
2364
|
+
'refreshing through a sandbox worktree and syncing managed bootstrap files back locally.',
|
|
2365
|
+
);
|
|
2366
|
+
|
|
2367
|
+
const sandboxTarget = resolveSandboxTarget(blocked.repoRoot, metadata.worktreePath, options.target);
|
|
2368
|
+
const nestedResult = run(
|
|
2369
|
+
process.execPath,
|
|
2370
|
+
[__filename, ...buildSandboxSetupArgs(options, sandboxTarget)],
|
|
2371
|
+
{ cwd: metadata.worktreePath },
|
|
2372
|
+
);
|
|
2373
|
+
if (isSpawnFailure(nestedResult)) {
|
|
2374
|
+
throw nestedResult.error;
|
|
2375
|
+
}
|
|
2376
|
+
if (nestedResult.status !== 0) {
|
|
2377
|
+
if (nestedResult.stdout) process.stdout.write(nestedResult.stdout);
|
|
2378
|
+
if (nestedResult.stderr) process.stderr.write(nestedResult.stderr);
|
|
2379
|
+
throw new Error(
|
|
2380
|
+
`sandboxed setup failed for protected branch '${blocked.branch}'. ` +
|
|
2381
|
+
`Inspect sandbox at ${metadata.worktreePath}`,
|
|
2382
|
+
);
|
|
2383
|
+
}
|
|
2384
|
+
|
|
2385
|
+
const syncOptions = {
|
|
2386
|
+
...options,
|
|
2387
|
+
target: blocked.repoRoot,
|
|
2388
|
+
recursive: false,
|
|
2389
|
+
allowProtectedBaseWrite: true,
|
|
2390
|
+
};
|
|
2391
|
+
const { installPayload, fixPayload, parentWorkspace } = runSetupBootstrapInternal(syncOptions);
|
|
2392
|
+
printOperations(`Setup/install${repoLabel}`, installPayload, syncOptions.dryRun);
|
|
2393
|
+
printOperations(`Setup/fix${repoLabel}`, fixPayload, syncOptions.dryRun);
|
|
2394
|
+
if (!syncOptions.dryRun && parentWorkspace) {
|
|
2395
|
+
console.log(`[${TOOL_NAME}] Parent workspace view: ${parentWorkspace.workspacePath}`);
|
|
2396
|
+
}
|
|
2397
|
+
|
|
2398
|
+
const scanResult = runScanInternal({ target: blocked.repoRoot, json: false });
|
|
2399
|
+
const currentBaseBranch = currentBranchName(scanResult.repoRoot);
|
|
2400
|
+
const autoFinishSummary = autoFinishReadyAgentBranches(scanResult.repoRoot, {
|
|
2401
|
+
baseBranch: currentBaseBranch,
|
|
2402
|
+
dryRun: syncOptions.dryRun,
|
|
2403
|
+
});
|
|
2404
|
+
printScanResult(scanResult, false);
|
|
2405
|
+
if (autoFinishSummary.enabled) {
|
|
2406
|
+
console.log(
|
|
2407
|
+
`[${TOOL_NAME}] Auto-finish sweep (base=${currentBaseBranch}): attempted=${autoFinishSummary.attempted}, completed=${autoFinishSummary.completed}, skipped=${autoFinishSummary.skipped}, failed=${autoFinishSummary.failed}`,
|
|
2408
|
+
);
|
|
2409
|
+
for (const detail of autoFinishSummary.details) {
|
|
2410
|
+
console.log(`[${TOOL_NAME}] ${detail}`);
|
|
2411
|
+
}
|
|
2412
|
+
} else if (autoFinishSummary.details.length > 0) {
|
|
2413
|
+
console.log(`[${TOOL_NAME}] ${autoFinishSummary.details[0]}`);
|
|
2414
|
+
}
|
|
2415
|
+
|
|
2416
|
+
const cleanupResult = cleanupProtectedBaseSandbox(blocked.repoRoot, metadata);
|
|
2417
|
+
console.log(
|
|
2418
|
+
`[${TOOL_NAME}] Protected-base setup sandbox cleanup: ${cleanupResult.note} ` +
|
|
2419
|
+
`(worktree=${cleanupResult.worktree}, branch=${cleanupResult.branch}).`,
|
|
2420
|
+
);
|
|
2421
|
+
|
|
2422
|
+
return {
|
|
2423
|
+
scanResult,
|
|
2424
|
+
};
|
|
2425
|
+
}
|
|
2426
|
+
|
|
1904
2427
|
function parseTargetFlag(rawArgs, defaultTarget = process.cwd()) {
|
|
1905
2428
|
const remaining = [];
|
|
1906
2429
|
let target = defaultTarget;
|
|
@@ -2082,6 +2605,19 @@ function inferGithubRepoFromOrigin(repoRoot) {
|
|
|
2082
2605
|
return `github.com/${slug}`;
|
|
2083
2606
|
}
|
|
2084
2607
|
|
|
2608
|
+
function inferGithubRepoSlug(rawValue) {
|
|
2609
|
+
const raw = String(rawValue || '').trim();
|
|
2610
|
+
if (!raw) return '';
|
|
2611
|
+
const match = raw.match(/github\.com[:/](.+?)(?:\.git)?$/i);
|
|
2612
|
+
if (!match) return '';
|
|
2613
|
+
const slug = String(match[1] || '')
|
|
2614
|
+
.replace(/^\/+/, '')
|
|
2615
|
+
.replace(/^github\.com\//i, '')
|
|
2616
|
+
.trim();
|
|
2617
|
+
if (!slug || !slug.includes('/')) return '';
|
|
2618
|
+
return slug;
|
|
2619
|
+
}
|
|
2620
|
+
|
|
2085
2621
|
function resolveScorecardRepo(repoRoot, explicitRepo) {
|
|
2086
2622
|
if (explicitRepo) {
|
|
2087
2623
|
return explicitRepo.trim();
|
|
@@ -2565,6 +3101,66 @@ function currentBranchName(repoRoot) {
|
|
|
2565
3101
|
return branch;
|
|
2566
3102
|
}
|
|
2567
3103
|
|
|
3104
|
+
function repoHasHeadCommit(repoRoot) {
|
|
3105
|
+
return gitRun(repoRoot, ['rev-parse', '--verify', 'HEAD'], { allowFailure: true }).status === 0;
|
|
3106
|
+
}
|
|
3107
|
+
|
|
3108
|
+
function readBranchDisplayName(repoRoot) {
|
|
3109
|
+
const symbolic = gitRun(repoRoot, ['symbolic-ref', '--quiet', '--short', 'HEAD'], { allowFailure: true });
|
|
3110
|
+
if (symbolic.status === 0) {
|
|
3111
|
+
const branch = String(symbolic.stdout || '').trim();
|
|
3112
|
+
if (!branch) {
|
|
3113
|
+
return '(unknown)';
|
|
3114
|
+
}
|
|
3115
|
+
return repoHasHeadCommit(repoRoot) ? branch : `${branch} (unborn; no commits yet)`;
|
|
3116
|
+
}
|
|
3117
|
+
|
|
3118
|
+
const detached = gitRun(repoRoot, ['rev-parse', '--short', 'HEAD'], { allowFailure: true });
|
|
3119
|
+
if (detached.status === 0) {
|
|
3120
|
+
return `(detached at ${String(detached.stdout || '').trim()})`;
|
|
3121
|
+
}
|
|
3122
|
+
return '(unknown)';
|
|
3123
|
+
}
|
|
3124
|
+
|
|
3125
|
+
function repoHasOriginRemote(repoRoot) {
|
|
3126
|
+
return gitRun(repoRoot, ['remote', 'get-url', 'origin'], { allowFailure: true }).status === 0;
|
|
3127
|
+
}
|
|
3128
|
+
|
|
3129
|
+
function detectComposeHintFiles(repoRoot) {
|
|
3130
|
+
return COMPOSE_HINT_FILES.filter((relativePath) => fs.existsSync(path.join(repoRoot, relativePath)));
|
|
3131
|
+
}
|
|
3132
|
+
|
|
3133
|
+
function printSetupRepoHints(repoRoot, baseBranch, repoLabel = '') {
|
|
3134
|
+
const branchDisplay = readBranchDisplayName(repoRoot);
|
|
3135
|
+
const hasHeadCommit = repoHasHeadCommit(repoRoot);
|
|
3136
|
+
const hasOrigin = repoHasOriginRemote(repoRoot);
|
|
3137
|
+
const composeFiles = detectComposeHintFiles(repoRoot);
|
|
3138
|
+
if (hasHeadCommit && hasOrigin && composeFiles.length === 0) {
|
|
3139
|
+
return;
|
|
3140
|
+
}
|
|
3141
|
+
|
|
3142
|
+
const label = repoLabel ? ` ${repoLabel}` : '';
|
|
3143
|
+
if (!hasHeadCommit) {
|
|
3144
|
+
console.log(`[${TOOL_NAME}] Fresh repo onboarding${label}: current branch is ${branchDisplay}.`);
|
|
3145
|
+
console.log(`[${TOOL_NAME}] Bootstrap commit${label}: git add . && git commit -m "bootstrap gitguardex"`);
|
|
3146
|
+
console.log(
|
|
3147
|
+
`[${TOOL_NAME}] First agent flow${label}: ` +
|
|
3148
|
+
`bash scripts/agent-branch-start.sh "<task>" "codex" -> ` +
|
|
3149
|
+
`python3 scripts/agent-file-locks.py claim --branch "$(git branch --show-current)" <file...> -> ` +
|
|
3150
|
+
`bash scripts/agent-branch-finish.sh --branch "$(git branch --show-current)" --base ${baseBranch} --via-pr --wait-for-merge`,
|
|
3151
|
+
);
|
|
3152
|
+
}
|
|
3153
|
+
if (!hasOrigin) {
|
|
3154
|
+
console.log(`[${TOOL_NAME}] No origin remote${label}: finish and auto-merge flows stay local until you add one.`);
|
|
3155
|
+
}
|
|
3156
|
+
if (composeFiles.length > 0) {
|
|
3157
|
+
console.log(
|
|
3158
|
+
`[${TOOL_NAME}] Docker Compose helper${label}: detected ${composeFiles.join(', ')}. ` +
|
|
3159
|
+
`Set GUARDEX_DOCKER_SERVICE and run 'bash scripts/guardex-docker-loader.sh -- <command...>'.`,
|
|
3160
|
+
);
|
|
3161
|
+
}
|
|
3162
|
+
}
|
|
3163
|
+
|
|
2568
3164
|
function workingTreeIsDirty(repoRoot) {
|
|
2569
3165
|
const result = gitRun(repoRoot, ['status', '--porcelain'], { allowFailure: true });
|
|
2570
3166
|
if (result.status !== 0) {
|
|
@@ -3376,6 +3972,17 @@ function parseVersionString(version) {
|
|
|
3376
3972
|
];
|
|
3377
3973
|
}
|
|
3378
3974
|
|
|
3975
|
+
function compareParsedVersions(left, right) {
|
|
3976
|
+
if (!left || !right) return 0;
|
|
3977
|
+
for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
|
|
3978
|
+
const leftValue = left[index] || 0;
|
|
3979
|
+
const rightValue = right[index] || 0;
|
|
3980
|
+
if (leftValue > rightValue) return 1;
|
|
3981
|
+
if (leftValue < rightValue) return -1;
|
|
3982
|
+
}
|
|
3983
|
+
return 0;
|
|
3984
|
+
}
|
|
3985
|
+
|
|
3379
3986
|
function isNewerVersion(latest, current) {
|
|
3380
3987
|
const latestParts = parseVersionString(latest);
|
|
3381
3988
|
const currentParts = parseVersionString(current);
|
|
@@ -3384,11 +3991,7 @@ function isNewerVersion(latest, current) {
|
|
|
3384
3991
|
return String(latest || '').trim() !== String(current || '').trim();
|
|
3385
3992
|
}
|
|
3386
3993
|
|
|
3387
|
-
|
|
3388
|
-
if (latestParts[index] > currentParts[index]) return true;
|
|
3389
|
-
if (latestParts[index] < currentParts[index]) return false;
|
|
3390
|
-
}
|
|
3391
|
-
return false;
|
|
3994
|
+
return compareParsedVersions(latestParts, currentParts) > 0;
|
|
3392
3995
|
}
|
|
3393
3996
|
|
|
3394
3997
|
function parseNpmVersionOutput(stdout) {
|
|
@@ -4078,8 +4681,7 @@ function runFixInternal(options) {
|
|
|
4078
4681
|
function runScanInternal(options) {
|
|
4079
4682
|
const repoRoot = resolveRepoRoot(options.target);
|
|
4080
4683
|
const guardexToggle = resolveGuardexRepoToggle(repoRoot);
|
|
4081
|
-
const
|
|
4082
|
-
const branch = currentBranchResult.status === 0 ? currentBranchResult.stdout.trim() : '(unknown)';
|
|
4684
|
+
const branch = readBranchDisplayName(repoRoot);
|
|
4083
4685
|
if (!guardexToggle.enabled) {
|
|
4084
4686
|
return {
|
|
4085
4687
|
repoRoot,
|
|
@@ -4543,6 +5145,8 @@ function doctor(rawArgs) {
|
|
|
4543
5145
|
...(options.skipPackageJson ? ['--skip-package-json'] : []),
|
|
4544
5146
|
...(options.skipGitignore ? ['--no-gitignore'] : []),
|
|
4545
5147
|
...(options.dryRun ? ['--dry-run'] : []),
|
|
5148
|
+
// Recursive child doctor runs should report pending PR state immediately instead of blocking the parent loop.
|
|
5149
|
+
'--no-wait-for-merge',
|
|
4546
5150
|
...(options.json ? ['--json'] : []),
|
|
4547
5151
|
...(options.allowProtectedBaseWrite ? ['--allow-protected-base-write'] : []),
|
|
4548
5152
|
],
|
|
@@ -4662,7 +5266,7 @@ function doctor(rawArgs) {
|
|
|
4662
5266
|
return;
|
|
4663
5267
|
}
|
|
4664
5268
|
|
|
4665
|
-
printOperations('Doctor/fix', fixPayload,
|
|
5269
|
+
printOperations('Doctor/fix', fixPayload, options.dryRun);
|
|
4666
5270
|
printScanResult(scanResult, false);
|
|
4667
5271
|
if (scanResult.guardexEnabled === false) {
|
|
4668
5272
|
console.log(`[${TOOL_NAME}] Repo-local Guardex enforcement is intentionally disabled.`);
|
|
@@ -5171,31 +5775,24 @@ function setup(rawArgs) {
|
|
|
5171
5775
|
console.log(`[${TOOL_NAME}] ── Setup target: ${repoPath} ──`);
|
|
5172
5776
|
}
|
|
5173
5777
|
|
|
5174
|
-
|
|
5175
|
-
|
|
5176
|
-
|
|
5177
|
-
|
|
5178
|
-
|
|
5778
|
+
const blocked = protectedBaseWriteBlock(perRepoOptions);
|
|
5779
|
+
if (blocked) {
|
|
5780
|
+
const sandboxResult = runSetupInSandbox(perRepoOptions, blocked, repoLabel);
|
|
5781
|
+
aggregateErrors += sandboxResult.scanResult.errors;
|
|
5782
|
+
aggregateWarnings += sandboxResult.scanResult.warnings;
|
|
5783
|
+
lastScanResult = sandboxResult.scanResult;
|
|
5784
|
+
continue;
|
|
5179
5785
|
}
|
|
5180
|
-
printOperations(`Setup/install${repoLabel}`, installPayload, perRepoOptions.dryRun);
|
|
5181
5786
|
|
|
5182
|
-
const fixPayload =
|
|
5183
|
-
|
|
5184
|
-
dryRun: perRepoOptions.dryRun,
|
|
5185
|
-
force: perRepoOptions.force,
|
|
5186
|
-
dropStaleLocks: true,
|
|
5187
|
-
skipAgents: perRepoOptions.skipAgents,
|
|
5188
|
-
skipPackageJson: perRepoOptions.skipPackageJson,
|
|
5189
|
-
skipGitignore: perRepoOptions.skipGitignore,
|
|
5190
|
-
});
|
|
5787
|
+
const { installPayload, fixPayload, parentWorkspace } = runSetupBootstrapInternal(perRepoOptions);
|
|
5788
|
+
printOperations(`Setup/install${repoLabel}`, installPayload, perRepoOptions.dryRun);
|
|
5191
5789
|
printOperations(`Setup/fix${repoLabel}`, fixPayload, perRepoOptions.dryRun);
|
|
5192
5790
|
|
|
5193
5791
|
if (perRepoOptions.dryRun) {
|
|
5194
5792
|
continue;
|
|
5195
5793
|
}
|
|
5196
5794
|
|
|
5197
|
-
if (
|
|
5198
|
-
const parentWorkspace = buildParentWorkspaceView(installPayload.repoRoot);
|
|
5795
|
+
if (parentWorkspace) {
|
|
5199
5796
|
console.log(`[${TOOL_NAME}] Parent workspace view: ${parentWorkspace.workspacePath}`);
|
|
5200
5797
|
}
|
|
5201
5798
|
|
|
@@ -5216,6 +5813,7 @@ function setup(rawArgs) {
|
|
|
5216
5813
|
} else if (autoFinishSummary.details.length > 0) {
|
|
5217
5814
|
console.log(`[${TOOL_NAME}] ${autoFinishSummary.details[0]}`);
|
|
5218
5815
|
}
|
|
5816
|
+
printSetupRepoHints(scanResult.repoRoot, currentBaseBranch, repoLabel);
|
|
5219
5817
|
|
|
5220
5818
|
aggregateErrors += scanResult.errors;
|
|
5221
5819
|
aggregateWarnings += scanResult.warnings;
|
|
@@ -5275,6 +5873,156 @@ function ensureCleanWorkingTree(repoRoot) {
|
|
|
5275
5873
|
}
|
|
5276
5874
|
}
|
|
5277
5875
|
|
|
5876
|
+
function readReleaseRepoPackageJson(repoRoot) {
|
|
5877
|
+
const manifestPath = path.join(repoRoot, 'package.json');
|
|
5878
|
+
if (!fs.existsSync(manifestPath)) {
|
|
5879
|
+
throw new Error(`Release blocked: package.json missing in ${repoRoot}`);
|
|
5880
|
+
}
|
|
5881
|
+
|
|
5882
|
+
try {
|
|
5883
|
+
return JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
5884
|
+
} catch (error) {
|
|
5885
|
+
throw new Error(`Release blocked: unable to parse package.json in ${repoRoot}: ${error.message}`);
|
|
5886
|
+
}
|
|
5887
|
+
}
|
|
5888
|
+
|
|
5889
|
+
function resolveReleaseGithubRepo(repoRoot) {
|
|
5890
|
+
const releasePackageJson = readReleaseRepoPackageJson(repoRoot);
|
|
5891
|
+
const fromManifest = inferGithubRepoSlug(
|
|
5892
|
+
releasePackageJson.repository &&
|
|
5893
|
+
(releasePackageJson.repository.url || releasePackageJson.repository),
|
|
5894
|
+
);
|
|
5895
|
+
if (fromManifest) {
|
|
5896
|
+
return fromManifest;
|
|
5897
|
+
}
|
|
5898
|
+
|
|
5899
|
+
const fromOrigin = inferGithubRepoSlug(readGitConfig(repoRoot, 'remote.origin.url'));
|
|
5900
|
+
if (fromOrigin) {
|
|
5901
|
+
return fromOrigin;
|
|
5902
|
+
}
|
|
5903
|
+
|
|
5904
|
+
throw new Error(
|
|
5905
|
+
'Release blocked: unable to resolve GitHub repo from package.json repository URL or origin remote.',
|
|
5906
|
+
);
|
|
5907
|
+
}
|
|
5908
|
+
|
|
5909
|
+
function readRepoReadme(repoRoot) {
|
|
5910
|
+
const readmePath = path.join(repoRoot, 'README.md');
|
|
5911
|
+
if (!fs.existsSync(readmePath)) {
|
|
5912
|
+
throw new Error(`Release blocked: README.md missing in ${repoRoot}`);
|
|
5913
|
+
}
|
|
5914
|
+
return fs.readFileSync(readmePath, 'utf8');
|
|
5915
|
+
}
|
|
5916
|
+
|
|
5917
|
+
function parseReadmeReleaseEntries(readmeContent) {
|
|
5918
|
+
const releaseNotesIndex = String(readmeContent || '').indexOf('## Release notes');
|
|
5919
|
+
if (releaseNotesIndex < 0) {
|
|
5920
|
+
throw new Error('Release blocked: README.md is missing the "## Release notes" section');
|
|
5921
|
+
}
|
|
5922
|
+
|
|
5923
|
+
const releaseNotesContent = String(readmeContent || '').slice(releaseNotesIndex);
|
|
5924
|
+
const entries = [];
|
|
5925
|
+
const lines = releaseNotesContent.split(/\r?\n/);
|
|
5926
|
+
let currentTag = '';
|
|
5927
|
+
let currentLines = [];
|
|
5928
|
+
|
|
5929
|
+
function flushEntry() {
|
|
5930
|
+
if (!currentTag) {
|
|
5931
|
+
return;
|
|
5932
|
+
}
|
|
5933
|
+
const body = currentLines.join('\n').trim();
|
|
5934
|
+
if (body) {
|
|
5935
|
+
entries.push({ tag: currentTag, body, version: parseVersionString(currentTag) });
|
|
5936
|
+
}
|
|
5937
|
+
currentTag = '';
|
|
5938
|
+
currentLines = [];
|
|
5939
|
+
}
|
|
5940
|
+
|
|
5941
|
+
for (const line of lines) {
|
|
5942
|
+
const headingMatch = line.match(/^###\s+(v\d+\.\d+\.\d+)\s*$/);
|
|
5943
|
+
if (headingMatch) {
|
|
5944
|
+
flushEntry();
|
|
5945
|
+
currentTag = headingMatch[1];
|
|
5946
|
+
continue;
|
|
5947
|
+
}
|
|
5948
|
+
|
|
5949
|
+
if (!currentTag) {
|
|
5950
|
+
continue;
|
|
5951
|
+
}
|
|
5952
|
+
|
|
5953
|
+
if (/^<\/details>\s*$/.test(line) || /^##\s+/.test(line)) {
|
|
5954
|
+
flushEntry();
|
|
5955
|
+
continue;
|
|
5956
|
+
}
|
|
5957
|
+
|
|
5958
|
+
currentLines.push(line);
|
|
5959
|
+
}
|
|
5960
|
+
|
|
5961
|
+
flushEntry();
|
|
5962
|
+
|
|
5963
|
+
if (entries.length === 0) {
|
|
5964
|
+
throw new Error('Release blocked: README.md did not yield any versioned release-note sections');
|
|
5965
|
+
}
|
|
5966
|
+
|
|
5967
|
+
return entries;
|
|
5968
|
+
}
|
|
5969
|
+
|
|
5970
|
+
function resolvePreviousPublishedReleaseTag(repoSlug, currentTag) {
|
|
5971
|
+
const result = run(GH_BIN, ['release', 'list', '--repo', repoSlug, '--limit', '20'], {
|
|
5972
|
+
timeout: 20_000,
|
|
5973
|
+
});
|
|
5974
|
+
if (result.error) {
|
|
5975
|
+
throw new Error(`Release blocked: unable to run '${GH_BIN} release list': ${result.error.message}`);
|
|
5976
|
+
}
|
|
5977
|
+
if (result.status !== 0) {
|
|
5978
|
+
const details = (result.stderr || result.stdout || '').trim();
|
|
5979
|
+
throw new Error(`Release blocked: unable to list GitHub releases.${details ? `\n${details}` : ''}`);
|
|
5980
|
+
}
|
|
5981
|
+
|
|
5982
|
+
const tags = String(result.stdout || '')
|
|
5983
|
+
.split('\n')
|
|
5984
|
+
.map((line) => line.split('\t')[0].trim())
|
|
5985
|
+
.filter(Boolean);
|
|
5986
|
+
|
|
5987
|
+
return tags.find((tag) => tag !== currentTag) || '';
|
|
5988
|
+
}
|
|
5989
|
+
|
|
5990
|
+
function selectReleaseEntriesForWindow(entries, currentTag, previousTag) {
|
|
5991
|
+
const currentVersion = parseVersionString(currentTag);
|
|
5992
|
+
if (!currentVersion) {
|
|
5993
|
+
throw new Error(`Release blocked: invalid current version tag '${currentTag}'`);
|
|
5994
|
+
}
|
|
5995
|
+
const previousVersion = previousTag ? parseVersionString(previousTag) : null;
|
|
5996
|
+
|
|
5997
|
+
const selected = entries.filter((entry) => {
|
|
5998
|
+
if (!entry.version) return false;
|
|
5999
|
+
if (compareParsedVersions(entry.version, currentVersion) > 0) return false;
|
|
6000
|
+
if (!previousVersion) return entry.tag === currentTag;
|
|
6001
|
+
return compareParsedVersions(entry.version, previousVersion) > 0;
|
|
6002
|
+
});
|
|
6003
|
+
|
|
6004
|
+
if (!selected.some((entry) => entry.tag === currentTag)) {
|
|
6005
|
+
throw new Error(`Release blocked: README.md is missing release notes for ${currentTag}`);
|
|
6006
|
+
}
|
|
6007
|
+
|
|
6008
|
+
return selected;
|
|
6009
|
+
}
|
|
6010
|
+
|
|
6011
|
+
function renderGeneratedReleaseNotes(entries, currentTag, previousTag) {
|
|
6012
|
+
const intro = previousTag ? `Changes since ${previousTag}.` : `Changes in ${currentTag}.`;
|
|
6013
|
+
const sections = entries
|
|
6014
|
+
.map((entry) => `### ${entry.tag}\n${entry.body}`)
|
|
6015
|
+
.join('\n\n');
|
|
6016
|
+
return `GitGuardex ${currentTag}\n\n${intro}\n\n${sections}`;
|
|
6017
|
+
}
|
|
6018
|
+
|
|
6019
|
+
function buildReleaseNotesFromReadme(repoRoot, currentTag, previousTag) {
|
|
6020
|
+
const readme = readRepoReadme(repoRoot);
|
|
6021
|
+
const entries = parseReadmeReleaseEntries(readme);
|
|
6022
|
+
const selected = selectReleaseEntriesForWindow(entries, currentTag, previousTag);
|
|
6023
|
+
return renderGeneratedReleaseNotes(selected, currentTag, previousTag);
|
|
6024
|
+
}
|
|
6025
|
+
|
|
5278
6026
|
function release(rawArgs) {
|
|
5279
6027
|
if (rawArgs.length > 0) {
|
|
5280
6028
|
throw new Error(`Unknown option: ${rawArgs[0]}`);
|
|
@@ -5290,13 +6038,74 @@ function release(rawArgs) {
|
|
|
5290
6038
|
ensureMainBranch(repoRoot);
|
|
5291
6039
|
ensureCleanWorkingTree(repoRoot);
|
|
5292
6040
|
|
|
5293
|
-
|
|
5294
|
-
|
|
5295
|
-
|
|
5296
|
-
|
|
6041
|
+
if (!isCommandAvailable(GH_BIN)) {
|
|
6042
|
+
throw new Error(`Release blocked: '${GH_BIN}' is not available`);
|
|
6043
|
+
}
|
|
6044
|
+
|
|
6045
|
+
const ghAuthStatus = run(GH_BIN, ['auth', 'status'], { timeout: 20_000 });
|
|
6046
|
+
if (ghAuthStatus.error) {
|
|
6047
|
+
throw new Error(`Release blocked: unable to run '${GH_BIN} auth status': ${ghAuthStatus.error.message}`);
|
|
6048
|
+
}
|
|
6049
|
+
if (ghAuthStatus.status !== 0) {
|
|
6050
|
+
const details = (ghAuthStatus.stderr || ghAuthStatus.stdout || '').trim();
|
|
6051
|
+
throw new Error(`Release blocked: '${GH_BIN}' auth is unavailable.${details ? `\n${details}` : ''}`);
|
|
6052
|
+
}
|
|
6053
|
+
|
|
6054
|
+
const releasePackageJson = readReleaseRepoPackageJson(repoRoot);
|
|
6055
|
+
const repoSlug = resolveReleaseGithubRepo(repoRoot);
|
|
6056
|
+
const currentTag = `v${releasePackageJson.version}`;
|
|
6057
|
+
const previousTag = resolvePreviousPublishedReleaseTag(repoSlug, currentTag);
|
|
6058
|
+
const notes = buildReleaseNotesFromReadme(repoRoot, currentTag, previousTag);
|
|
6059
|
+
const headCommit = gitRun(repoRoot, ['rev-parse', 'HEAD']).stdout.trim();
|
|
6060
|
+
|
|
6061
|
+
const existingRelease = run(GH_BIN, ['release', 'view', currentTag, '--repo', repoSlug], {
|
|
6062
|
+
timeout: 20_000,
|
|
6063
|
+
});
|
|
6064
|
+
if (existingRelease.error) {
|
|
6065
|
+
throw new Error(`Release blocked: unable to run '${GH_BIN} release view': ${existingRelease.error.message}`);
|
|
6066
|
+
}
|
|
6067
|
+
|
|
6068
|
+
const releaseArgs =
|
|
6069
|
+
existingRelease.status === 0
|
|
6070
|
+
? ['release', 'edit', currentTag, '--repo', repoSlug, '--title', currentTag, '--notes', notes]
|
|
6071
|
+
: [
|
|
6072
|
+
'release',
|
|
6073
|
+
'create',
|
|
6074
|
+
currentTag,
|
|
6075
|
+
'--repo',
|
|
6076
|
+
repoSlug,
|
|
6077
|
+
'--target',
|
|
6078
|
+
headCommit,
|
|
6079
|
+
'--title',
|
|
6080
|
+
currentTag,
|
|
6081
|
+
'--notes',
|
|
6082
|
+
notes,
|
|
6083
|
+
];
|
|
6084
|
+
|
|
6085
|
+
console.log(
|
|
6086
|
+
`[${TOOL_NAME}] ${existingRelease.status === 0 ? 'Updating' : 'Creating'} GitHub release ${currentTag} on ${repoSlug}`,
|
|
6087
|
+
);
|
|
6088
|
+
if (previousTag) {
|
|
6089
|
+
console.log(`[${TOOL_NAME}] Aggregating README release notes newer than ${previousTag}.`);
|
|
6090
|
+
} else {
|
|
6091
|
+
console.log(`[${TOOL_NAME}] No earlier published GitHub release found; using only ${currentTag}.`);
|
|
5297
6092
|
}
|
|
5298
6093
|
|
|
5299
|
-
|
|
6094
|
+
const releaseResult = run(GH_BIN, releaseArgs, { cwd: repoRoot, timeout: 60_000 });
|
|
6095
|
+
if (releaseResult.error) {
|
|
6096
|
+
throw new Error(`Release blocked: unable to run '${GH_BIN} release': ${releaseResult.error.message}`);
|
|
6097
|
+
}
|
|
6098
|
+
if (releaseResult.status !== 0) {
|
|
6099
|
+
const details = (releaseResult.stderr || releaseResult.stdout || '').trim();
|
|
6100
|
+
throw new Error(`GitHub release command failed.${details ? `\n${details}` : ''}`);
|
|
6101
|
+
}
|
|
6102
|
+
|
|
6103
|
+
const releaseUrl = String(releaseResult.stdout || '').trim();
|
|
6104
|
+
if (releaseUrl) {
|
|
6105
|
+
console.log(releaseUrl);
|
|
6106
|
+
}
|
|
6107
|
+
|
|
6108
|
+
console.log(`[${TOOL_NAME}] ✅ GitHub release ${currentTag} is synced to the README history.`);
|
|
5300
6109
|
process.exitCode = 0;
|
|
5301
6110
|
}
|
|
5302
6111
|
|
|
@@ -6100,6 +6909,7 @@ function main() {
|
|
|
6100
6909
|
}
|
|
6101
6910
|
|
|
6102
6911
|
if (command === '--version' || command === '-v' || command === 'version') {
|
|
6912
|
+
maybeSelfUpdateBeforeStatus();
|
|
6103
6913
|
console.log(packageJson.version);
|
|
6104
6914
|
return;
|
|
6105
6915
|
}
|