@imdeadpool/guardex 7.0.21 → 7.0.23
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 +39 -29
- package/package.json +5 -1
- package/src/cli/args.js +89 -0
- package/src/cli/main.js +645 -2873
- package/src/context.js +195 -31
- package/src/core/stdin.js +52 -0
- package/src/core/versions.js +33 -0
- package/src/doctor/index.js +1071 -0
- package/src/finish/index.js +456 -358
- package/src/git/index.js +604 -1
- package/src/hooks/index.js +64 -0
- package/src/output/index.js +72 -5
- package/src/report/session-severity.js +213 -0
- package/src/sandbox/index.js +301 -52
- package/src/scaffold/index.js +627 -0
- package/src/toolchain/index.js +559 -179
- package/templates/AGENTS.multiagent-safety.md +25 -0
- package/templates/scripts/agent-branch-finish.sh +86 -6
- package/templates/scripts/agent-session-state.js +62 -1
- package/templates/scripts/agent-worktree-prune.sh +15 -1
- package/templates/scripts/codex-agent.sh +38 -0
- package/templates/scripts/install-vscode-active-agents-extension.js +38 -11
- package/templates/scripts/openspec/init-plan-workspace.sh +34 -3
- package/templates/vscode/guardex-active-agents/README.md +9 -6
- package/templates/vscode/guardex-active-agents/extension.js +805 -77
- package/templates/vscode/guardex-active-agents/icon.png +0 -0
- package/templates/vscode/guardex-active-agents/package.json +15 -3
- package/templates/vscode/guardex-active-agents/session-schema.js +311 -4
package/src/git/index.js
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
const fs = require('node:fs');
|
|
2
|
-
const {
|
|
2
|
+
const {
|
|
3
|
+
path,
|
|
4
|
+
TOOL_NAME,
|
|
5
|
+
GIT_PROTECTED_BRANCHES_KEY,
|
|
6
|
+
GIT_BASE_BRANCH_KEY,
|
|
7
|
+
GIT_SYNC_STRATEGY_KEY,
|
|
8
|
+
DEFAULT_PROTECTED_BRANCHES,
|
|
9
|
+
DEFAULT_BASE_BRANCH,
|
|
10
|
+
DEFAULT_SYNC_STRATEGY,
|
|
11
|
+
COMPOSE_HINT_FILES,
|
|
12
|
+
LOCK_FILE_RELATIVE,
|
|
13
|
+
} = require('../context');
|
|
3
14
|
const { run } = require('../core/runtime');
|
|
4
15
|
|
|
5
16
|
function gitRun(repoRoot, args, { allowFailure = false } = {}) {
|
|
@@ -113,10 +124,602 @@ function discoverNestedGitRepos(rootPath, opts = {}) {
|
|
|
113
124
|
return root ? [root, ...rest] : [];
|
|
114
125
|
}
|
|
115
126
|
|
|
127
|
+
function parseBranchList(rawValue) {
|
|
128
|
+
return String(rawValue || '')
|
|
129
|
+
.split(/[\s,]+/)
|
|
130
|
+
.map((item) => item.trim())
|
|
131
|
+
.filter(Boolean);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function uniquePreserveOrder(items) {
|
|
135
|
+
const seen = new Set();
|
|
136
|
+
const result = [];
|
|
137
|
+
for (const item of items) {
|
|
138
|
+
if (seen.has(item)) continue;
|
|
139
|
+
seen.add(item);
|
|
140
|
+
result.push(item);
|
|
141
|
+
}
|
|
142
|
+
return result;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function readConfiguredProtectedBranches(repoRoot) {
|
|
146
|
+
const result = gitRun(repoRoot, ['config', '--get', GIT_PROTECTED_BRANCHES_KEY], { allowFailure: true });
|
|
147
|
+
if (result.status !== 0) {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
const parsed = uniquePreserveOrder(parseBranchList(result.stdout.trim()));
|
|
151
|
+
if (parsed.length === 0) {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
return parsed;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function listLocalUserBranches(repoRoot) {
|
|
158
|
+
const result = gitRun(repoRoot, ['for-each-ref', '--format=%(refname:short)', 'refs/heads'], { allowFailure: true });
|
|
159
|
+
const branchNames = result.status === 0
|
|
160
|
+
? uniquePreserveOrder(
|
|
161
|
+
String(result.stdout || '')
|
|
162
|
+
.split('\n')
|
|
163
|
+
.map((item) => item.trim())
|
|
164
|
+
.filter(Boolean),
|
|
165
|
+
)
|
|
166
|
+
: [];
|
|
167
|
+
|
|
168
|
+
const additionalUserBranches = branchNames.filter(
|
|
169
|
+
(branchName) =>
|
|
170
|
+
!branchName.startsWith('agent/') &&
|
|
171
|
+
!DEFAULT_PROTECTED_BRANCHES.includes(branchName),
|
|
172
|
+
);
|
|
173
|
+
if (additionalUserBranches.length > 0) {
|
|
174
|
+
return additionalUserBranches;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const current = gitRun(repoRoot, ['branch', '--show-current'], { allowFailure: true });
|
|
178
|
+
if (current.status !== 0) {
|
|
179
|
+
return [];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const branchName = String(current.stdout || '').trim();
|
|
183
|
+
if (
|
|
184
|
+
!branchName ||
|
|
185
|
+
branchName.startsWith('agent/') ||
|
|
186
|
+
DEFAULT_PROTECTED_BRANCHES.includes(branchName)
|
|
187
|
+
) {
|
|
188
|
+
return [];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return [branchName];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function listLocalAgentBranches(repoRoot) {
|
|
195
|
+
const result = gitRun(
|
|
196
|
+
repoRoot,
|
|
197
|
+
['for-each-ref', '--format=%(refname:short)', 'refs/heads/agent/'],
|
|
198
|
+
{ allowFailure: true },
|
|
199
|
+
);
|
|
200
|
+
if (result.status !== 0) {
|
|
201
|
+
return [];
|
|
202
|
+
}
|
|
203
|
+
return uniquePreserveOrder(
|
|
204
|
+
String(result.stdout || '')
|
|
205
|
+
.split('\n')
|
|
206
|
+
.map((item) => item.trim())
|
|
207
|
+
.filter(Boolean),
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function mapWorktreePathsByBranch(repoRoot) {
|
|
212
|
+
const result = gitRun(repoRoot, ['worktree', 'list', '--porcelain'], { allowFailure: true });
|
|
213
|
+
const map = new Map();
|
|
214
|
+
if (result.status !== 0) {
|
|
215
|
+
return map;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const lines = String(result.stdout || '').split('\n');
|
|
219
|
+
let currentWorktree = '';
|
|
220
|
+
for (const line of lines) {
|
|
221
|
+
if (line.startsWith('worktree ')) {
|
|
222
|
+
currentWorktree = line.slice('worktree '.length).trim();
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
if (line.startsWith('branch refs/heads/')) {
|
|
226
|
+
const branchName = line.slice('branch refs/heads/'.length).trim();
|
|
227
|
+
if (currentWorktree && branchName) {
|
|
228
|
+
map.set(branchName, currentWorktree);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return map;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function gitRefExists(repoRoot, ref) {
|
|
236
|
+
return run('git', ['-C', repoRoot, 'show-ref', '--verify', '--quiet', ref]).status === 0;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function hasSignificantWorkingTreeChanges(worktreePath) {
|
|
240
|
+
const result = run('git', [
|
|
241
|
+
'-C',
|
|
242
|
+
worktreePath,
|
|
243
|
+
'status',
|
|
244
|
+
'--porcelain',
|
|
245
|
+
'--untracked-files=normal',
|
|
246
|
+
'--',
|
|
247
|
+
]);
|
|
248
|
+
if (result.status !== 0) {
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const lines = String(result.stdout || '')
|
|
253
|
+
.split('\n')
|
|
254
|
+
.map((line) => line.trimEnd())
|
|
255
|
+
.filter((line) => line.length > 0);
|
|
256
|
+
|
|
257
|
+
for (const line of lines) {
|
|
258
|
+
const pathPart = (line.length > 3 ? line.slice(3) : '').trim();
|
|
259
|
+
if (!pathPart) continue;
|
|
260
|
+
if (pathPart === LOCK_FILE_RELATIVE) continue;
|
|
261
|
+
if (pathPart.startsWith(`${LOCK_FILE_RELATIVE} -> `)) continue;
|
|
262
|
+
if (pathPart.endsWith(` -> ${LOCK_FILE_RELATIVE}`)) continue;
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function readProtectedBranches(repoRoot) {
|
|
269
|
+
const result = gitRun(repoRoot, ['config', '--get', GIT_PROTECTED_BRANCHES_KEY], { allowFailure: true });
|
|
270
|
+
if (result.status !== 0) {
|
|
271
|
+
return [...DEFAULT_PROTECTED_BRANCHES];
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const parsed = uniquePreserveOrder(parseBranchList(result.stdout.trim()));
|
|
275
|
+
if (parsed.length === 0) {
|
|
276
|
+
return [...DEFAULT_PROTECTED_BRANCHES];
|
|
277
|
+
}
|
|
278
|
+
return parsed;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function ensureSetupProtectedBranches(repoRoot, dryRun) {
|
|
282
|
+
const localUserBranches = listLocalUserBranches(repoRoot);
|
|
283
|
+
if (localUserBranches.length === 0) {
|
|
284
|
+
return {
|
|
285
|
+
status: 'unchanged',
|
|
286
|
+
file: `git config ${GIT_PROTECTED_BRANCHES_KEY}`,
|
|
287
|
+
note: 'no additional local user branches detected',
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const configured = readConfiguredProtectedBranches(repoRoot);
|
|
292
|
+
const currentBranches = configured || [...DEFAULT_PROTECTED_BRANCHES];
|
|
293
|
+
const missingBranches = localUserBranches.filter((branchName) => !currentBranches.includes(branchName));
|
|
294
|
+
if (missingBranches.length === 0) {
|
|
295
|
+
return {
|
|
296
|
+
status: 'unchanged',
|
|
297
|
+
file: `git config ${GIT_PROTECTED_BRANCHES_KEY}`,
|
|
298
|
+
note: 'local user branches already protected',
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const nextBranches = uniquePreserveOrder([...currentBranches, ...missingBranches]);
|
|
303
|
+
if (!dryRun) {
|
|
304
|
+
writeProtectedBranches(repoRoot, nextBranches);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return {
|
|
308
|
+
status: dryRun ? 'would-update' : 'updated',
|
|
309
|
+
file: `git config ${GIT_PROTECTED_BRANCHES_KEY}`,
|
|
310
|
+
note: `added local user branch(es): ${missingBranches.join(', ')}`,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function writeProtectedBranches(repoRoot, branches) {
|
|
315
|
+
if (branches.length === 0) {
|
|
316
|
+
gitRun(repoRoot, ['config', '--unset-all', GIT_PROTECTED_BRANCHES_KEY], { allowFailure: true });
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
gitRun(repoRoot, ['config', GIT_PROTECTED_BRANCHES_KEY, branches.join(' ')]);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function readGitConfig(repoRoot, key) {
|
|
323
|
+
const result = gitRun(repoRoot, ['config', '--get', key], { allowFailure: true });
|
|
324
|
+
if (result.status !== 0) {
|
|
325
|
+
return '';
|
|
326
|
+
}
|
|
327
|
+
return (result.stdout || '').trim();
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function resolveBaseBranch(repoRoot, explicitBase) {
|
|
331
|
+
if (explicitBase) {
|
|
332
|
+
return explicitBase;
|
|
333
|
+
}
|
|
334
|
+
const configured = readGitConfig(repoRoot, GIT_BASE_BRANCH_KEY);
|
|
335
|
+
return configured || DEFAULT_BASE_BRANCH;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function resolveSyncStrategy(repoRoot, explicitStrategy) {
|
|
339
|
+
const strategy = (explicitStrategy || readGitConfig(repoRoot, GIT_SYNC_STRATEGY_KEY) || DEFAULT_SYNC_STRATEGY)
|
|
340
|
+
.trim()
|
|
341
|
+
.toLowerCase();
|
|
342
|
+
if (strategy !== 'rebase' && strategy !== 'merge') {
|
|
343
|
+
throw new Error(`Invalid sync strategy '${strategy}' (expected: rebase or merge)`);
|
|
344
|
+
}
|
|
345
|
+
return strategy;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function currentBranchName(repoRoot) {
|
|
349
|
+
const result = gitRun(repoRoot, ['branch', '--show-current'], { allowFailure: true });
|
|
350
|
+
if (result.status !== 0) {
|
|
351
|
+
throw new Error('Unable to detect current branch');
|
|
352
|
+
}
|
|
353
|
+
const branch = (result.stdout || '').trim();
|
|
354
|
+
if (!branch) {
|
|
355
|
+
throw new Error('Detached HEAD is not supported for sync operations');
|
|
356
|
+
}
|
|
357
|
+
return branch;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function repoHasHeadCommit(repoRoot) {
|
|
361
|
+
return gitRun(repoRoot, ['rev-parse', '--verify', 'HEAD'], { allowFailure: true }).status === 0;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function readBranchDisplayName(repoRoot) {
|
|
365
|
+
const symbolic = gitRun(repoRoot, ['symbolic-ref', '--quiet', '--short', 'HEAD'], { allowFailure: true });
|
|
366
|
+
if (symbolic.status === 0) {
|
|
367
|
+
const branch = String(symbolic.stdout || '').trim();
|
|
368
|
+
if (!branch) {
|
|
369
|
+
return '(unknown)';
|
|
370
|
+
}
|
|
371
|
+
return repoHasHeadCommit(repoRoot) ? branch : `${branch} (unborn; no commits yet)`;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const detached = gitRun(repoRoot, ['rev-parse', '--short', 'HEAD'], { allowFailure: true });
|
|
375
|
+
if (detached.status === 0) {
|
|
376
|
+
return `(detached at ${String(detached.stdout || '').trim()})`;
|
|
377
|
+
}
|
|
378
|
+
return '(unknown)';
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function hasOriginRemote(repoRoot) {
|
|
382
|
+
return gitRun(repoRoot, ['remote', 'get-url', 'origin'], { allowFailure: true }).status === 0;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function detectComposeHintFiles(repoRoot) {
|
|
386
|
+
return COMPOSE_HINT_FILES.filter((relativePath) => fs.existsSync(path.join(repoRoot, relativePath)));
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function printSetupRepoHints(repoRoot, baseBranch, repoLabel = '') {
|
|
390
|
+
const branchDisplay = readBranchDisplayName(repoRoot);
|
|
391
|
+
const hasHeadCommit = repoHasHeadCommit(repoRoot);
|
|
392
|
+
const hasOrigin = hasOriginRemote(repoRoot);
|
|
393
|
+
const composeFiles = detectComposeHintFiles(repoRoot);
|
|
394
|
+
if (hasHeadCommit && hasOrigin && composeFiles.length === 0) {
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const label = repoLabel ? ` ${repoLabel}` : '';
|
|
399
|
+
if (!hasHeadCommit) {
|
|
400
|
+
console.log(`[${TOOL_NAME}] Fresh repo onboarding${label}: current branch is ${branchDisplay}.`);
|
|
401
|
+
console.log(`[${TOOL_NAME}] Bootstrap commit${label}: git add . && git commit -m "bootstrap gitguardex"`);
|
|
402
|
+
console.log(
|
|
403
|
+
`[${TOOL_NAME}] First agent flow${label}: ` +
|
|
404
|
+
`gx branch start "<task>" "codex" -> ` +
|
|
405
|
+
`gx locks claim --branch "$(git branch --show-current)" <file...> -> ` +
|
|
406
|
+
`gx branch finish --branch "$(git branch --show-current)" --base ${baseBranch} --via-pr --wait-for-merge`,
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
if (!hasOrigin) {
|
|
410
|
+
console.log(`[${TOOL_NAME}] No origin remote${label}: finish and auto-merge flows stay local until you add one.`);
|
|
411
|
+
}
|
|
412
|
+
if (composeFiles.length > 0) {
|
|
413
|
+
console.log(
|
|
414
|
+
`[${TOOL_NAME}] Docker Compose helper${label}: detected ${composeFiles.join(', ')}. ` +
|
|
415
|
+
`Set GUARDEX_DOCKER_SERVICE and run 'bash scripts/guardex-docker-loader.sh -- <command...>'.`,
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function workingTreeIsDirty(repoRoot) {
|
|
421
|
+
const result = gitRun(repoRoot, ['status', '--porcelain'], { allowFailure: true });
|
|
422
|
+
if (result.status !== 0) {
|
|
423
|
+
throw new Error('Unable to inspect git working tree status');
|
|
424
|
+
}
|
|
425
|
+
const lines = (result.stdout || '').split('\n').filter((line) => line.length > 0);
|
|
426
|
+
const significant = lines.filter((line) => {
|
|
427
|
+
const pathPart = (line.length > 3 ? line.slice(3) : '').trim();
|
|
428
|
+
if (!pathPart) return false;
|
|
429
|
+
if (pathPart === LOCK_FILE_RELATIVE) return false;
|
|
430
|
+
if (pathPart.startsWith(`${LOCK_FILE_RELATIVE} -> `)) return false;
|
|
431
|
+
if (pathPart.endsWith(` -> ${LOCK_FILE_RELATIVE}`)) return false;
|
|
432
|
+
return true;
|
|
433
|
+
});
|
|
434
|
+
return significant.length > 0;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function ensureRepoBranch(repoRoot, branch) {
|
|
438
|
+
const current = currentBranchName(repoRoot);
|
|
439
|
+
if (current === branch) {
|
|
440
|
+
return { ok: true, changed: false };
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const checkoutResult = run('git', ['-C', repoRoot, 'checkout', branch], { timeout: 20_000 });
|
|
444
|
+
if (checkoutResult.error && typeof checkoutResult.status !== 'number') {
|
|
445
|
+
return {
|
|
446
|
+
ok: false,
|
|
447
|
+
changed: false,
|
|
448
|
+
stdout: checkoutResult.stdout || '',
|
|
449
|
+
stderr: checkoutResult.stderr || '',
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
if (checkoutResult.status !== 0) {
|
|
453
|
+
return {
|
|
454
|
+
ok: false,
|
|
455
|
+
changed: false,
|
|
456
|
+
stdout: checkoutResult.stdout || '',
|
|
457
|
+
stderr: checkoutResult.stderr || '',
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
return { ok: true, changed: true };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function ensureOriginBaseRef(repoRoot, baseBranch) {
|
|
465
|
+
const fetch = gitRun(repoRoot, ['fetch', 'origin', baseBranch, '--quiet'], { allowFailure: true });
|
|
466
|
+
if (fetch.status !== 0) {
|
|
467
|
+
throw new Error(
|
|
468
|
+
`Unable to fetch origin/${baseBranch}. Ensure remote 'origin' exists and branch '${baseBranch}' is available.`,
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
const hasRemoteBase = gitRun(repoRoot, ['show-ref', '--verify', '--quiet', `refs/remotes/origin/${baseBranch}`], {
|
|
472
|
+
allowFailure: true,
|
|
473
|
+
});
|
|
474
|
+
if (hasRemoteBase.status !== 0) {
|
|
475
|
+
throw new Error(`Remote base branch not found: origin/${baseBranch}`);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function aheadBehind(repoRoot, branchRef, baseRef) {
|
|
480
|
+
const result = gitRun(repoRoot, ['rev-list', '--left-right', '--count', `${branchRef}...${baseRef}`], {
|
|
481
|
+
allowFailure: true,
|
|
482
|
+
});
|
|
483
|
+
if (result.status !== 0) {
|
|
484
|
+
throw new Error(`Unable to compute ahead/behind for ${branchRef} vs ${baseRef}`);
|
|
485
|
+
}
|
|
486
|
+
const parts = (result.stdout || '').trim().split(/\s+/).filter(Boolean);
|
|
487
|
+
const ahead = Number.parseInt(parts[0] || '0', 10);
|
|
488
|
+
const behind = Number.parseInt(parts[1] || '0', 10);
|
|
489
|
+
return { ahead: Number.isFinite(ahead) ? ahead : 0, behind: Number.isFinite(behind) ? behind : 0 };
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function lockRegistryStatus(repoRoot) {
|
|
493
|
+
const result = gitRun(repoRoot, ['status', '--porcelain', '--', LOCK_FILE_RELATIVE], { allowFailure: true });
|
|
494
|
+
if (result.status !== 0) {
|
|
495
|
+
return { dirty: false, untracked: false };
|
|
496
|
+
}
|
|
497
|
+
const lines = (result.stdout || '').split('\n').filter((line) => line.length > 0);
|
|
498
|
+
if (lines.length === 0) {
|
|
499
|
+
return { dirty: false, untracked: false };
|
|
500
|
+
}
|
|
501
|
+
const untracked = lines.some((line) => line.startsWith('??'));
|
|
502
|
+
return { dirty: true, untracked };
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function listAgentWorktrees(repoRoot) {
|
|
506
|
+
const result = gitRun(repoRoot, ['worktree', 'list', '--porcelain'], { allowFailure: true });
|
|
507
|
+
if (result.status !== 0) {
|
|
508
|
+
throw new Error('Unable to list git worktrees for finish command');
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const entries = [];
|
|
512
|
+
let currentPath = '';
|
|
513
|
+
let currentBranchRef = '';
|
|
514
|
+
const lines = String(result.stdout || '').split('\n');
|
|
515
|
+
for (const line of lines) {
|
|
516
|
+
if (!line.trim()) {
|
|
517
|
+
if (currentPath && currentBranchRef.startsWith('refs/heads/agent/')) {
|
|
518
|
+
entries.push({
|
|
519
|
+
worktreePath: currentPath,
|
|
520
|
+
branch: currentBranchRef.replace(/^refs\/heads\//, ''),
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
currentPath = '';
|
|
524
|
+
currentBranchRef = '';
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
if (line.startsWith('worktree ')) {
|
|
528
|
+
currentPath = line.slice('worktree '.length).trim();
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
if (line.startsWith('branch ')) {
|
|
532
|
+
currentBranchRef = line.slice('branch '.length).trim();
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
if (currentPath && currentBranchRef.startsWith('refs/heads/agent/')) {
|
|
537
|
+
entries.push({
|
|
538
|
+
worktreePath: currentPath,
|
|
539
|
+
branch: currentBranchRef.replace(/^refs\/heads\//, ''),
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
return entries;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function listLocalAgentBranchesForFinish(repoRoot) {
|
|
547
|
+
return uniquePreserveOrder(
|
|
548
|
+
listLocalAgentBranches(repoRoot).filter((line) => line.startsWith('agent/')),
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function gitQuietChangeResult(worktreePath, args) {
|
|
553
|
+
const result = run('git', ['-C', worktreePath, ...args], { stdio: 'pipe' });
|
|
554
|
+
if (result.status === 0) {
|
|
555
|
+
return false;
|
|
556
|
+
}
|
|
557
|
+
if (result.status === 1) {
|
|
558
|
+
return true;
|
|
559
|
+
}
|
|
560
|
+
throw new Error(
|
|
561
|
+
`git ${args.join(' ')} failed in ${worktreePath}: ${(
|
|
562
|
+
result.stderr || result.stdout || ''
|
|
563
|
+
).trim()}`,
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function worktreeHasLocalChanges(worktreePath) {
|
|
568
|
+
const hasUnstaged = gitQuietChangeResult(worktreePath, [
|
|
569
|
+
'diff',
|
|
570
|
+
'--quiet',
|
|
571
|
+
'--',
|
|
572
|
+
'.',
|
|
573
|
+
':(exclude).omx/state/agent-file-locks.json',
|
|
574
|
+
]);
|
|
575
|
+
if (hasUnstaged) {
|
|
576
|
+
return true;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const hasStaged = gitQuietChangeResult(worktreePath, [
|
|
580
|
+
'diff',
|
|
581
|
+
'--cached',
|
|
582
|
+
'--quiet',
|
|
583
|
+
'--',
|
|
584
|
+
'.',
|
|
585
|
+
':(exclude).omx/state/agent-file-locks.json',
|
|
586
|
+
]);
|
|
587
|
+
if (hasStaged) {
|
|
588
|
+
return true;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const untracked = run('git', ['-C', worktreePath, 'ls-files', '--others', '--exclude-standard'], {
|
|
592
|
+
stdio: 'pipe',
|
|
593
|
+
});
|
|
594
|
+
if (untracked.status !== 0) {
|
|
595
|
+
throw new Error(`Unable to inspect untracked files in ${worktreePath}`);
|
|
596
|
+
}
|
|
597
|
+
return String(untracked.stdout || '').trim().length > 0;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function gitOutputLines(worktreePath, args) {
|
|
601
|
+
const result = run('git', ['-C', worktreePath, ...args], { stdio: 'pipe' });
|
|
602
|
+
if (result.status !== 0) {
|
|
603
|
+
throw new Error(
|
|
604
|
+
`git ${args.join(' ')} failed in ${worktreePath}: ${(
|
|
605
|
+
result.stderr || result.stdout || ''
|
|
606
|
+
).trim()}`,
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
return String(result.stdout || '')
|
|
610
|
+
.split('\n')
|
|
611
|
+
.map((line) => line.trim())
|
|
612
|
+
.filter(Boolean);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function branchExists(repoRoot, branch) {
|
|
616
|
+
const result = gitRun(repoRoot, ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], {
|
|
617
|
+
allowFailure: true,
|
|
618
|
+
});
|
|
619
|
+
return result.status === 0;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function resolveFinishBaseBranch(repoRoot, _sourceBranch, explicitBase) {
|
|
623
|
+
if (explicitBase) {
|
|
624
|
+
return explicitBase;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const configured = readGitConfig(repoRoot, GIT_BASE_BRANCH_KEY);
|
|
628
|
+
if (configured) {
|
|
629
|
+
return configured;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
return DEFAULT_BASE_BRANCH;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function branchMergedIntoBase(repoRoot, branch, baseBranch) {
|
|
636
|
+
if (!branchExists(repoRoot, baseBranch)) {
|
|
637
|
+
return false;
|
|
638
|
+
}
|
|
639
|
+
const result = gitRun(repoRoot, ['merge-base', '--is-ancestor', branch, baseBranch], {
|
|
640
|
+
allowFailure: true,
|
|
641
|
+
});
|
|
642
|
+
if (result.status === 0) {
|
|
643
|
+
return true;
|
|
644
|
+
}
|
|
645
|
+
if (result.status === 1) {
|
|
646
|
+
return false;
|
|
647
|
+
}
|
|
648
|
+
throw new Error(`Unable to determine merge status for ${branch} -> ${baseBranch}`);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function syncOperation(repoRoot, strategy, baseRef, ffOnly) {
|
|
652
|
+
if (strategy === 'rebase') {
|
|
653
|
+
if (ffOnly) {
|
|
654
|
+
throw new Error('--ff-only is only supported with --strategy merge');
|
|
655
|
+
}
|
|
656
|
+
const rebased = run('git', ['-C', repoRoot, 'rebase', baseRef], { stdio: 'pipe' });
|
|
657
|
+
if (rebased.status !== 0) {
|
|
658
|
+
const details = (rebased.stderr || rebased.stdout || '').trim();
|
|
659
|
+
const gitDir = path.join(repoRoot, '.git');
|
|
660
|
+
const rebaseActive = fs.existsSync(path.join(gitDir, 'rebase-merge')) || fs.existsSync(path.join(gitDir, 'rebase-apply'));
|
|
661
|
+
const help = rebaseActive
|
|
662
|
+
? '\nResolve conflicts, then run: git rebase --continue\nOr abort: git rebase --abort'
|
|
663
|
+
: '';
|
|
664
|
+
throw new Error(`Sync failed during rebase onto ${baseRef}.${details ? `\n${details}` : ''}${help}`);
|
|
665
|
+
}
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const mergeArgs = ['-C', repoRoot, 'merge', '--no-edit'];
|
|
670
|
+
if (ffOnly) {
|
|
671
|
+
mergeArgs.push('--ff-only');
|
|
672
|
+
}
|
|
673
|
+
mergeArgs.push(baseRef);
|
|
674
|
+
const merged = run('git', mergeArgs, { stdio: 'pipe' });
|
|
675
|
+
if (merged.status !== 0) {
|
|
676
|
+
const details = (merged.stderr || merged.stdout || '').trim();
|
|
677
|
+
const gitDir = path.join(repoRoot, '.git');
|
|
678
|
+
const mergeActive = fs.existsSync(path.join(gitDir, 'MERGE_HEAD'));
|
|
679
|
+
const help = mergeActive ? '\nResolve conflicts, then run: git commit\nOr abort: git merge --abort' : '';
|
|
680
|
+
throw new Error(`Sync failed during merge from ${baseRef}.${details ? `\n${details}` : ''}${help}`);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
116
684
|
module.exports = {
|
|
117
685
|
DEFAULT_NESTED_REPO_MAX_DEPTH: NESTED_REPO_DEFAULT_MAX_DEPTH,
|
|
118
686
|
gitRun,
|
|
119
687
|
resolveRepoRoot,
|
|
120
688
|
isGitRepo,
|
|
121
689
|
discoverNestedGitRepos,
|
|
690
|
+
parseBranchList,
|
|
691
|
+
uniquePreserveOrder,
|
|
692
|
+
readConfiguredProtectedBranches,
|
|
693
|
+
listLocalUserBranches,
|
|
694
|
+
listLocalAgentBranches,
|
|
695
|
+
mapWorktreePathsByBranch,
|
|
696
|
+
gitRefExists,
|
|
697
|
+
hasSignificantWorkingTreeChanges,
|
|
698
|
+
readProtectedBranches,
|
|
699
|
+
ensureSetupProtectedBranches,
|
|
700
|
+
writeProtectedBranches,
|
|
701
|
+
readGitConfig,
|
|
702
|
+
resolveBaseBranch,
|
|
703
|
+
resolveSyncStrategy,
|
|
704
|
+
currentBranchName,
|
|
705
|
+
repoHasHeadCommit,
|
|
706
|
+
readBranchDisplayName,
|
|
707
|
+
hasOriginRemote,
|
|
708
|
+
repoHasOriginRemote: hasOriginRemote,
|
|
709
|
+
detectComposeHintFiles,
|
|
710
|
+
printSetupRepoHints,
|
|
711
|
+
workingTreeIsDirty,
|
|
712
|
+
ensureRepoBranch,
|
|
713
|
+
ensureOriginBaseRef,
|
|
714
|
+
aheadBehind,
|
|
715
|
+
lockRegistryStatus,
|
|
716
|
+
listAgentWorktrees,
|
|
717
|
+
listLocalAgentBranchesForFinish,
|
|
718
|
+
gitQuietChangeResult,
|
|
719
|
+
worktreeHasLocalChanges,
|
|
720
|
+
gitOutputLines,
|
|
721
|
+
branchExists,
|
|
722
|
+
resolveFinishBaseBranch,
|
|
723
|
+
branchMergedIntoBase,
|
|
724
|
+
syncOperation,
|
|
122
725
|
};
|
package/src/hooks/index.js
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
const path = require('node:path');
|
|
2
2
|
|
|
3
|
+
function requireFlagValue(rawArgs, index, flagName) {
|
|
4
|
+
const value = rawArgs[index + 1];
|
|
5
|
+
if (!value || value.startsWith('--')) {
|
|
6
|
+
throw new Error(`${flagName} requires a value`);
|
|
7
|
+
}
|
|
8
|
+
return value;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function parseHeartbeatArgs(rawArgs) {
|
|
12
|
+
let branch = '';
|
|
13
|
+
let state = '';
|
|
14
|
+
|
|
15
|
+
for (let index = 0; index < rawArgs.length; index += 1) {
|
|
16
|
+
const arg = rawArgs[index];
|
|
17
|
+
if (arg === '--branch') {
|
|
18
|
+
branch = requireFlagValue(rawArgs, index, '--branch');
|
|
19
|
+
index += 1;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (arg === '--state') {
|
|
23
|
+
state = requireFlagValue(rawArgs, index, '--state');
|
|
24
|
+
index += 1;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
throw new Error(`Unknown heartbeat option: ${arg}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (!branch) {
|
|
31
|
+
throw new Error('heartbeat requires --branch <agent/...>');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return { branch, state };
|
|
35
|
+
}
|
|
36
|
+
|
|
3
37
|
function hook(rawArgs, deps) {
|
|
4
38
|
const {
|
|
5
39
|
extractTargetedArgs,
|
|
@@ -55,6 +89,36 @@ function internal(rawArgs, deps) {
|
|
|
55
89
|
} = deps;
|
|
56
90
|
|
|
57
91
|
const [subcommand, assetKey, ...rest] = rawArgs;
|
|
92
|
+
if (subcommand === 'heartbeat') {
|
|
93
|
+
const { target, passthrough } = extractTargetedArgs([assetKey, ...rest].filter(Boolean));
|
|
94
|
+
const repoRoot = resolveRepoRoot(target);
|
|
95
|
+
const options = parseHeartbeatArgs(passthrough);
|
|
96
|
+
const heartbeatArgs = ['heartbeat', '--repo', repoRoot, '--branch', options.branch];
|
|
97
|
+
if (options.state) {
|
|
98
|
+
heartbeatArgs.push('--state', options.state);
|
|
99
|
+
}
|
|
100
|
+
const result = runPackageAsset('sessionState', heartbeatArgs, { cwd: repoRoot });
|
|
101
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
102
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
103
|
+
process.exitCode = result.status;
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (subcommand === 'stop-session') {
|
|
107
|
+
const { target, passthrough } = extractTargetedArgs([assetKey, ...rest].filter(Boolean));
|
|
108
|
+
const repoRoot = resolveRepoRoot(target);
|
|
109
|
+
const options = parseHeartbeatArgs(passthrough);
|
|
110
|
+
const result = runPackageAsset('sessionState', [
|
|
111
|
+
'terminate',
|
|
112
|
+
'--repo',
|
|
113
|
+
repoRoot,
|
|
114
|
+
'--branch',
|
|
115
|
+
options.branch,
|
|
116
|
+
], { cwd: repoRoot });
|
|
117
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
118
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
119
|
+
process.exitCode = result.status;
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
58
122
|
if (subcommand !== 'run-shell') {
|
|
59
123
|
throw new Error(`Unknown internal command: ${subcommand || '(missing)'}`);
|
|
60
124
|
}
|