@imdeadpool/guardex 7.0.13 → 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 +35 -4
- package/bin/multiagent-safety.js +1140 -156
- package/package.json +2 -2
- package/templates/AGENTS.multiagent-safety.md +16 -9
- package/templates/github/workflows/cr.yml +9 -3
- 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 +156 -52
- 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',
|
|
@@ -115,21 +123,34 @@ const REQUIRED_WORKFLOW_FILES = [
|
|
|
115
123
|
];
|
|
116
124
|
|
|
117
125
|
const REQUIRED_PACKAGE_SCRIPTS = {
|
|
126
|
+
'agent:codex': 'bash ./scripts/codex-agent.sh',
|
|
118
127
|
'agent:branch:start': 'bash ./scripts/agent-branch-start.sh',
|
|
119
128
|
'agent:branch:finish': 'bash ./scripts/agent-branch-finish.sh',
|
|
120
|
-
'agent:cleanup': '
|
|
129
|
+
'agent:cleanup': 'gx cleanup',
|
|
121
130
|
'agent:hooks:install': 'bash ./scripts/install-agent-git-hooks.sh',
|
|
122
131
|
'agent:locks:claim': 'python3 ./scripts/agent-file-locks.py claim',
|
|
132
|
+
'agent:locks:allow-delete': 'python3 ./scripts/agent-file-locks.py allow-delete',
|
|
123
133
|
'agent:locks:release': 'python3 ./scripts/agent-file-locks.py release',
|
|
124
134
|
'agent:locks:status': 'python3 ./scripts/agent-file-locks.py status',
|
|
125
135
|
'agent:plan:init': 'bash ./scripts/openspec/init-plan-workspace.sh',
|
|
126
136
|
'agent:change:init': 'bash ./scripts/openspec/init-change-workspace.sh',
|
|
137
|
+
'agent:protect:list': 'gx protect list',
|
|
138
|
+
'agent:branch:sync': 'gx sync',
|
|
139
|
+
'agent:branch:sync:check': 'gx sync --check',
|
|
140
|
+
'agent:safety:setup': 'gx setup',
|
|
141
|
+
'agent:safety:scan': 'gx status --strict',
|
|
142
|
+
'agent:safety:fix': 'gx setup --repair',
|
|
143
|
+
'agent:safety:doctor': 'gx doctor',
|
|
144
|
+
'agent:docker:load': 'bash ./scripts/guardex-docker-loader.sh',
|
|
145
|
+
'agent:review:watch': 'bash ./scripts/review-bot-watch.sh',
|
|
146
|
+
'agent:finish': 'gx finish --all',
|
|
127
147
|
};
|
|
128
148
|
|
|
129
149
|
const EXECUTABLE_RELATIVE_PATHS = new Set([
|
|
130
150
|
'scripts/agent-branch-start.sh',
|
|
131
151
|
'scripts/agent-branch-finish.sh',
|
|
132
152
|
'scripts/codex-agent.sh',
|
|
153
|
+
'scripts/guardex-docker-loader.sh',
|
|
133
154
|
'scripts/review-bot-watch.sh',
|
|
134
155
|
'scripts/agent-worktree-prune.sh',
|
|
135
156
|
'scripts/agent-file-locks.py',
|
|
@@ -162,35 +183,34 @@ const AGENTS_MARKER_START = '<!-- multiagent-safety:START -->';
|
|
|
162
183
|
const AGENTS_MARKER_END = '<!-- multiagent-safety:END -->';
|
|
163
184
|
const GITIGNORE_MARKER_START = '# multiagent-safety:START';
|
|
164
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
|
+
];
|
|
165
192
|
const MANAGED_GITIGNORE_PATHS = [
|
|
166
193
|
'.omx/',
|
|
167
194
|
'.omc/',
|
|
195
|
+
'scripts/*',
|
|
168
196
|
'scripts/agent-branch-start.sh',
|
|
169
|
-
'scripts/agent-branch-finish.sh',
|
|
170
|
-
'scripts/codex-agent.sh',
|
|
171
|
-
'scripts/review-bot-watch.sh',
|
|
172
|
-
'scripts/agent-worktree-prune.sh',
|
|
173
197
|
'scripts/agent-file-locks.py',
|
|
174
|
-
'
|
|
175
|
-
'scripts/install-agent-git-hooks.sh',
|
|
176
|
-
'scripts/openspec/init-plan-workspace.sh',
|
|
177
|
-
'scripts/openspec/init-change-workspace.sh',
|
|
178
|
-
'.githooks/pre-commit',
|
|
179
|
-
'.githooks/pre-push',
|
|
180
|
-
'.githooks/post-merge',
|
|
181
|
-
'.githooks/post-checkout',
|
|
198
|
+
'.githooks',
|
|
182
199
|
'oh-my-codex/',
|
|
183
200
|
'.codex/skills/gitguardex/SKILL.md',
|
|
184
201
|
'.codex/skills/guardex-merge-skills-to-dev/SKILL.md',
|
|
185
202
|
'.claude/commands/gitguardex.md',
|
|
186
203
|
LOCK_FILE_RELATIVE,
|
|
187
204
|
];
|
|
205
|
+
const REPO_SCAFFOLD_DIRECTORIES = ['bin'];
|
|
188
206
|
const OMX_SCAFFOLD_DIRECTORIES = [
|
|
189
207
|
'.omx',
|
|
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,20 +281,33 @@ 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
|
|
266
300
|
2) Bootstrap: gx setup
|
|
267
301
|
3) Repair: gx doctor
|
|
268
302
|
4) Task loop: bash scripts/codex-agent.sh "<task>" "<agent>"
|
|
269
|
-
or branch-start -> claim -> branch-finish
|
|
303
|
+
or branch-start -> python3 scripts/agent-file-locks.py claim -> branch-finish
|
|
270
304
|
5) Finish: gx finish --all
|
|
271
305
|
6) Cleanup: gx cleanup
|
|
272
306
|
7) OpenSpec: /opsx:propose -> /opsx:apply -> /opsx:archive
|
|
273
307
|
8) Optional: gx protect add release staging
|
|
274
308
|
9) Optional: gx sync --check && gx sync
|
|
275
309
|
10) Review bot: install https://github.com/apps/cr-gpt + set OPENAI_API_KEY
|
|
276
|
-
11) Fork sync: cp .github/pull.yml.example .github/pull.yml
|
|
310
|
+
11) Fork sync: install https://github.com/apps/pull + cp .github/pull.yml.example .github/pull.yml
|
|
277
311
|
`;
|
|
278
312
|
|
|
279
313
|
const AI_SETUP_COMMANDS = `npm i -g @imdeadpool/guardex
|
|
@@ -281,6 +315,7 @@ gh --version
|
|
|
281
315
|
gx setup
|
|
282
316
|
gx doctor
|
|
283
317
|
bash scripts/codex-agent.sh "<task>" "<agent>"
|
|
318
|
+
python3 scripts/agent-file-locks.py claim --branch "<agent-branch>" <file...>
|
|
284
319
|
gx finish --all
|
|
285
320
|
gx cleanup
|
|
286
321
|
gx protect add release staging
|
|
@@ -508,8 +543,6 @@ const NESTED_REPO_DEFAULT_SKIP_DIRS = new Set([
|
|
|
508
543
|
'.venv',
|
|
509
544
|
'.pnpm-store',
|
|
510
545
|
]);
|
|
511
|
-
const NESTED_REPO_WORKTREE_RELATIVE_DIR = path.join('.omx', 'agent-worktrees');
|
|
512
|
-
|
|
513
546
|
function discoverNestedGitRepos(rootPath, opts = {}) {
|
|
514
547
|
const maxDepth = Number.isFinite(opts.maxDepth) ? Math.max(1, opts.maxDepth) : NESTED_REPO_DEFAULT_MAX_DEPTH;
|
|
515
548
|
const extraSkip = new Set(Array.isArray(opts.extraSkip) ? opts.extraSkip : []);
|
|
@@ -524,7 +557,7 @@ function discoverNestedGitRepos(rootPath, opts = {}) {
|
|
|
524
557
|
return path.resolve(resolvedRoot, raw);
|
|
525
558
|
})();
|
|
526
559
|
|
|
527
|
-
const
|
|
560
|
+
const worktreeSkipAbsolutes = AGENT_WORKTREE_RELATIVE_DIRS.map((relativeDir) => path.join(resolvedRoot, relativeDir));
|
|
528
561
|
const found = new Set();
|
|
529
562
|
found.add(resolvedRoot);
|
|
530
563
|
|
|
@@ -556,7 +589,7 @@ function discoverNestedGitRepos(rootPath, opts = {}) {
|
|
|
556
589
|
|
|
557
590
|
if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
|
|
558
591
|
if (shouldSkipDir(entry.name)) continue;
|
|
559
|
-
if (entryPath
|
|
592
|
+
if (worktreeSkipAbsolutes.includes(entryPath)) continue;
|
|
560
593
|
walk(entryPath, depth + 1);
|
|
561
594
|
}
|
|
562
595
|
}
|
|
@@ -598,9 +631,27 @@ function toDestinationPath(relativeTemplatePath) {
|
|
|
598
631
|
throw new Error(`Unsupported template path: ${relativeTemplatePath}`);
|
|
599
632
|
}
|
|
600
633
|
|
|
601
|
-
function ensureParentDir(filePath, dryRun) {
|
|
634
|
+
function ensureParentDir(repoRoot, filePath, dryRun) {
|
|
602
635
|
if (dryRun) return;
|
|
603
|
-
|
|
636
|
+
|
|
637
|
+
const parentDir = path.dirname(filePath);
|
|
638
|
+
const relativeParentDir = path.relative(repoRoot, parentDir);
|
|
639
|
+
const segments = relativeParentDir.split(path.sep).filter(Boolean);
|
|
640
|
+
let currentPath = repoRoot;
|
|
641
|
+
|
|
642
|
+
for (const segment of segments) {
|
|
643
|
+
currentPath = path.join(currentPath, segment);
|
|
644
|
+
if (fs.existsSync(currentPath) && !fs.statSync(currentPath).isDirectory()) {
|
|
645
|
+
const blockingPath = path.relative(repoRoot, currentPath) || path.basename(currentPath);
|
|
646
|
+
const targetPath = path.relative(repoRoot, filePath) || path.basename(filePath);
|
|
647
|
+
throw new Error(
|
|
648
|
+
`Path conflict: ${blockingPath} exists as a file, but ${targetPath} needs it to be a directory. ` +
|
|
649
|
+
`Remove or rename ${blockingPath} and rerun '${SHORT_TOOL_NAME} setup'.`,
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
fs.mkdirSync(parentDir, { recursive: true });
|
|
604
655
|
}
|
|
605
656
|
|
|
606
657
|
function ensureExecutable(destinationPath, relativePath, dryRun) {
|
|
@@ -635,7 +686,7 @@ function copyTemplateFile(repoRoot, relativeTemplatePath, force, dryRun) {
|
|
|
635
686
|
}
|
|
636
687
|
}
|
|
637
688
|
|
|
638
|
-
ensureParentDir(destinationPath, dryRun);
|
|
689
|
+
ensureParentDir(repoRoot, destinationPath, dryRun);
|
|
639
690
|
if (!dryRun) {
|
|
640
691
|
fs.writeFileSync(destinationPath, sourceContent, 'utf8');
|
|
641
692
|
ensureExecutable(destinationPath, destinationRelativePath, dryRun);
|
|
@@ -673,7 +724,7 @@ function ensureTemplateFilePresent(repoRoot, relativeTemplatePath, dryRun) {
|
|
|
673
724
|
return { status: 'skipped-conflict', file: destinationRelativePath };
|
|
674
725
|
}
|
|
675
726
|
|
|
676
|
-
ensureParentDir(destinationPath, dryRun);
|
|
727
|
+
ensureParentDir(repoRoot, destinationPath, dryRun);
|
|
677
728
|
if (!dryRun) {
|
|
678
729
|
fs.writeFileSync(destinationPath, sourceContent, 'utf8');
|
|
679
730
|
ensureExecutable(destinationPath, destinationRelativePath, dryRun);
|
|
@@ -689,6 +740,22 @@ function lockFilePath(repoRoot) {
|
|
|
689
740
|
function ensureOmxScaffold(repoRoot, dryRun) {
|
|
690
741
|
const operations = [];
|
|
691
742
|
|
|
743
|
+
for (const relativeDir of REPO_SCAFFOLD_DIRECTORIES) {
|
|
744
|
+
const absoluteDir = path.join(repoRoot, relativeDir);
|
|
745
|
+
if (fs.existsSync(absoluteDir)) {
|
|
746
|
+
if (!fs.statSync(absoluteDir).isDirectory()) {
|
|
747
|
+
throw new Error(`Expected directory at ${relativeDir} but found a file.`);
|
|
748
|
+
}
|
|
749
|
+
operations.push({ status: 'unchanged', file: relativeDir });
|
|
750
|
+
continue;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
if (!dryRun) {
|
|
754
|
+
fs.mkdirSync(absoluteDir, { recursive: true });
|
|
755
|
+
}
|
|
756
|
+
operations.push({ status: 'created', file: relativeDir });
|
|
757
|
+
}
|
|
758
|
+
|
|
692
759
|
for (const relativeDir of OMX_SCAFFOLD_DIRECTORIES) {
|
|
693
760
|
const absoluteDir = path.join(repoRoot, relativeDir);
|
|
694
761
|
if (fs.existsSync(absoluteDir)) {
|
|
@@ -965,6 +1032,14 @@ function parseCommonArgs(rawArgs, defaults) {
|
|
|
965
1032
|
options.allowProtectedBaseWrite = true;
|
|
966
1033
|
continue;
|
|
967
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
|
+
}
|
|
968
1043
|
|
|
969
1044
|
throw new Error(`Unknown option: ${arg}`);
|
|
970
1045
|
}
|
|
@@ -976,10 +1051,9 @@ function parseCommonArgs(rawArgs, defaults) {
|
|
|
976
1051
|
return options;
|
|
977
1052
|
}
|
|
978
1053
|
|
|
979
|
-
function
|
|
980
|
-
const
|
|
1054
|
+
function parseRepoTraversalArgs(rawArgs, defaults) {
|
|
1055
|
+
const traversalDefaults = {
|
|
981
1056
|
...defaults,
|
|
982
|
-
parentWorkspaceView: false,
|
|
983
1057
|
recursive: true,
|
|
984
1058
|
nestedMaxDepth: NESTED_REPO_DEFAULT_MAX_DEPTH,
|
|
985
1059
|
nestedSkipDirs: [],
|
|
@@ -989,20 +1063,12 @@ function parseSetupArgs(rawArgs, defaults) {
|
|
|
989
1063
|
|
|
990
1064
|
for (let index = 0; index < rawArgs.length; index += 1) {
|
|
991
1065
|
const arg = rawArgs[index];
|
|
992
|
-
if (arg === '--parent-workspace-view') {
|
|
993
|
-
setupDefaults.parentWorkspaceView = true;
|
|
994
|
-
continue;
|
|
995
|
-
}
|
|
996
|
-
if (arg === '--no-parent-workspace-view') {
|
|
997
|
-
setupDefaults.parentWorkspaceView = false;
|
|
998
|
-
continue;
|
|
999
|
-
}
|
|
1000
1066
|
if (arg === '--no-recursive' || arg === '--no-nested' || arg === '--single-repo') {
|
|
1001
|
-
|
|
1067
|
+
traversalDefaults.recursive = false;
|
|
1002
1068
|
continue;
|
|
1003
1069
|
}
|
|
1004
1070
|
if (arg === '--recursive' || arg === '--nested') {
|
|
1005
|
-
|
|
1071
|
+
traversalDefaults.recursive = true;
|
|
1006
1072
|
continue;
|
|
1007
1073
|
}
|
|
1008
1074
|
if (arg === '--max-depth') {
|
|
@@ -1011,47 +1077,61 @@ function parseSetupArgs(rawArgs, defaults) {
|
|
|
1011
1077
|
if (!Number.isFinite(parsed) || parsed < 1) {
|
|
1012
1078
|
throw new Error('--max-depth requires a positive integer');
|
|
1013
1079
|
}
|
|
1014
|
-
|
|
1080
|
+
traversalDefaults.nestedMaxDepth = parsed;
|
|
1015
1081
|
index += 1;
|
|
1016
1082
|
continue;
|
|
1017
1083
|
}
|
|
1018
1084
|
if (arg === '--skip-nested') {
|
|
1019
1085
|
const raw = requireValue(rawArgs, index, '--skip-nested');
|
|
1020
|
-
|
|
1086
|
+
traversalDefaults.nestedSkipDirs.push(raw);
|
|
1021
1087
|
index += 1;
|
|
1022
1088
|
continue;
|
|
1023
1089
|
}
|
|
1024
1090
|
if (arg === '--include-submodules') {
|
|
1025
|
-
|
|
1091
|
+
traversalDefaults.includeSubmodules = true;
|
|
1026
1092
|
continue;
|
|
1027
1093
|
}
|
|
1028
1094
|
forwardedArgs.push(arg);
|
|
1029
1095
|
}
|
|
1030
1096
|
|
|
1031
|
-
return parseCommonArgs(forwardedArgs,
|
|
1097
|
+
return parseCommonArgs(forwardedArgs, traversalDefaults);
|
|
1032
1098
|
}
|
|
1033
1099
|
|
|
1034
|
-
function
|
|
1035
|
-
const
|
|
1036
|
-
|
|
1037
|
-
|
|
1100
|
+
function parseSetupArgs(rawArgs, defaults) {
|
|
1101
|
+
const setupDefaults = {
|
|
1102
|
+
...defaults,
|
|
1103
|
+
parentWorkspaceView: false,
|
|
1038
1104
|
};
|
|
1105
|
+
const forwardedArgs = [];
|
|
1039
1106
|
|
|
1040
1107
|
for (let index = 0; index < rawArgs.length; index += 1) {
|
|
1041
1108
|
const arg = rawArgs[index];
|
|
1042
|
-
if (arg === '--
|
|
1043
|
-
|
|
1044
|
-
index += 1;
|
|
1109
|
+
if (arg === '--parent-workspace-view') {
|
|
1110
|
+
setupDefaults.parentWorkspaceView = true;
|
|
1045
1111
|
continue;
|
|
1046
1112
|
}
|
|
1047
|
-
if (arg === '--
|
|
1048
|
-
|
|
1113
|
+
if (arg === '--no-parent-workspace-view') {
|
|
1114
|
+
setupDefaults.parentWorkspaceView = false;
|
|
1049
1115
|
continue;
|
|
1050
1116
|
}
|
|
1051
|
-
|
|
1117
|
+
forwardedArgs.push(arg);
|
|
1052
1118
|
}
|
|
1053
1119
|
|
|
1054
|
-
return
|
|
1120
|
+
return parseRepoTraversalArgs(forwardedArgs, setupDefaults);
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
function parseDoctorArgs(rawArgs) {
|
|
1124
|
+
return parseRepoTraversalArgs(rawArgs, {
|
|
1125
|
+
target: process.cwd(),
|
|
1126
|
+
dropStaleLocks: true,
|
|
1127
|
+
skipAgents: false,
|
|
1128
|
+
skipPackageJson: false,
|
|
1129
|
+
skipGitignore: false,
|
|
1130
|
+
dryRun: false,
|
|
1131
|
+
json: false,
|
|
1132
|
+
allowProtectedBaseWrite: false,
|
|
1133
|
+
waitForMerge: true,
|
|
1134
|
+
});
|
|
1055
1135
|
}
|
|
1056
1136
|
|
|
1057
1137
|
function normalizeWorkspacePath(relativePath) {
|
|
@@ -1063,16 +1143,15 @@ function buildParentWorkspaceView(repoRoot) {
|
|
|
1063
1143
|
const workspaceFileName = `${path.basename(repoRoot)}-branches.code-workspace`;
|
|
1064
1144
|
const workspacePath = path.join(parentDir, workspaceFileName);
|
|
1065
1145
|
const repoRelativePath = normalizeWorkspacePath(path.relative(parentDir, repoRoot) || '.');
|
|
1066
|
-
const worktreesRelativePath = normalizeWorkspacePath(
|
|
1067
|
-
path.join(repoRelativePath === '.' ? '' : repoRelativePath, '.omx', 'agent-worktrees'),
|
|
1068
|
-
);
|
|
1069
1146
|
|
|
1070
1147
|
return {
|
|
1071
1148
|
workspacePath,
|
|
1072
1149
|
payload: {
|
|
1073
1150
|
folders: [
|
|
1074
1151
|
{ path: repoRelativePath },
|
|
1075
|
-
|
|
1152
|
+
...AGENT_WORKTREE_RELATIVE_DIRS.map((relativeDir) => ({
|
|
1153
|
+
path: normalizeWorkspacePath(path.join(repoRelativePath === '.' ? '' : repoRelativePath, relativeDir)),
|
|
1154
|
+
})),
|
|
1076
1155
|
],
|
|
1077
1156
|
settings: {
|
|
1078
1157
|
'scm.alwaysShowRepositories': true,
|
|
@@ -1156,6 +1235,40 @@ function assertProtectedMainWriteAllowed(options, commandName) {
|
|
|
1156
1235
|
);
|
|
1157
1236
|
}
|
|
1158
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
|
+
|
|
1159
1272
|
function extractAgentBranchStartMetadata(output) {
|
|
1160
1273
|
const branchMatch = String(output || '').match(/^\[agent-branch-start\] Created branch: (.+)$/m);
|
|
1161
1274
|
const worktreeMatch = String(output || '').match(/^\[agent-branch-start\] Worktree: (.+)$/m);
|
|
@@ -1169,7 +1282,7 @@ function resolveSandboxTarget(repoRoot, worktreePath, targetPath) {
|
|
|
1169
1282
|
const resolvedTarget = path.resolve(targetPath);
|
|
1170
1283
|
const relativeTarget = path.relative(repoRoot, resolvedTarget);
|
|
1171
1284
|
if (relativeTarget.startsWith('..') || path.isAbsolute(relativeTarget)) {
|
|
1172
|
-
throw new Error(`
|
|
1285
|
+
throw new Error(`sandbox target must stay inside repo root: ${resolvedTarget}`);
|
|
1173
1286
|
}
|
|
1174
1287
|
if (!relativeTarget || relativeTarget === '.') {
|
|
1175
1288
|
return worktreePath;
|
|
@@ -1177,6 +1290,16 @@ function resolveSandboxTarget(repoRoot, worktreePath, targetPath) {
|
|
|
1177
1290
|
return path.join(worktreePath, relativeTarget);
|
|
1178
1291
|
}
|
|
1179
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
|
+
|
|
1180
1303
|
function buildSandboxDoctorArgs(options, sandboxTarget) {
|
|
1181
1304
|
const args = ['doctor', '--target', sandboxTarget];
|
|
1182
1305
|
if (options.dryRun) args.push('--dry-run');
|
|
@@ -1185,6 +1308,7 @@ function buildSandboxDoctorArgs(options, sandboxTarget) {
|
|
|
1185
1308
|
if (options.skipPackageJson) args.push('--skip-package-json');
|
|
1186
1309
|
if (options.skipGitignore) args.push('--no-gitignore');
|
|
1187
1310
|
if (!options.dropStaleLocks) args.push('--keep-stale-locks');
|
|
1311
|
+
args.push(options.waitForMerge ? '--wait-for-merge' : '--no-wait-for-merge');
|
|
1188
1312
|
if (options.json) args.push('--json');
|
|
1189
1313
|
return args;
|
|
1190
1314
|
}
|
|
@@ -1220,7 +1344,7 @@ function ensureRepoBranch(repoRoot, branch) {
|
|
|
1220
1344
|
return { ok: true, changed: true };
|
|
1221
1345
|
}
|
|
1222
1346
|
|
|
1223
|
-
function
|
|
1347
|
+
function protectedBaseSandboxBranchPrefix() {
|
|
1224
1348
|
const now = new Date();
|
|
1225
1349
|
const stamp = [
|
|
1226
1350
|
now.getUTCFullYear(),
|
|
@@ -1234,15 +1358,15 @@ function doctorSandboxBranchPrefix() {
|
|
|
1234
1358
|
return `agent/gx/${stamp}`;
|
|
1235
1359
|
}
|
|
1236
1360
|
|
|
1237
|
-
function
|
|
1238
|
-
return path.join(repoRoot,
|
|
1361
|
+
function protectedBaseSandboxWorktreePath(repoRoot, branchName) {
|
|
1362
|
+
return path.join(repoRoot, defaultAgentWorktreeRelativeDir(), branchName.replace(/\//g, '__'));
|
|
1239
1363
|
}
|
|
1240
1364
|
|
|
1241
1365
|
function gitRefExists(repoRoot, ref) {
|
|
1242
1366
|
return run('git', ['-C', repoRoot, 'show-ref', '--verify', '--quiet', ref]).status === 0;
|
|
1243
1367
|
}
|
|
1244
1368
|
|
|
1245
|
-
function
|
|
1369
|
+
function resolveProtectedBaseSandboxStartRef(repoRoot, baseBranch) {
|
|
1246
1370
|
run('git', ['-C', repoRoot, 'fetch', 'origin', baseBranch, '--quiet'], { timeout: 20_000 });
|
|
1247
1371
|
if (gitRefExists(repoRoot, `refs/remotes/origin/${baseBranch}`)) {
|
|
1248
1372
|
return `origin/${baseBranch}`;
|
|
@@ -1250,18 +1374,21 @@ function resolveDoctorSandboxStartRef(repoRoot, baseBranch) {
|
|
|
1250
1374
|
if (gitRefExists(repoRoot, `refs/heads/${baseBranch}`)) {
|
|
1251
1375
|
return baseBranch;
|
|
1252
1376
|
}
|
|
1253
|
-
|
|
1377
|
+
if (currentBranchName(repoRoot) === baseBranch) {
|
|
1378
|
+
return null;
|
|
1379
|
+
}
|
|
1380
|
+
throw new Error(`Unable to find base ref for sandbox bootstrap: ${baseBranch}`);
|
|
1254
1381
|
}
|
|
1255
1382
|
|
|
1256
|
-
function
|
|
1257
|
-
const branchPrefix =
|
|
1383
|
+
function startProtectedBaseSandboxFallback(blocked, sandboxSuffix) {
|
|
1384
|
+
const branchPrefix = protectedBaseSandboxBranchPrefix();
|
|
1258
1385
|
let selectedBranch = '';
|
|
1259
1386
|
let selectedWorktreePath = '';
|
|
1260
1387
|
|
|
1261
1388
|
for (let attempt = 0; attempt < 30; attempt += 1) {
|
|
1262
|
-
const suffix = attempt === 0 ?
|
|
1389
|
+
const suffix = attempt === 0 ? sandboxSuffix : `${attempt + 1}-${sandboxSuffix}`;
|
|
1263
1390
|
const candidateBranch = `${branchPrefix}-${suffix}`;
|
|
1264
|
-
const candidateWorktreePath =
|
|
1391
|
+
const candidateWorktreePath = protectedBaseSandboxWorktreePath(blocked.repoRoot, candidateBranch);
|
|
1265
1392
|
if (gitRefExists(blocked.repoRoot, `refs/heads/${candidateBranch}`)) {
|
|
1266
1393
|
continue;
|
|
1267
1394
|
}
|
|
@@ -1274,20 +1401,36 @@ function startDoctorSandboxFallback(blocked) {
|
|
|
1274
1401
|
}
|
|
1275
1402
|
|
|
1276
1403
|
if (!selectedBranch || !selectedWorktreePath) {
|
|
1277
|
-
throw new Error('Unable to allocate unique sandbox branch/worktree
|
|
1404
|
+
throw new Error('Unable to allocate unique sandbox branch/worktree');
|
|
1278
1405
|
}
|
|
1279
1406
|
|
|
1280
1407
|
fs.mkdirSync(path.dirname(selectedWorktreePath), { recursive: true });
|
|
1281
|
-
const startRef =
|
|
1282
|
-
const
|
|
1283
|
-
'
|
|
1284
|
-
['-C', blocked.repoRoot, 'worktree', 'add', '
|
|
1285
|
-
);
|
|
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);
|
|
1286
1413
|
if (isSpawnFailure(addResult)) {
|
|
1287
1414
|
throw addResult.error;
|
|
1288
1415
|
}
|
|
1289
1416
|
if (addResult.status !== 0) {
|
|
1290
|
-
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
|
+
}
|
|
1291
1434
|
}
|
|
1292
1435
|
|
|
1293
1436
|
return {
|
|
@@ -1302,16 +1445,20 @@ function startDoctorSandboxFallback(blocked) {
|
|
|
1302
1445
|
};
|
|
1303
1446
|
}
|
|
1304
1447
|
|
|
1305
|
-
function
|
|
1448
|
+
function startProtectedBaseSandbox(blocked, { taskName, sandboxSuffix }) {
|
|
1449
|
+
if (sandboxSuffix === 'gx-doctor') {
|
|
1450
|
+
return startProtectedBaseSandboxFallback(blocked, sandboxSuffix);
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1306
1453
|
const startScript = path.join(blocked.repoRoot, 'scripts', 'agent-branch-start.sh');
|
|
1307
1454
|
if (!fs.existsSync(startScript)) {
|
|
1308
|
-
return
|
|
1455
|
+
return startProtectedBaseSandboxFallback(blocked, sandboxSuffix);
|
|
1309
1456
|
}
|
|
1310
1457
|
|
|
1311
1458
|
const startResult = run('bash', [
|
|
1312
1459
|
startScript,
|
|
1313
1460
|
'--task',
|
|
1314
|
-
|
|
1461
|
+
taskName,
|
|
1315
1462
|
'--agent',
|
|
1316
1463
|
SHORT_TOOL_NAME,
|
|
1317
1464
|
'--base',
|
|
@@ -1321,7 +1468,7 @@ function startDoctorSandbox(blocked) {
|
|
|
1321
1468
|
throw startResult.error;
|
|
1322
1469
|
}
|
|
1323
1470
|
if (startResult.status !== 0) {
|
|
1324
|
-
return
|
|
1471
|
+
return startProtectedBaseSandboxFallback(blocked, sandboxSuffix);
|
|
1325
1472
|
}
|
|
1326
1473
|
|
|
1327
1474
|
const metadata = extractAgentBranchStartMetadata(startResult.stdout);
|
|
@@ -1336,11 +1483,11 @@ function startDoctorSandbox(blocked) {
|
|
|
1336
1483
|
if (!restoreResult.ok) {
|
|
1337
1484
|
const detail = [restoreResult.stderr, restoreResult.stdout].filter(Boolean).join('\n').trim();
|
|
1338
1485
|
throw new Error(
|
|
1339
|
-
`
|
|
1486
|
+
`sandbox startup switched protected base checkout and could not restore '${blocked.branch}'.` +
|
|
1340
1487
|
(detail ? `\n${detail}` : ''),
|
|
1341
1488
|
);
|
|
1342
1489
|
}
|
|
1343
|
-
return
|
|
1490
|
+
return startProtectedBaseSandboxFallback(blocked, sandboxSuffix);
|
|
1344
1491
|
}
|
|
1345
1492
|
|
|
1346
1493
|
return {
|
|
@@ -1350,6 +1497,59 @@ function startDoctorSandbox(blocked) {
|
|
|
1350
1497
|
};
|
|
1351
1498
|
}
|
|
1352
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
|
+
|
|
1353
1553
|
function parseGitPathList(output) {
|
|
1354
1554
|
return String(output || '')
|
|
1355
1555
|
.split('\n')
|
|
@@ -1388,6 +1588,59 @@ function collectDoctorDeletedPaths(worktreePath) {
|
|
|
1388
1588
|
return Array.from(deleted);
|
|
1389
1589
|
}
|
|
1390
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
|
+
|
|
1391
1644
|
function claimDoctorChangedLocks(metadata) {
|
|
1392
1645
|
const lockScript = path.join(metadata.worktreePath, 'scripts', 'agent-file-locks.py');
|
|
1393
1646
|
if (!fs.existsSync(lockScript) || !metadata.branch) {
|
|
@@ -1399,7 +1652,10 @@ function claimDoctorChangedLocks(metadata) {
|
|
|
1399
1652
|
};
|
|
1400
1653
|
}
|
|
1401
1654
|
|
|
1402
|
-
const changedPaths =
|
|
1655
|
+
const changedPaths = Array.from(new Set([
|
|
1656
|
+
...collectDoctorChangedPaths(metadata.worktreePath),
|
|
1657
|
+
...collectDoctorForceAddPaths(metadata.worktreePath),
|
|
1658
|
+
]));
|
|
1403
1659
|
const deletedPaths = collectDoctorDeletedPaths(metadata.worktreePath);
|
|
1404
1660
|
if (changedPaths.length > 0) {
|
|
1405
1661
|
run('python3', [lockScript, 'claim', '--branch', metadata.branch, ...changedPaths], {
|
|
@@ -1431,7 +1687,19 @@ function autoCommitDoctorSandboxChanges(metadata) {
|
|
|
1431
1687
|
}
|
|
1432
1688
|
|
|
1433
1689
|
claimDoctorChangedLocks(metadata);
|
|
1434
|
-
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
|
+
}
|
|
1435
1703
|
const staged = run(
|
|
1436
1704
|
'git',
|
|
1437
1705
|
['-C', metadata.worktreePath, 'diff', '--cached', '--name-only', '--', '.', `:(exclude)${LOCK_FILE_RELATIVE}`],
|
|
@@ -1496,7 +1764,7 @@ function doctorFinishFlowIsPending(output) {
|
|
|
1496
1764
|
);
|
|
1497
1765
|
}
|
|
1498
1766
|
|
|
1499
|
-
function finishDoctorSandboxBranch(blocked, metadata) {
|
|
1767
|
+
function finishDoctorSandboxBranch(blocked, metadata, options = {}) {
|
|
1500
1768
|
const finishScript = path.join(metadata.worktreePath, 'scripts', 'agent-branch-finish.sh');
|
|
1501
1769
|
if (!fs.existsSync(finishScript)) {
|
|
1502
1770
|
return {
|
|
@@ -1538,10 +1806,11 @@ function finishDoctorSandboxBranch(blocked, metadata) {
|
|
|
1538
1806
|
const waitTimeoutSeconds =
|
|
1539
1807
|
Number.isFinite(rawWaitTimeoutSeconds) && rawWaitTimeoutSeconds >= 30 ? rawWaitTimeoutSeconds : 1800;
|
|
1540
1808
|
const finishTimeoutMs = Math.max(180_000, (waitTimeoutSeconds + 60) * 1000);
|
|
1809
|
+
const waitForMergeArg = options.waitForMerge === false ? '--no-wait-for-merge' : '--wait-for-merge';
|
|
1541
1810
|
|
|
1542
1811
|
const finishResult = run(
|
|
1543
1812
|
'bash',
|
|
1544
|
-
[finishScript, '--branch', metadata.branch, '--
|
|
1813
|
+
[finishScript, '--branch', metadata.branch, '--base', blocked.branch, '--via-pr', waitForMergeArg],
|
|
1545
1814
|
{ cwd: metadata.worktreePath, timeout: finishTimeoutMs },
|
|
1546
1815
|
);
|
|
1547
1816
|
if (isSpawnFailure(finishResult)) {
|
|
@@ -1580,8 +1849,186 @@ function finishDoctorSandboxBranch(blocked, metadata) {
|
|
|
1580
1849
|
};
|
|
1581
1850
|
}
|
|
1582
1851
|
|
|
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 },
|
|
1959
|
+
);
|
|
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
|
+
}
|
|
1982
|
+
|
|
1983
|
+
let cleanupResult;
|
|
1984
|
+
try {
|
|
1985
|
+
cleanupResult = cleanupProtectedBaseSandbox(blocked.repoRoot, metadata);
|
|
1986
|
+
} catch (error) {
|
|
1987
|
+
return {
|
|
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 || '',
|
|
2004
|
+
};
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
if (stashRef) {
|
|
2008
|
+
run('git', ['-C', blocked.repoRoot, 'stash', 'drop', stashRef], { timeout: 20_000 });
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
return {
|
|
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,
|
|
2018
|
+
};
|
|
2019
|
+
}
|
|
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
|
+
|
|
1583
2027
|
function runDoctorInSandbox(options, blocked) {
|
|
1584
|
-
const startResult =
|
|
2028
|
+
const startResult = startProtectedBaseSandbox(blocked, {
|
|
2029
|
+
taskName: `${SHORT_TOOL_NAME}-doctor`,
|
|
2030
|
+
sandboxSuffix: 'gx-doctor',
|
|
2031
|
+
});
|
|
1585
2032
|
const metadata = startResult.metadata;
|
|
1586
2033
|
|
|
1587
2034
|
const sandboxTarget = resolveSandboxTarget(blocked.repoRoot, metadata.worktreePath, options.target);
|
|
@@ -1603,10 +2050,15 @@ function runDoctorInSandbox(options, blocked) {
|
|
|
1603
2050
|
note: 'sandbox doctor did not complete successfully',
|
|
1604
2051
|
};
|
|
1605
2052
|
|
|
2053
|
+
let protectedBaseRepairSyncResult = {
|
|
2054
|
+
status: 'skipped',
|
|
2055
|
+
note: 'sandbox doctor did not complete successfully',
|
|
2056
|
+
};
|
|
1606
2057
|
let lockSyncResult = {
|
|
1607
2058
|
status: 'skipped',
|
|
1608
2059
|
note: 'sandbox doctor did not complete successfully',
|
|
1609
2060
|
};
|
|
2061
|
+
let sandboxLockContent = null;
|
|
1610
2062
|
let postSandboxAutoFinishSummary = {
|
|
1611
2063
|
enabled: false,
|
|
1612
2064
|
attempted: 0,
|
|
@@ -1639,7 +2091,7 @@ function runDoctorInSandbox(options, blocked) {
|
|
|
1639
2091
|
if (!options.dryRun) {
|
|
1640
2092
|
autoCommitResult = autoCommitDoctorSandboxChanges(metadata);
|
|
1641
2093
|
if (autoCommitResult.status === 'committed') {
|
|
1642
|
-
finishResult = finishDoctorSandboxBranch(blocked, metadata);
|
|
2094
|
+
finishResult = finishDoctorSandboxBranch(blocked, metadata, options);
|
|
1643
2095
|
} else if (autoCommitResult.status === 'no-changes') {
|
|
1644
2096
|
finishResult = {
|
|
1645
2097
|
status: 'skipped',
|
|
@@ -1675,7 +2127,11 @@ function runDoctorInSandbox(options, blocked) {
|
|
|
1675
2127
|
note: `${LOCK_FILE_RELATIVE} missing in sandbox worktree`,
|
|
1676
2128
|
};
|
|
1677
2129
|
} else {
|
|
1678
|
-
const sourceContent =
|
|
2130
|
+
const sourceContent = stripDoctorSandboxLocks(
|
|
2131
|
+
fs.readFileSync(sandboxLockPath, 'utf8'),
|
|
2132
|
+
metadata.branch,
|
|
2133
|
+
);
|
|
2134
|
+
sandboxLockContent = sourceContent;
|
|
1679
2135
|
const destinationContent = fs.readFileSync(baseLockPath, 'utf8');
|
|
1680
2136
|
if (sourceContent === destinationContent) {
|
|
1681
2137
|
lockSyncResult = {
|
|
@@ -1692,6 +2148,62 @@ function runDoctorInSandbox(options, blocked) {
|
|
|
1692
2148
|
}
|
|
1693
2149
|
}
|
|
1694
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
|
+
|
|
1695
2207
|
postSandboxAutoFinishSummary = autoFinishReadyAgentBranches(blocked.repoRoot, {
|
|
1696
2208
|
baseBranch: blocked.branch,
|
|
1697
2209
|
dryRun: options.dryRun,
|
|
@@ -1708,6 +2220,7 @@ function runDoctorInSandbox(options, blocked) {
|
|
|
1708
2220
|
JSON.stringify(
|
|
1709
2221
|
{
|
|
1710
2222
|
...parsed,
|
|
2223
|
+
protectedBaseRepairSync: protectedBaseRepairSyncResult,
|
|
1711
2224
|
sandboxOmxScaffoldSync: omxScaffoldSyncResult,
|
|
1712
2225
|
sandboxLockSync: lockSyncResult,
|
|
1713
2226
|
sandboxAutoCommit: autoCommitResult,
|
|
@@ -1738,14 +2251,38 @@ function runDoctorInSandbox(options, blocked) {
|
|
|
1738
2251
|
if (nestedResult.status === 0) {
|
|
1739
2252
|
if (autoCommitResult.status === 'committed') {
|
|
1740
2253
|
console.log(
|
|
1741
|
-
`[${TOOL_NAME}] Auto-committed doctor repairs in sandbox branch '${metadata.branch}'.`,
|
|
2254
|
+
`[${TOOL_NAME}] Auto-committed doctor repairs in sandbox branch '${metadata.branch}'.`,
|
|
2255
|
+
);
|
|
2256
|
+
} else if (autoCommitResult.status === 'failed') {
|
|
2257
|
+
console.log(`[${TOOL_NAME}] Doctor sandbox auto-commit failed; branch left for manual follow-up.`);
|
|
2258
|
+
if (autoCommitResult.stdout) process.stdout.write(autoCommitResult.stdout);
|
|
2259
|
+
if (autoCommitResult.stderr) process.stderr.write(autoCommitResult.stderr);
|
|
2260
|
+
} else {
|
|
2261
|
+
console.log(`[${TOOL_NAME}] Doctor sandbox auto-commit skipped: ${autoCommitResult.note}.`);
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
if (protectedBaseRepairSyncResult.status === 'merged') {
|
|
2265
|
+
console.log(`[${TOOL_NAME}] Fast-forwarded tracked doctor repairs into the protected branch workspace.`);
|
|
2266
|
+
} else if (protectedBaseRepairSyncResult.status === 'unchanged') {
|
|
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);
|
|
2274
|
+
} else {
|
|
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}).`,
|
|
1742
2281
|
);
|
|
1743
|
-
} else if (
|
|
1744
|
-
console.log(`[${TOOL_NAME}]
|
|
1745
|
-
if (autoCommitResult.stdout) process.stdout.write(autoCommitResult.stdout);
|
|
1746
|
-
if (autoCommitResult.stderr) process.stderr.write(autoCommitResult.stderr);
|
|
2282
|
+
} else if (lockSyncResult.status === 'unchanged') {
|
|
2283
|
+
console.log(`[${TOOL_NAME}] Lock registry already synced in protected branch workspace.`);
|
|
1747
2284
|
} else {
|
|
1748
|
-
console.log(`[${TOOL_NAME}]
|
|
2285
|
+
console.log(`[${TOOL_NAME}] Lock registry sync skipped: ${lockSyncResult.note}.`);
|
|
1749
2286
|
}
|
|
1750
2287
|
|
|
1751
2288
|
if (finishResult.status === 'completed') {
|
|
@@ -1763,22 +2300,13 @@ function runDoctorInSandbox(options, blocked) {
|
|
|
1763
2300
|
if (finishResult.stderr) process.stderr.write(finishResult.stderr);
|
|
1764
2301
|
} else if (finishResult.status === 'failed') {
|
|
1765
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}'.`);
|
|
1766
2304
|
if (finishResult.stdout) process.stdout.write(finishResult.stdout);
|
|
1767
2305
|
if (finishResult.stderr) process.stderr.write(finishResult.stderr);
|
|
1768
2306
|
} else {
|
|
1769
2307
|
console.log(`[${TOOL_NAME}] Auto-finish skipped: ${finishResult.note}.`);
|
|
1770
2308
|
}
|
|
1771
2309
|
|
|
1772
|
-
if (lockSyncResult.status === 'synced') {
|
|
1773
|
-
console.log(
|
|
1774
|
-
`[${TOOL_NAME}] Synced repaired lock registry back to protected branch workspace (${LOCK_FILE_RELATIVE}).`,
|
|
1775
|
-
);
|
|
1776
|
-
} else if (lockSyncResult.status === 'unchanged') {
|
|
1777
|
-
console.log(`[${TOOL_NAME}] Lock registry already synced in protected branch workspace.`);
|
|
1778
|
-
} else {
|
|
1779
|
-
console.log(`[${TOOL_NAME}] Lock registry sync skipped: ${lockSyncResult.note}.`);
|
|
1780
|
-
}
|
|
1781
|
-
|
|
1782
2310
|
if (postSandboxAutoFinishSummary.enabled) {
|
|
1783
2311
|
console.log(
|
|
1784
2312
|
`[${TOOL_NAME}] Auto-finish sweep (base=${blocked.branch}): attempted=${postSandboxAutoFinishSummary.attempted}, completed=${postSandboxAutoFinishSummary.completed}, skipped=${postSandboxAutoFinishSummary.skipped}, failed=${postSandboxAutoFinishSummary.failed}`,
|
|
@@ -1813,12 +2341,89 @@ function runDoctorInSandbox(options, blocked) {
|
|
|
1813
2341
|
) {
|
|
1814
2342
|
exitCode = 1;
|
|
1815
2343
|
}
|
|
2344
|
+
if (exitCode === 0 && protectedBaseRepairSyncResult.status === 'failed') {
|
|
2345
|
+
exitCode = 1;
|
|
2346
|
+
}
|
|
1816
2347
|
process.exitCode = exitCode;
|
|
1817
2348
|
return;
|
|
1818
2349
|
}
|
|
1819
2350
|
process.exitCode = 1;
|
|
1820
2351
|
}
|
|
1821
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
|
+
|
|
1822
2427
|
function parseTargetFlag(rawArgs, defaultTarget = process.cwd()) {
|
|
1823
2428
|
const remaining = [];
|
|
1824
2429
|
let target = defaultTarget;
|
|
@@ -2000,6 +2605,19 @@ function inferGithubRepoFromOrigin(repoRoot) {
|
|
|
2000
2605
|
return `github.com/${slug}`;
|
|
2001
2606
|
}
|
|
2002
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
|
+
|
|
2003
2621
|
function resolveScorecardRepo(repoRoot, explicitRepo) {
|
|
2004
2622
|
if (explicitRepo) {
|
|
2005
2623
|
return explicitRepo.trim();
|
|
@@ -2483,6 +3101,66 @@ function currentBranchName(repoRoot) {
|
|
|
2483
3101
|
return branch;
|
|
2484
3102
|
}
|
|
2485
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
|
+
|
|
2486
3164
|
function workingTreeIsDirty(repoRoot) {
|
|
2487
3165
|
const result = gitRun(repoRoot, ['status', '--porcelain'], { allowFailure: true });
|
|
2488
3166
|
if (result.status !== 0) {
|
|
@@ -3294,6 +3972,17 @@ function parseVersionString(version) {
|
|
|
3294
3972
|
];
|
|
3295
3973
|
}
|
|
3296
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
|
+
|
|
3297
3986
|
function isNewerVersion(latest, current) {
|
|
3298
3987
|
const latestParts = parseVersionString(latest);
|
|
3299
3988
|
const currentParts = parseVersionString(current);
|
|
@@ -3302,11 +3991,7 @@ function isNewerVersion(latest, current) {
|
|
|
3302
3991
|
return String(latest || '').trim() !== String(current || '').trim();
|
|
3303
3992
|
}
|
|
3304
3993
|
|
|
3305
|
-
|
|
3306
|
-
if (latestParts[index] > currentParts[index]) return true;
|
|
3307
|
-
if (latestParts[index] < currentParts[index]) return false;
|
|
3308
|
-
}
|
|
3309
|
-
return false;
|
|
3994
|
+
return compareParsedVersions(latestParts, currentParts) > 0;
|
|
3310
3995
|
}
|
|
3311
3996
|
|
|
3312
3997
|
function parseNpmVersionOutput(stdout) {
|
|
@@ -3897,6 +4582,10 @@ function runInstallInternal(options) {
|
|
|
3897
4582
|
}
|
|
3898
4583
|
const operations = [];
|
|
3899
4584
|
|
|
4585
|
+
if (!options.skipGitignore) {
|
|
4586
|
+
operations.push(ensureManagedGitignore(repoRoot, Boolean(options.dryRun)));
|
|
4587
|
+
}
|
|
4588
|
+
|
|
3900
4589
|
operations.push(...ensureOmxScaffold(repoRoot, Boolean(options.dryRun)));
|
|
3901
4590
|
|
|
3902
4591
|
for (const templateFile of TEMPLATE_FILES) {
|
|
@@ -3904,9 +4593,6 @@ function runInstallInternal(options) {
|
|
|
3904
4593
|
}
|
|
3905
4594
|
|
|
3906
4595
|
operations.push(ensureLockRegistry(repoRoot, Boolean(options.dryRun)));
|
|
3907
|
-
if (!options.skipGitignore) {
|
|
3908
|
-
operations.push(ensureManagedGitignore(repoRoot, Boolean(options.dryRun)));
|
|
3909
|
-
}
|
|
3910
4596
|
|
|
3911
4597
|
if (!options.skipPackageJson) {
|
|
3912
4598
|
operations.push(ensurePackageScripts(repoRoot, Boolean(options.dryRun), { force: Boolean(options.force) }));
|
|
@@ -3941,6 +4627,10 @@ function runFixInternal(options) {
|
|
|
3941
4627
|
}
|
|
3942
4628
|
const operations = [];
|
|
3943
4629
|
|
|
4630
|
+
if (!options.skipGitignore) {
|
|
4631
|
+
operations.push(ensureManagedGitignore(repoRoot, Boolean(options.dryRun)));
|
|
4632
|
+
}
|
|
4633
|
+
|
|
3944
4634
|
operations.push(...ensureOmxScaffold(repoRoot, Boolean(options.dryRun)));
|
|
3945
4635
|
|
|
3946
4636
|
for (const templateFile of TEMPLATE_FILES) {
|
|
@@ -3948,9 +4638,6 @@ function runFixInternal(options) {
|
|
|
3948
4638
|
}
|
|
3949
4639
|
|
|
3950
4640
|
operations.push(ensureLockRegistry(repoRoot, Boolean(options.dryRun)));
|
|
3951
|
-
if (!options.skipGitignore) {
|
|
3952
|
-
operations.push(ensureManagedGitignore(repoRoot, Boolean(options.dryRun)));
|
|
3953
|
-
}
|
|
3954
4641
|
|
|
3955
4642
|
const lockState = lockStateOrError(repoRoot);
|
|
3956
4643
|
if (!lockState.ok) {
|
|
@@ -3994,8 +4681,7 @@ function runFixInternal(options) {
|
|
|
3994
4681
|
function runScanInternal(options) {
|
|
3995
4682
|
const repoRoot = resolveRepoRoot(options.target);
|
|
3996
4683
|
const guardexToggle = resolveGuardexRepoToggle(repoRoot);
|
|
3997
|
-
const
|
|
3998
|
-
const branch = currentBranchResult.status === 0 ? currentBranchResult.stdout.trim() : '(unknown)';
|
|
4684
|
+
const branch = readBranchDisplayName(repoRoot);
|
|
3999
4685
|
if (!guardexToggle.enabled) {
|
|
4000
4686
|
return {
|
|
4001
4687
|
repoRoot,
|
|
@@ -4421,26 +5107,118 @@ function scan(rawArgs) {
|
|
|
4421
5107
|
}
|
|
4422
5108
|
|
|
4423
5109
|
function doctor(rawArgs) {
|
|
4424
|
-
const options =
|
|
4425
|
-
|
|
4426
|
-
|
|
4427
|
-
|
|
4428
|
-
|
|
4429
|
-
|
|
4430
|
-
|
|
4431
|
-
|
|
4432
|
-
|
|
4433
|
-
|
|
5110
|
+
const options = parseDoctorArgs(rawArgs);
|
|
5111
|
+
const topRepoRoot = resolveRepoRoot(options.target);
|
|
5112
|
+
const discoveredRepos = options.recursive
|
|
5113
|
+
? discoverNestedGitRepos(topRepoRoot, {
|
|
5114
|
+
maxDepth: options.nestedMaxDepth,
|
|
5115
|
+
extraSkip: options.nestedSkipDirs,
|
|
5116
|
+
includeSubmodules: options.includeSubmodules,
|
|
5117
|
+
})
|
|
5118
|
+
: [topRepoRoot];
|
|
5119
|
+
|
|
5120
|
+
if (discoveredRepos.length > 1) {
|
|
5121
|
+
if (!options.json) {
|
|
5122
|
+
console.log(
|
|
5123
|
+
`[${TOOL_NAME}] Detected ${discoveredRepos.length} git repos under ${topRepoRoot}. ` +
|
|
5124
|
+
`Repairing each with doctor (use --single-repo to limit to the target).`,
|
|
5125
|
+
);
|
|
5126
|
+
}
|
|
5127
|
+
|
|
5128
|
+
const repoResults = [];
|
|
5129
|
+
let aggregateExitCode = 0;
|
|
5130
|
+
for (const repoPath of discoveredRepos) {
|
|
5131
|
+
if (!options.json) {
|
|
5132
|
+
console.log(`[${TOOL_NAME}] ── Doctor target: ${repoPath} ──`);
|
|
5133
|
+
}
|
|
5134
|
+
|
|
5135
|
+
const nestedResult = run(
|
|
5136
|
+
process.execPath,
|
|
5137
|
+
[
|
|
5138
|
+
path.resolve(__filename),
|
|
5139
|
+
'doctor',
|
|
5140
|
+
'--single-repo',
|
|
5141
|
+
'--target',
|
|
5142
|
+
repoPath,
|
|
5143
|
+
...(options.dropStaleLocks ? [] : ['--keep-stale-locks']),
|
|
5144
|
+
...(options.skipAgents ? ['--skip-agents'] : []),
|
|
5145
|
+
...(options.skipPackageJson ? ['--skip-package-json'] : []),
|
|
5146
|
+
...(options.skipGitignore ? ['--no-gitignore'] : []),
|
|
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',
|
|
5150
|
+
...(options.json ? ['--json'] : []),
|
|
5151
|
+
...(options.allowProtectedBaseWrite ? ['--allow-protected-base-write'] : []),
|
|
5152
|
+
],
|
|
5153
|
+
{ cwd: topRepoRoot },
|
|
5154
|
+
);
|
|
5155
|
+
if (isSpawnFailure(nestedResult)) {
|
|
5156
|
+
throw nestedResult.error;
|
|
5157
|
+
}
|
|
5158
|
+
|
|
5159
|
+
const exitCode = typeof nestedResult.status === 'number' ? nestedResult.status : 1;
|
|
5160
|
+
if (exitCode !== 0 && aggregateExitCode === 0) {
|
|
5161
|
+
aggregateExitCode = exitCode;
|
|
5162
|
+
}
|
|
5163
|
+
|
|
5164
|
+
if (options.json) {
|
|
5165
|
+
let parsedResult = null;
|
|
5166
|
+
if (nestedResult.stdout) {
|
|
5167
|
+
try {
|
|
5168
|
+
parsedResult = JSON.parse(nestedResult.stdout);
|
|
5169
|
+
} catch {
|
|
5170
|
+
parsedResult = null;
|
|
5171
|
+
}
|
|
5172
|
+
}
|
|
5173
|
+
repoResults.push(
|
|
5174
|
+
parsedResult
|
|
5175
|
+
? { repoRoot: repoPath, exitCode, result: parsedResult }
|
|
5176
|
+
: {
|
|
5177
|
+
repoRoot: repoPath,
|
|
5178
|
+
exitCode,
|
|
5179
|
+
stdout: nestedResult.stdout || '',
|
|
5180
|
+
stderr: nestedResult.stderr || '',
|
|
5181
|
+
},
|
|
5182
|
+
);
|
|
5183
|
+
} else {
|
|
5184
|
+
if (nestedResult.stdout) process.stdout.write(nestedResult.stdout);
|
|
5185
|
+
if (nestedResult.stderr) process.stderr.write(nestedResult.stderr);
|
|
5186
|
+
process.stdout.write('\n');
|
|
5187
|
+
}
|
|
5188
|
+
}
|
|
5189
|
+
|
|
5190
|
+
if (options.json) {
|
|
5191
|
+
process.stdout.write(
|
|
5192
|
+
JSON.stringify(
|
|
5193
|
+
{
|
|
5194
|
+
repoRoot: topRepoRoot,
|
|
5195
|
+
recursive: true,
|
|
5196
|
+
repos: repoResults,
|
|
5197
|
+
},
|
|
5198
|
+
null,
|
|
5199
|
+
2,
|
|
5200
|
+
) + '\n',
|
|
5201
|
+
);
|
|
5202
|
+
}
|
|
5203
|
+
|
|
5204
|
+
process.exitCode = aggregateExitCode;
|
|
5205
|
+
return;
|
|
5206
|
+
}
|
|
5207
|
+
|
|
5208
|
+
const singleRepoOptions = {
|
|
5209
|
+
...options,
|
|
5210
|
+
target: topRepoRoot,
|
|
5211
|
+
};
|
|
4434
5212
|
|
|
4435
|
-
const blocked = protectedBaseWriteBlock(
|
|
5213
|
+
const blocked = protectedBaseWriteBlock(singleRepoOptions, { requireBootstrap: false });
|
|
4436
5214
|
if (blocked) {
|
|
4437
|
-
runDoctorInSandbox(
|
|
5215
|
+
runDoctorInSandbox(singleRepoOptions, blocked);
|
|
4438
5216
|
return;
|
|
4439
5217
|
}
|
|
4440
5218
|
|
|
4441
|
-
assertProtectedMainWriteAllowed(
|
|
4442
|
-
const fixPayload = runFixInternal(
|
|
4443
|
-
const scanResult = runScanInternal({ target:
|
|
5219
|
+
assertProtectedMainWriteAllowed(singleRepoOptions, 'doctor');
|
|
5220
|
+
const fixPayload = runFixInternal(singleRepoOptions);
|
|
5221
|
+
const scanResult = runScanInternal({ target: singleRepoOptions.target, json: false });
|
|
4444
5222
|
const currentBaseBranch = currentBranchName(scanResult.repoRoot);
|
|
4445
5223
|
const autoFinishSummary = scanResult.guardexEnabled === false
|
|
4446
5224
|
? {
|
|
@@ -4453,12 +5231,12 @@ function doctor(rawArgs) {
|
|
|
4453
5231
|
}
|
|
4454
5232
|
: autoFinishReadyAgentBranches(scanResult.repoRoot, {
|
|
4455
5233
|
baseBranch: currentBaseBranch,
|
|
4456
|
-
dryRun:
|
|
5234
|
+
dryRun: singleRepoOptions.dryRun,
|
|
4457
5235
|
});
|
|
4458
5236
|
const safe = scanResult.guardexEnabled === false || (scanResult.errors === 0 && scanResult.warnings === 0);
|
|
4459
5237
|
const musafe = safe;
|
|
4460
5238
|
|
|
4461
|
-
if (
|
|
5239
|
+
if (singleRepoOptions.json) {
|
|
4462
5240
|
process.stdout.write(
|
|
4463
5241
|
JSON.stringify(
|
|
4464
5242
|
{
|
|
@@ -4469,7 +5247,7 @@ function doctor(rawArgs) {
|
|
|
4469
5247
|
fix: {
|
|
4470
5248
|
operations: fixPayload.operations,
|
|
4471
5249
|
hookResult: fixPayload.hookResult,
|
|
4472
|
-
dryRun: Boolean(
|
|
5250
|
+
dryRun: Boolean(singleRepoOptions.dryRun),
|
|
4473
5251
|
},
|
|
4474
5252
|
scan: {
|
|
4475
5253
|
guardexEnabled: scanResult.guardexEnabled !== false,
|
|
@@ -4997,31 +5775,24 @@ function setup(rawArgs) {
|
|
|
4997
5775
|
console.log(`[${TOOL_NAME}] ── Setup target: ${repoPath} ──`);
|
|
4998
5776
|
}
|
|
4999
5777
|
|
|
5000
|
-
|
|
5001
|
-
|
|
5002
|
-
|
|
5003
|
-
|
|
5004
|
-
|
|
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;
|
|
5005
5785
|
}
|
|
5006
|
-
printOperations(`Setup/install${repoLabel}`, installPayload, perRepoOptions.dryRun);
|
|
5007
5786
|
|
|
5008
|
-
const fixPayload =
|
|
5009
|
-
|
|
5010
|
-
dryRun: perRepoOptions.dryRun,
|
|
5011
|
-
force: perRepoOptions.force,
|
|
5012
|
-
dropStaleLocks: true,
|
|
5013
|
-
skipAgents: perRepoOptions.skipAgents,
|
|
5014
|
-
skipPackageJson: perRepoOptions.skipPackageJson,
|
|
5015
|
-
skipGitignore: perRepoOptions.skipGitignore,
|
|
5016
|
-
});
|
|
5787
|
+
const { installPayload, fixPayload, parentWorkspace } = runSetupBootstrapInternal(perRepoOptions);
|
|
5788
|
+
printOperations(`Setup/install${repoLabel}`, installPayload, perRepoOptions.dryRun);
|
|
5017
5789
|
printOperations(`Setup/fix${repoLabel}`, fixPayload, perRepoOptions.dryRun);
|
|
5018
5790
|
|
|
5019
5791
|
if (perRepoOptions.dryRun) {
|
|
5020
5792
|
continue;
|
|
5021
5793
|
}
|
|
5022
5794
|
|
|
5023
|
-
if (
|
|
5024
|
-
const parentWorkspace = buildParentWorkspaceView(installPayload.repoRoot);
|
|
5795
|
+
if (parentWorkspace) {
|
|
5025
5796
|
console.log(`[${TOOL_NAME}] Parent workspace view: ${parentWorkspace.workspacePath}`);
|
|
5026
5797
|
}
|
|
5027
5798
|
|
|
@@ -5042,6 +5813,7 @@ function setup(rawArgs) {
|
|
|
5042
5813
|
} else if (autoFinishSummary.details.length > 0) {
|
|
5043
5814
|
console.log(`[${TOOL_NAME}] ${autoFinishSummary.details[0]}`);
|
|
5044
5815
|
}
|
|
5816
|
+
printSetupRepoHints(scanResult.repoRoot, currentBaseBranch, repoLabel);
|
|
5045
5817
|
|
|
5046
5818
|
aggregateErrors += scanResult.errors;
|
|
5047
5819
|
aggregateWarnings += scanResult.warnings;
|
|
@@ -5101,6 +5873,156 @@ function ensureCleanWorkingTree(repoRoot) {
|
|
|
5101
5873
|
}
|
|
5102
5874
|
}
|
|
5103
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
|
+
|
|
5104
6026
|
function release(rawArgs) {
|
|
5105
6027
|
if (rawArgs.length > 0) {
|
|
5106
6028
|
throw new Error(`Unknown option: ${rawArgs[0]}`);
|
|
@@ -5116,13 +6038,74 @@ function release(rawArgs) {
|
|
|
5116
6038
|
ensureMainBranch(repoRoot);
|
|
5117
6039
|
ensureCleanWorkingTree(repoRoot);
|
|
5118
6040
|
|
|
5119
|
-
|
|
5120
|
-
|
|
5121
|
-
|
|
5122
|
-
|
|
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}.`);
|
|
6092
|
+
}
|
|
6093
|
+
|
|
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);
|
|
5123
6106
|
}
|
|
5124
6107
|
|
|
5125
|
-
console.log(`[${TOOL_NAME}] ✅
|
|
6108
|
+
console.log(`[${TOOL_NAME}] ✅ GitHub release ${currentTag} is synced to the README history.`);
|
|
5126
6109
|
process.exitCode = 0;
|
|
5127
6110
|
}
|
|
5128
6111
|
|
|
@@ -5926,6 +6909,7 @@ function main() {
|
|
|
5926
6909
|
}
|
|
5927
6910
|
|
|
5928
6911
|
if (command === '--version' || command === '-v' || command === 'version') {
|
|
6912
|
+
maybeSelfUpdateBeforeStatus();
|
|
5929
6913
|
console.log(packageJson.version);
|
|
5930
6914
|
return;
|
|
5931
6915
|
}
|