harness-everything 0.25.1 â 0.27.0
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +37 -0
- package/ci/mechanism-34-worktree-submodule-reachability.test.js +307 -0
- package/ci/mechanism-35-verification-contract.test.js +416 -0
- package/ci/mutation-check.js +4 -0
- package/ci/mutation-loader.js +8 -0
- package/docs/audit.md +2 -0
- package/git-commit/SKILL.md +2 -2
- package/git-commit/guides/SUBMODULES.md +54 -1
- package/harness-everything/SKILL.md +2 -2
- package/harness-everything/scripts/verify-gate.js +223 -75
- package/hooks/scripts/lib/workflow-isolation.js +3 -4
- package/hooks/scripts/state-persist.js +1 -1
- package/opencode-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/plugins/harness-everything/.codex-plugin/plugin.json +1 -1
- package/plugins/harness-everything/hooks/scripts/lib/workflow-isolation.js +3 -4
- package/plugins/harness-everything/hooks/scripts/state-persist.js +1 -1
- package/plugins/harness-everything/plugin.json +1 -1
- package/plugins/harness-everything/scripts/lib/verification-contract.js +357 -0
- package/plugins/harness-everything/skills/git-commit/SKILL.md +2 -2
- package/plugins/harness-everything/skills/git-commit/guides/SUBMODULES.md +54 -1
- package/plugins/harness-everything/skills/harness-everything/SKILL.md +2 -2
- package/plugins/harness-everything/skills/harness-everything/scripts/verify-gate.js +223 -75
- package/plugins/harness-everything/skills/using-git-worktrees/SKILL.md +1 -1
- package/plugins/harness-everything/skills/using-git-worktrees/references/submodules-in-worktrees.md +11 -1
- package/plugins/harness-everything/skills/using-git-worktrees/scripts/submodule-reachability.js +265 -0
- package/plugins/harness-everything/skills/verification-loop/SKILL.md +15 -23
- package/plugins/harness-everything/skills/verification-loop/references/project-verification-contract.md +112 -0
- package/plugins/harness-everything/skills/verification-loop/references/verification-phases.md +4 -3
- package/plugins/harness-everything/skills/verification-loop/references/verify-contract.schema.json +32 -0
- package/plugins/harness-everything/skills/verification-loop/references/verify-result.schema.json +65 -0
- package/sbom.cdx.json +7 -7
- package/scripts/lib/verification-contract.js +357 -0
- package/using-git-worktrees/SKILL.md +1 -1
- package/using-git-worktrees/references/submodules-in-worktrees.md +11 -1
- package/using-git-worktrees/scripts/submodule-reachability.js +265 -0
- package/verification-loop/SKILL.md +15 -23
- package/verification-loop/references/project-verification-contract.md +112 -0
- package/verification-loop/references/verification-phases.md +4 -3
- package/verification-loop/references/verify-contract.schema.json +32 -0
- package/verification-loop/references/verify-result.schema.json +65 -0
|
@@ -1,100 +1,248 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
2
4
|
/**
|
|
3
|
-
*
|
|
4
|
-
* Runs the target project's own mechanical checks (lint/test scripts from the
|
|
5
|
-
* nearest package.json) and refuses completion (Exit 1) when any of them fail.
|
|
5
|
+
* Harness project verification gate.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* broken project.
|
|
11
|
-
* - HARNESS_SKIP_PROJECT_CHECKS=1 skips the project-script phase (the tmp-file
|
|
12
|
-
* check above still applies). The gate sets it for its own children so a
|
|
13
|
-
* project whose test suite itself drives the Harness runtime (like this repo) cannot
|
|
14
|
-
* recurse back into the gate.
|
|
7
|
+
* Contract: .harness/verify.json
|
|
8
|
+
* Discovery: read-only candidates; legacy package lint/test remains auto-run.
|
|
9
|
+
* UNCHECKED_* is non-blocking control flow and is never passing-test evidence.
|
|
15
10
|
*/
|
|
16
|
-
const { execSync } = require('child_process');
|
|
17
11
|
const fs = require('fs');
|
|
18
12
|
const path = require('path');
|
|
13
|
+
const { spawnSync } = require('child_process');
|
|
19
14
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
15
|
+
function loadVerificationModel() {
|
|
16
|
+
for (const candidate of [
|
|
17
|
+
path.resolve(__dirname, '../../scripts/lib/verification-contract.js'),
|
|
18
|
+
path.resolve(__dirname, '../../../scripts/lib/verification-contract.js'),
|
|
19
|
+
]) {
|
|
20
|
+
if (fs.existsSync(candidate)) return require(candidate);
|
|
21
|
+
}
|
|
22
|
+
throw new Error('verification-contract runtime is missing from this Harness distribution');
|
|
27
23
|
}
|
|
28
24
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
25
|
+
const {
|
|
26
|
+
detectRoot,
|
|
27
|
+
enumerateSubmoduleRoots,
|
|
28
|
+
findGitRoot,
|
|
29
|
+
readContract,
|
|
30
|
+
} = loadVerificationModel();
|
|
31
|
+
|
|
32
|
+
const jsonMode = process.argv.includes('--json');
|
|
33
|
+
const cwd = path.resolve(process.cwd());
|
|
34
|
+
const repoRoot = findGitRoot(cwd);
|
|
35
|
+
|
|
36
|
+
function emptyReport(status, reason) {
|
|
37
|
+
return {
|
|
38
|
+
status,
|
|
39
|
+
reason,
|
|
40
|
+
repoRoot,
|
|
41
|
+
roots: [],
|
|
42
|
+
checks: [],
|
|
43
|
+
candidates: [],
|
|
44
|
+
};
|
|
33
45
|
}
|
|
34
46
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
47
|
+
function emit(report) {
|
|
48
|
+
if (jsonMode) {
|
|
49
|
+
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
console.log('=== đĄī¸ Harness Verification Gate ===');
|
|
54
|
+
console.log(`Status: ${report.status}`);
|
|
55
|
+
console.log(`Repository: ${report.repoRoot}`);
|
|
56
|
+
if (report.reason) console.log(`Reason: ${report.reason}`);
|
|
57
|
+
|
|
58
|
+
for (const root of report.roots || []) {
|
|
59
|
+
console.log(`\n[${root.status}] ${root.path}`);
|
|
60
|
+
if (root.reason) console.log(` ${root.reason}`);
|
|
61
|
+
for (const check of root.checks || []) {
|
|
62
|
+
const exit = check.exitCode === null ? 'timeout/error' : check.exitCode;
|
|
63
|
+
console.log(` check ${check.id}: ${check.run} (exit ${exit}, ${check.durationMs}ms)`);
|
|
64
|
+
if (check.stdout) process.stdout.write(check.stdout.endsWith('\n') ? check.stdout : check.stdout + '\n');
|
|
65
|
+
if (check.stderr) process.stderr.write(check.stderr.endsWith('\n') ? check.stderr : check.stderr + '\n');
|
|
66
|
+
}
|
|
67
|
+
for (const candidate of root.candidates || []) {
|
|
68
|
+
console.log(` candidate [${candidate.source}]: ${candidate.run}`);
|
|
69
|
+
}
|
|
45
70
|
}
|
|
71
|
+
|
|
72
|
+
if (report.status === 'UNCHECKED_NO_CHECKS' || report.status === 'UNCHECKED_DISCOVERED') {
|
|
73
|
+
console.log('\nExit code is 0 (non-blocking), but NO COMPLETE AUTOMATED EVIDENCE was produced.');
|
|
74
|
+
console.log('PROHIBITED: Do NOT cite this run as proof that tests pass. Perform the listed or project-declared verification first.');
|
|
75
|
+
} else if (report.status === 'PASSED') {
|
|
76
|
+
console.log('\nâ
Verification PASSED. Objective project checks completed successfully.');
|
|
77
|
+
} else if (report.status === 'FAILED') {
|
|
78
|
+
console.error('\nâ Verification FAILED. Fix the reported check/contract error and run this gate again.');
|
|
79
|
+
} else if (report.status === 'SKIPPED') {
|
|
80
|
+
console.log('âšī¸ Project checks skipped by HARNESS_SKIP_PROJECT_CHECKS=1.');
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function finish(report) {
|
|
85
|
+
emit(report);
|
|
86
|
+
process.exit(report.status === 'FAILED' ? 1 : 0);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function statusRank(status) {
|
|
90
|
+
return {
|
|
91
|
+
PASSED: 0,
|
|
92
|
+
UNCHECKED_NO_CHECKS: 1,
|
|
93
|
+
UNCHECKED_DISCOVERED: 2,
|
|
94
|
+
FAILED: 3,
|
|
95
|
+
}[status] ?? 0;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function aggregateStatus(roots) {
|
|
99
|
+
if (!roots.length) return 'UNCHECKED_NO_CHECKS';
|
|
100
|
+
return roots.reduce((worst, root) => statusRank(root.status) > statusRank(worst) ? root.status : worst, 'PASSED');
|
|
46
101
|
}
|
|
47
102
|
|
|
48
|
-
function
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
103
|
+
function executeCheck(check) {
|
|
104
|
+
const started = Date.now();
|
|
105
|
+
const result = spawnSync(check.run, {
|
|
106
|
+
cwd: check.cwd,
|
|
107
|
+
shell: true,
|
|
108
|
+
encoding: 'utf8',
|
|
109
|
+
windowsHide: true,
|
|
110
|
+
timeout: check.timeoutSec * 1000,
|
|
111
|
+
env: { ...process.env, HARNESS_SKIP_PROJECT_CHECKS: '1' },
|
|
112
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
113
|
+
});
|
|
114
|
+
const durationMs = Date.now() - started;
|
|
115
|
+
const timedOut = !!(result.error && result.error.code === 'ETIMEDOUT');
|
|
116
|
+
const exitCode = typeof result.status === 'number' ? result.status : null;
|
|
117
|
+
return {
|
|
118
|
+
id: check.id,
|
|
119
|
+
run: check.run,
|
|
120
|
+
cwd: check.cwd,
|
|
121
|
+
source: check.source,
|
|
122
|
+
exitCode,
|
|
123
|
+
durationMs,
|
|
124
|
+
timedOut,
|
|
125
|
+
signal: result.signal || null,
|
|
126
|
+
stdout: result.stdout || '',
|
|
127
|
+
stderr: result.stderr || '',
|
|
128
|
+
passed: !timedOut && exitCode === 0,
|
|
129
|
+
};
|
|
53
130
|
}
|
|
54
131
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
132
|
+
function publicCheck(check) {
|
|
133
|
+
return {
|
|
134
|
+
id: check.id,
|
|
135
|
+
run: check.run,
|
|
136
|
+
cwd: check.cwd,
|
|
137
|
+
source: check.source,
|
|
138
|
+
exitCode: check.exitCode,
|
|
139
|
+
durationMs: check.durationMs,
|
|
140
|
+
...(check.timedOut ? { timedOut: true } : {}),
|
|
141
|
+
...(check.signal ? { signal: check.signal } : {}),
|
|
142
|
+
...(!jsonMode && check.stdout ? { stdout: check.stdout } : {}),
|
|
143
|
+
...(!jsonMode && check.stderr ? { stderr: check.stderr } : {}),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function candidateView(candidate) {
|
|
148
|
+
return {
|
|
149
|
+
source: candidate.source,
|
|
150
|
+
run: candidate.run,
|
|
151
|
+
cwd: candidate.cwd,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function rootReason(status, checks, candidates) {
|
|
156
|
+
if (status === 'FAILED') {
|
|
157
|
+
const failed = checks.find(check => !check.passed);
|
|
158
|
+
if (!failed) return 'verification failed';
|
|
159
|
+
return failed.timedOut
|
|
160
|
+
? `check ${failed.id} timed out after ${failed.durationMs}ms`
|
|
161
|
+
: `check ${failed.id} exited ${failed.exitCode}`;
|
|
63
162
|
}
|
|
163
|
+
if (status === 'UNCHECKED_DISCOVERED') return `${candidates.length} verification candidate(s) detected but not executed`;
|
|
164
|
+
if (status === 'UNCHECKED_NO_CHECKS') return 'no project verification checks were declared or detected';
|
|
165
|
+
return `${checks.length} check(s) passed`;
|
|
64
166
|
}
|
|
65
167
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
168
|
+
function contractPlan(contract) {
|
|
169
|
+
const rootResults = contract.roots.map(root => ({
|
|
170
|
+
path: root.path,
|
|
171
|
+
status: 'UNCHECKED_NO_CHECKS',
|
|
172
|
+
reason: '',
|
|
173
|
+
checks: [],
|
|
174
|
+
candidates: [],
|
|
175
|
+
}));
|
|
176
|
+
const byPath = new Map(rootResults.map(root => [path.resolve(root.path), root]));
|
|
177
|
+
|
|
178
|
+
for (const check of contract.checks) {
|
|
179
|
+
const executed = executeCheck(check);
|
|
180
|
+
const root = byPath.get(path.resolve(check.root));
|
|
181
|
+
root.checks.push(executed);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
for (const root of rootResults) {
|
|
185
|
+
root.status = root.checks.length === 0
|
|
186
|
+
? 'UNCHECKED_NO_CHECKS'
|
|
187
|
+
: root.checks.some(check => !check.passed) ? 'FAILED' : 'PASSED';
|
|
188
|
+
root.reason = rootReason(root.status, root.checks, root.candidates);
|
|
189
|
+
}
|
|
190
|
+
return rootResults;
|
|
79
191
|
}
|
|
80
192
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
193
|
+
function discoveredPlan() {
|
|
194
|
+
const roots = [];
|
|
195
|
+
for (const rootPath of enumerateSubmoduleRoots(repoRoot)) {
|
|
196
|
+
const detected = detectRoot(rootPath);
|
|
197
|
+
const executed = detected.checks.map(executeCheck);
|
|
198
|
+
const candidates = detected.candidates;
|
|
199
|
+
const status = executed.some(check => !check.passed)
|
|
200
|
+
? 'FAILED'
|
|
201
|
+
: candidates.length > 0 ? 'UNCHECKED_DISCOVERED'
|
|
202
|
+
: executed.length > 0 ? 'PASSED' : 'UNCHECKED_NO_CHECKS';
|
|
203
|
+
roots.push({
|
|
204
|
+
path: rootPath,
|
|
205
|
+
status,
|
|
206
|
+
reason: rootReason(status, executed, candidates),
|
|
207
|
+
checks: executed,
|
|
208
|
+
candidates,
|
|
90
209
|
});
|
|
91
|
-
console.log(`â
${name} passed.`);
|
|
92
|
-
} catch (err) {
|
|
93
|
-
console.error(`\nâ Verification FAILED: "${runner} run ${name}" exited non-zero${err.signal ? ` (signal ${err.signal})` : ''}.`);
|
|
94
|
-
console.error("Fix the failures above and run this gate again.");
|
|
95
|
-
process.exit(1);
|
|
96
210
|
}
|
|
211
|
+
return roots;
|
|
97
212
|
}
|
|
98
213
|
|
|
99
|
-
|
|
100
|
-
|
|
214
|
+
function finalize(roots) {
|
|
215
|
+
const status = aggregateStatus(roots);
|
|
216
|
+
const failingRoot = roots.find(root => root.status === status);
|
|
217
|
+
const report = {
|
|
218
|
+
status,
|
|
219
|
+
reason: failingRoot ? failingRoot.reason : '',
|
|
220
|
+
repoRoot,
|
|
221
|
+
roots: roots.map(root => ({
|
|
222
|
+
path: root.path,
|
|
223
|
+
status: root.status,
|
|
224
|
+
reason: root.reason,
|
|
225
|
+
checks: root.checks.map(publicCheck),
|
|
226
|
+
candidates: root.candidates.map(candidateView),
|
|
227
|
+
})),
|
|
228
|
+
checks: roots.flatMap(root => root.checks.map(publicCheck)),
|
|
229
|
+
candidates: roots.flatMap(root => root.candidates.map(candidateView)),
|
|
230
|
+
};
|
|
231
|
+
finish(report);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
// Injection remains first and cannot be bypassed by the recursion guard.
|
|
236
|
+
if (fs.existsSync(path.join(cwd, '.verify-fail.tmp'))) {
|
|
237
|
+
finish(emptyReport('FAILED', '.verify-fail.tmp present (injected failure)'));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (process.env.HARNESS_SKIP_PROJECT_CHECKS === '1') {
|
|
241
|
+
finish(emptyReport('SKIPPED', 'HARNESS_SKIP_PROJECT_CHECKS=1 recursion guard'));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const contract = readContract(repoRoot);
|
|
245
|
+
finalize(contract ? contractPlan(contract) : discoveredPlan());
|
|
246
|
+
} catch (error) {
|
|
247
|
+
finish(emptyReport('FAILED', error && error.message ? error.message : String(error)));
|
|
248
|
+
}
|
package/plugins/harness-everything/skills/using-git-worktrees/references/submodules-in-worktrees.md
CHANGED
|
@@ -111,9 +111,19 @@ git -C <primary> submodule update
|
|
|
111
111
|
|
|
112
112
|
The PowerShell form is identical except paths/refs should be quoted as shown above.
|
|
113
113
|
|
|
114
|
+
## Preferred automated check
|
|
115
|
+
|
|
116
|
+
From the superproject worktree, run the cross-platform helper before staging a gitlink and again before removing the worktree:
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
node <this-skill-dir>/scripts/submodule-reachability.js --json
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Exit 0 means every linked-worktree submodule commit was proven reachable outside the current worktree (or the superproject is not a linked worktree). An uninitialized gitlink is checked against the primary checkout and fails closed if that object is absent; initialize it to inspect remote refs, or fetch the commit into the primary checkout. Exit 1 means at least one commit is not proven externally reachable. Exit 2 is a usage/Git inspection error, including an unmerged submodule gitlink. The check MUST cover the current submodule HEAD and the gitlink SHAs in both the superproject index and HEAD. Checking out an older published submodule commit MUST NOT hide an unpublished staged or committed gitlink. The JSON report retains the working HEAD fields and adds `referencedCommits`, with each SHA, its `sources` (`index` and/or `HEAD`), and its external reachability evidence. Every referenced commit must pass before removal; a clean submodule status is valid input.
|
|
123
|
+
|
|
114
124
|
## Pre-removal check
|
|
115
125
|
|
|
116
|
-
|
|
126
|
+
Prefer the helper above. Raw fallback: for every changed gitlink SHA, prove at least one external home exists:
|
|
117
127
|
|
|
118
128
|
```bash
|
|
119
129
|
git -C <submodule> branch -r --contains <sha>
|
package/plugins/harness-everything/skills/using-git-worktrees/scripts/submodule-reachability.js
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { spawnSync } = require('child_process');
|
|
7
|
+
|
|
8
|
+
function runGit(args, cwd, { allowFailure = false } = {}) {
|
|
9
|
+
const result = spawnSync('git', args, { cwd, encoding: 'utf8' });
|
|
10
|
+
if (!allowFailure && result.status !== 0) {
|
|
11
|
+
const message = (result.stderr || result.stdout || '').trim();
|
|
12
|
+
throw new Error(`git ${args.join(' ')} failed${message ? `: ${message}` : ''}`);
|
|
13
|
+
}
|
|
14
|
+
return result;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function gitText(args, cwd, options) {
|
|
18
|
+
const result = runGit(args, cwd, options);
|
|
19
|
+
return result.status === 0 ? result.stdout.trim() : '';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function absoluteCommonDir(root) {
|
|
23
|
+
const absolute = runGit(['rev-parse', '--path-format=absolute', '--git-common-dir'], root, { allowFailure: true });
|
|
24
|
+
if (absolute.status === 0) return path.resolve(absolute.stdout.trim());
|
|
25
|
+
const raw = gitText(['rev-parse', '--git-common-dir'], root);
|
|
26
|
+
return path.resolve(root, raw);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isInside(child, parent) {
|
|
30
|
+
const rel = path.relative(path.resolve(parent), path.resolve(child));
|
|
31
|
+
return rel === '' || (!rel.startsWith('..' + path.sep) && rel !== '..' && !path.isAbsolute(rel));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function samePath(a, b) {
|
|
35
|
+
const normalize = value => {
|
|
36
|
+
let resolved = path.resolve(value);
|
|
37
|
+
try { resolved = fs.realpathSync.native(resolved); } catch { /* keep resolved */ }
|
|
38
|
+
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
39
|
+
};
|
|
40
|
+
return normalize(a) === normalize(b);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function parseSubmoduleStatus(text) {
|
|
44
|
+
if (!text.trim()) return [];
|
|
45
|
+
return text.split(/\r?\n/).filter(Boolean).map(line => {
|
|
46
|
+
// gitText() trims the whole command output, so the first clean-status
|
|
47
|
+
// marker (a leading space) may already be gone. Accept only the four Git
|
|
48
|
+
// status states here; an omitted marker is normalized back to clean.
|
|
49
|
+
const match = line.match(/^([ +U-]?)([0-9a-fA-F]{40,64})\s+(.+?)(?:\s+\(.+\))?$/);
|
|
50
|
+
if (!match) throw new Error(`cannot parse git submodule status line: ${line}`);
|
|
51
|
+
return {
|
|
52
|
+
prefix: match[1] || ' ',
|
|
53
|
+
recordedSha: match[2],
|
|
54
|
+
path: match[3],
|
|
55
|
+
};
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function primaryHasCommit(primaryRoot, commonDir, submodulePath, sha, moduleGitDir = null) {
|
|
60
|
+
if (!primaryRoot) return false;
|
|
61
|
+
const primarySub = path.join(primaryRoot, ...submodulePath.split('/'));
|
|
62
|
+
// A deinitialized submodule leaves an empty directory, and `git -C` then
|
|
63
|
+
// resolves to the enclosing repository. Only the exact submodule repo counts.
|
|
64
|
+
const toplevel = runGit(['-C', primarySub, 'rev-parse', '--show-toplevel'], primaryRoot, { allowFailure: true });
|
|
65
|
+
let result = null;
|
|
66
|
+
if (toplevel.status === 0 && samePath(toplevel.stdout.trim(), primarySub)) {
|
|
67
|
+
result = runGit(['-C', primarySub, 'cat-file', '-e', `${sha}^{commit}`], primaryRoot, { allowFailure: true });
|
|
68
|
+
if (result.status === 0) return true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// A nested submodule repository is stored below its parent's module repo,
|
|
72
|
+
// e.g. .git/modules/parent/modules/child. Callers pass that exact location
|
|
73
|
+
// when recursion is known; direct submodules retain the historical fallback.
|
|
74
|
+
const candidateGitDir = moduleGitDir || path.join(commonDir, 'modules', ...submodulePath.split('/'));
|
|
75
|
+
result = runGit(['--git-dir', candidateGitDir, 'cat-file', '-e', `${sha}^{commit}`], primaryRoot, { allowFailure: true });
|
|
76
|
+
return result.status === 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Read the recorded gitlinks from their owning repository, not from the
|
|
80
|
+
// submodule working HEAD (which may have been checked out to an older commit).
|
|
81
|
+
function recordedGitlinks(owner, relativePath) {
|
|
82
|
+
const commits = new Map();
|
|
83
|
+
function add(sha, source) {
|
|
84
|
+
if (!commits.has(sha)) commits.set(sha, { sha, sources: [] });
|
|
85
|
+
commits.get(sha).sources.push(source);
|
|
86
|
+
}
|
|
87
|
+
const index = runGit(['--literal-pathspecs', 'ls-files', '--stage', '-z', '--', relativePath], owner).stdout;
|
|
88
|
+
for (const record of index.split('\0').filter(Boolean)) {
|
|
89
|
+
const match = record.match(/^160000 ([0-9a-f]{40,64}) ([0-3])\t(.*)$/s);
|
|
90
|
+
if (!match || match[3] !== relativePath) continue;
|
|
91
|
+
if (match[2] !== '0') throw new Error(`unmerged submodule gitlink cannot be verified: ${relativePath}`);
|
|
92
|
+
add(match[1], 'index');
|
|
93
|
+
}
|
|
94
|
+
if (runGit(['rev-parse', '--verify', 'HEAD'], owner, { allowFailure: true }).status === 0) {
|
|
95
|
+
const tree = runGit(['--literal-pathspecs', 'ls-tree', '-z', 'HEAD', '--', relativePath], owner).stdout;
|
|
96
|
+
for (const record of tree.split('\0').filter(Boolean)) {
|
|
97
|
+
const match = record.match(/^160000 commit ([0-9a-f]{40,64})\t(.*)$/s);
|
|
98
|
+
if (match && match[2] === relativePath) add(match[1], 'HEAD');
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return [...commits.values()];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function inspect(root = process.cwd()) {
|
|
105
|
+
root = path.resolve(gitText(['rev-parse', '--show-toplevel'], path.resolve(root)));
|
|
106
|
+
const superGitDir = path.resolve(gitText(['rev-parse', '--absolute-git-dir'], root));
|
|
107
|
+
const commonDir = absoluteCommonDir(root);
|
|
108
|
+
const linkedWorktree = superGitDir !== commonDir;
|
|
109
|
+
const primaryRoot = linkedWorktree && path.basename(commonDir).toLowerCase() === '.git'
|
|
110
|
+
? path.dirname(commonDir)
|
|
111
|
+
: null;
|
|
112
|
+
const status = gitText(['submodule', 'status', '--recursive'], root);
|
|
113
|
+
const submodules = [];
|
|
114
|
+
const initializedPaths = [];
|
|
115
|
+
const primaryModuleDirs = new Map();
|
|
116
|
+
|
|
117
|
+
for (const entry of parseSubmoduleStatus(status)) {
|
|
118
|
+
if (entry.prefix === 'U') {
|
|
119
|
+
throw new Error(`unmerged submodule gitlink cannot be verified: ${entry.path}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const parentPath = initializedPaths.filter(parent => entry.path.startsWith(parent + '/'))
|
|
123
|
+
.sort((a, b) => b.length - a.length)[0];
|
|
124
|
+
const owner = parentPath ? path.join(root, ...parentPath.split('/')) : root;
|
|
125
|
+
const relativePath = parentPath ? entry.path.slice(parentPath.length + 1) : entry.path;
|
|
126
|
+
const subPath = path.join(root, ...entry.path.split('/'));
|
|
127
|
+
const parentModuleGitDir = parentPath ? primaryModuleDirs.get(parentPath) : null;
|
|
128
|
+
const primaryModuleGitDir = parentPath
|
|
129
|
+
? path.join(parentModuleGitDir, 'modules', ...relativePath.split('/'))
|
|
130
|
+
: path.join(commonDir, 'modules', ...entry.path.split('/'));
|
|
131
|
+
primaryModuleDirs.set(entry.path, primaryModuleGitDir);
|
|
132
|
+
const referencedCommits = recordedGitlinks(owner, relativePath).map(reference => {
|
|
133
|
+
// A recorded object may be absent locally but already recovered in primary.
|
|
134
|
+
const hasObject = entry.prefix !== '-' && runGit(
|
|
135
|
+
['cat-file', '-e', `${reference.sha}^{commit}`], subPath, { allowFailure: true }
|
|
136
|
+
).status === 0;
|
|
137
|
+
const remoteRefs = hasObject ? gitText(
|
|
138
|
+
['for-each-ref', '--contains', reference.sha, '--format=%(refname:short)', 'refs/remotes/'], subPath
|
|
139
|
+
).split(/\r?\n/).filter(Boolean) : [];
|
|
140
|
+
const presentInPrimary = linkedWorktree
|
|
141
|
+
? primaryHasCommit(primaryRoot, commonDir, entry.path, reference.sha, primaryModuleGitDir) : true;
|
|
142
|
+
return {
|
|
143
|
+
...reference,
|
|
144
|
+
remoteRefs,
|
|
145
|
+
reachableFromRemote: remoteRefs.length > 0,
|
|
146
|
+
presentInPrimary,
|
|
147
|
+
externallyReachable: !linkedWorktree || remoteRefs.length > 0 || presentInPrimary,
|
|
148
|
+
};
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
if (entry.prefix === '-') {
|
|
152
|
+
const presentInPrimary = linkedWorktree
|
|
153
|
+
? primaryHasCommit(primaryRoot, commonDir, entry.path, entry.recordedSha, primaryModuleGitDir)
|
|
154
|
+
: true;
|
|
155
|
+
const externallyReachable = !linkedWorktree || presentInPrimary;
|
|
156
|
+
submodules.push({
|
|
157
|
+
path: entry.path,
|
|
158
|
+
sha: entry.recordedSha,
|
|
159
|
+
initialized: false,
|
|
160
|
+
detached: null,
|
|
161
|
+
perWorktreeModuleDir: null,
|
|
162
|
+
reachableFromRemote: false,
|
|
163
|
+
remoteRefs: [],
|
|
164
|
+
presentInPrimary,
|
|
165
|
+
referencedCommits,
|
|
166
|
+
externallyReachable: externallyReachable && referencedCommits.every(reference => reference.externallyReachable),
|
|
167
|
+
});
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
initializedPaths.push(entry.path);
|
|
172
|
+
const head = runGit(['-C', subPath, 'rev-parse', 'HEAD'], root, { allowFailure: true });
|
|
173
|
+
if (head.status !== 0) {
|
|
174
|
+
throw new Error(`cannot inspect initialized submodule ${entry.path}: ${(head.stderr || head.stdout || '').trim()}`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const sha = head.stdout.trim();
|
|
178
|
+
const branch = gitText(['-C', subPath, 'branch', '--show-current'], root);
|
|
179
|
+
const subGitDir = path.resolve(gitText(['-C', subPath, 'rev-parse', '--absolute-git-dir'], root));
|
|
180
|
+
const remoteRefs = gitText(
|
|
181
|
+
['-C', subPath, 'for-each-ref', '--contains', sha, '--format=%(refname:short)', 'refs/remotes/'],
|
|
182
|
+
root
|
|
183
|
+
).split(/\r?\n/).filter(Boolean);
|
|
184
|
+
const reachableFromRemote = remoteRefs.length > 0;
|
|
185
|
+
const presentInPrimary = linkedWorktree
|
|
186
|
+
? primaryHasCommit(primaryRoot, commonDir, entry.path, sha, primaryModuleGitDir)
|
|
187
|
+
: true;
|
|
188
|
+
const externallyReachable = !linkedWorktree || reachableFromRemote || presentInPrimary;
|
|
189
|
+
|
|
190
|
+
submodules.push({
|
|
191
|
+
path: entry.path,
|
|
192
|
+
sha,
|
|
193
|
+
initialized: true,
|
|
194
|
+
detached: branch === '',
|
|
195
|
+
branch: branch || null,
|
|
196
|
+
gitDir: subGitDir,
|
|
197
|
+
perWorktreeModuleDir: linkedWorktree && isInside(subGitDir, path.join(commonDir, 'worktrees')),
|
|
198
|
+
reachableFromRemote,
|
|
199
|
+
remoteRefs,
|
|
200
|
+
presentInPrimary,
|
|
201
|
+
referencedCommits,
|
|
202
|
+
externallyReachable: externallyReachable && referencedCommits.every(reference => reference.externallyReachable),
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
root,
|
|
208
|
+
linkedWorktree,
|
|
209
|
+
gitDir: superGitDir,
|
|
210
|
+
commonDir,
|
|
211
|
+
primaryRoot,
|
|
212
|
+
ok: submodules.every(entry => entry.externallyReachable),
|
|
213
|
+
submodules,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function printHuman(report) {
|
|
218
|
+
console.log(`Superproject: ${report.root}`);
|
|
219
|
+
console.log(`Linked worktree: ${report.linkedWorktree ? 'yes' : 'no'}`);
|
|
220
|
+
if (!report.submodules.length) {
|
|
221
|
+
console.log('Submodules: none');
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
for (const entry of report.submodules) {
|
|
225
|
+
const state = entry.externallyReachable ? 'OK' : 'UNREACHABLE';
|
|
226
|
+
console.log(`[${state}] ${entry.path} @ ${entry.sha}`);
|
|
227
|
+
for (const reference of entry.referencedCommits) {
|
|
228
|
+
console.log(` ${reference.sources.join('/')} gitlink: ${reference.sha} (${reference.externallyReachable ? 'OK' : 'UNREACHABLE'})`);
|
|
229
|
+
}
|
|
230
|
+
if (!entry.initialized) {
|
|
231
|
+
console.log(' initialized: no');
|
|
232
|
+
console.log(` present in primary checkout: ${entry.presentInPrimary ? 'yes' : 'no'}`);
|
|
233
|
+
if (report.linkedWorktree && !entry.presentInPrimary) {
|
|
234
|
+
console.log(' remote reachability: unverified; initialize the submodule or fetch the commit into the primary checkout');
|
|
235
|
+
}
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
console.log(` branch: ${entry.branch || '(detached)'}`);
|
|
239
|
+
console.log(` per-worktree git dir: ${entry.perWorktreeModuleDir ? 'yes' : 'no'}`);
|
|
240
|
+
console.log(` remote reachability: ${entry.remoteRefs.length ? entry.remoteRefs.join(', ') : 'none'}`);
|
|
241
|
+
console.log(` present in primary checkout: ${entry.presentInPrimary ? 'yes' : 'no'}`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function main(argv = process.argv.slice(2)) {
|
|
246
|
+
const json = argv.includes('--json');
|
|
247
|
+
const rootIndex = argv.indexOf('--root');
|
|
248
|
+
const root = rootIndex >= 0 ? argv[rootIndex + 1] : process.cwd();
|
|
249
|
+
if (rootIndex >= 0 && !root) throw new Error('--root requires a path');
|
|
250
|
+
const report = inspect(root);
|
|
251
|
+
if (json) process.stdout.write(JSON.stringify(report, null, 2) + '\n');
|
|
252
|
+
else printHuman(report);
|
|
253
|
+
process.exitCode = report.ok ? 0 : 1;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (require.main === module) {
|
|
257
|
+
try {
|
|
258
|
+
main();
|
|
259
|
+
} catch (error) {
|
|
260
|
+
console.error(`submodule-reachability: ${error.message}`);
|
|
261
|
+
process.exit(2);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
module.exports = { inspect, parseSubmoduleStatus, primaryHasCommit };
|