harness-everything 0.26.0 → 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 +14 -0
- package/ci/mechanism-34-worktree-submodule-reachability.test.js +307 -0
- package/ci/mutation-check.js +2 -0
- package/ci/mutation-loader.js +4 -0
- package/git-commit/SKILL.md +2 -2
- package/git-commit/guides/SUBMODULES.md +54 -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/plugin.json +1 -1
- 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/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/sbom.cdx.json +7 -7
- 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
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"name": "harness-everything",
|
|
11
11
|
"source": "./",
|
|
12
12
|
"description": "Harness OS: an orchestrated agent operating system — tier routing, circuit breaker guardrails, verification gates, WAL session memory, and self-evolving workspace rules.",
|
|
13
|
-
"version": "0.
|
|
13
|
+
"version": "0.27.0",
|
|
14
14
|
"author": {
|
|
15
15
|
"name": "Miya Daniel"
|
|
16
16
|
},
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "harness-everything",
|
|
3
3
|
"description": "Harness OS: an orchestrated agent operating system — tier routing, circuit breaker guardrails, verification gates, WAL session memory, and self-evolving workspace rules.",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.27.0",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Miya Daniel"
|
|
7
7
|
},
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project are documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.27.0](https://github.com/dyphn1/Harness-everything/compare/v0.26.0...v0.27.0) (2026-09-27)
|
|
6
|
+
|
|
7
|
+
### Features
|
|
8
|
+
|
|
9
|
+
* **skills:** restore deterministic submodule reachability check ([0dd06e0](https://github.com/dyphn1/Harness-everything/commit/0dd06e0bd6aa38e04b3e8c11892d8d006c440821))
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **plugin:** sync nested submodule reachability ([94dbe4c](https://github.com/dyphn1/Harness-everything/commit/94dbe4c94ed2278fcb5cb6faf0f252ef5b282323))
|
|
14
|
+
* **skills:** land submodule reachability gate on main ([1b6de2b](https://github.com/dyphn1/Harness-everything/commit/1b6de2b5fd0cc6ae1b99530ef00c24f12efefb55))
|
|
15
|
+
* **skills:** require exact primary submodule repo for reachability ([c3f0f76](https://github.com/dyphn1/Harness-everything/commit/c3f0f76e1a127837b1faa2f5bebe06c01da1e8e4))
|
|
16
|
+
* **skills:** resolve nested submodule object stores ([aa6440b](https://github.com/dyphn1/Harness-everything/commit/aa6440b625b223948d6e69cbf9775fe45b861c50))
|
|
17
|
+
* **skills:** verify staged and committed submodule references ([6678496](https://github.com/dyphn1/Harness-everything/commit/6678496acadea25465ff3283b97bc31e4d17d455))
|
|
18
|
+
|
|
5
19
|
## [0.26.0](https://github.com/dyphn1/Harness-everything/compare/v0.25.1...v0.26.0) (2026-09-24)
|
|
6
20
|
|
|
7
21
|
### Features
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('assert');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { spawnSync } = require('child_process');
|
|
8
|
+
|
|
9
|
+
const ROOT = path.resolve(__dirname, '..');
|
|
10
|
+
const HELPER = path.join(ROOT, 'using-git-worktrees', 'scripts', 'submodule-reachability.js');
|
|
11
|
+
|
|
12
|
+
function run(command, args, cwd, { allowFailure = false, env = {}, input } = {}) {
|
|
13
|
+
const result = spawnSync(command, args, {
|
|
14
|
+
cwd,
|
|
15
|
+
encoding: 'utf8',
|
|
16
|
+
input,
|
|
17
|
+
env: { ...process.env, ...env },
|
|
18
|
+
});
|
|
19
|
+
if (!allowFailure && result.status !== 0) {
|
|
20
|
+
throw new Error([
|
|
21
|
+
`${command} ${args.join(' ')} failed with ${result.status}`,
|
|
22
|
+
result.stdout,
|
|
23
|
+
result.stderr,
|
|
24
|
+
].filter(Boolean).join('\n'));
|
|
25
|
+
}
|
|
26
|
+
return result;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function git(cwd, args, options) {
|
|
30
|
+
return run('git', args, cwd, options);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function initRepo(dir) {
|
|
34
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
35
|
+
git(dir, ['init', '-b', 'main']);
|
|
36
|
+
git(dir, ['config', 'user.name', 'Harness Test']);
|
|
37
|
+
git(dir, ['config', 'user.email', 'harness@example.invalid']);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function readJsonRun(cwd) {
|
|
41
|
+
const result = run(process.execPath, [HELPER, '--json'], cwd, { allowFailure: true });
|
|
42
|
+
let parsed;
|
|
43
|
+
try {
|
|
44
|
+
parsed = JSON.parse(result.stdout);
|
|
45
|
+
} catch (error) {
|
|
46
|
+
throw new Error(`helper did not emit JSON (status=${result.status}): ${result.stdout}\n${result.stderr}`);
|
|
47
|
+
}
|
|
48
|
+
return { ...result, parsed };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-submodule-reachability-'));
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const child = path.join(base, 'child');
|
|
55
|
+
const superRepo = path.join(base, 'super');
|
|
56
|
+
const worktree = path.join(base, 'super-wt');
|
|
57
|
+
|
|
58
|
+
initRepo(child);
|
|
59
|
+
fs.writeFileSync(path.join(child, 'seed.txt'), 'seed\n');
|
|
60
|
+
git(child, ['add', 'seed.txt']);
|
|
61
|
+
git(child, ['commit', '-m', 'seed']);
|
|
62
|
+
const seedSha = git(child, ['rev-parse', 'HEAD']).stdout.trim();
|
|
63
|
+
|
|
64
|
+
initRepo(superRepo);
|
|
65
|
+
fs.writeFileSync(path.join(superRepo, 'README.md'), 'super\n');
|
|
66
|
+
git(superRepo, ['add', 'README.md']);
|
|
67
|
+
git(superRepo, ['commit', '-m', 'super seed']);
|
|
68
|
+
git(superRepo, ['-c', 'protocol.file.allow=always', 'submodule', 'add', child, 'libs/sub']);
|
|
69
|
+
git(superRepo, ['commit', '-am', 'add submodule']);
|
|
70
|
+
|
|
71
|
+
git(superRepo, ['worktree', 'add', worktree, '-b', 'feat']);
|
|
72
|
+
git(worktree, ['-c', 'protocol.file.allow=always', 'submodule', 'update', '--init']);
|
|
73
|
+
const linkedSub = path.join(worktree, 'libs', 'sub');
|
|
74
|
+
git(linkedSub, ['config', 'user.name', 'Harness Test']);
|
|
75
|
+
git(linkedSub, ['config', 'user.email', 'harness@example.invalid']);
|
|
76
|
+
|
|
77
|
+
// Clean initialized submodules use a leading-space status marker. The helper
|
|
78
|
+
// must preserve/normalize that state instead of consuming the first SHA byte
|
|
79
|
+
// as a prefix when command output is trimmed.
|
|
80
|
+
const cleanRawStatus = git(worktree, ['submodule', 'status', '--recursive']).stdout;
|
|
81
|
+
assert.match(cleanRawStatus, /^ [0-9a-f]{40,64}\s+libs\/sub\b/, 'fixture must begin with a clean initialized submodule');
|
|
82
|
+
const clean = readJsonRun(worktree);
|
|
83
|
+
assert.strictEqual(clean.status, 0, 'a clean initialized linked-worktree submodule must be inspectable');
|
|
84
|
+
assert.strictEqual(clean.parsed.ok, true);
|
|
85
|
+
assert.strictEqual(clean.parsed.linkedWorktree, true);
|
|
86
|
+
const cleanSub = clean.parsed.submodules.find(entry => entry.path === 'libs/sub');
|
|
87
|
+
assert.ok(cleanSub, 'clean initialized submodule must be reported');
|
|
88
|
+
assert.strictEqual(cleanSub.sha, seedSha);
|
|
89
|
+
assert.strictEqual(cleanSub.initialized, true);
|
|
90
|
+
assert.strictEqual(cleanSub.detached, true);
|
|
91
|
+
assert.strictEqual(cleanSub.externallyReachable, true);
|
|
92
|
+
|
|
93
|
+
// RED case: a new commit exists only in the linked worktree's submodule git dir.
|
|
94
|
+
git(linkedSub, ['commit', '--allow-empty', '-m', 'isolated child commit']);
|
|
95
|
+
const isolatedSha = git(linkedSub, ['rev-parse', 'HEAD']).stdout.trim();
|
|
96
|
+
const isolated = readJsonRun(worktree);
|
|
97
|
+
assert.strictEqual(isolated.status, 1, 'an unpublished linked-worktree submodule commit must fail');
|
|
98
|
+
assert.strictEqual(isolated.parsed.ok, false);
|
|
99
|
+
assert.strictEqual(isolated.parsed.linkedWorktree, true);
|
|
100
|
+
const isolatedSub = isolated.parsed.submodules.find(entry => entry.path === 'libs/sub');
|
|
101
|
+
assert.ok(isolatedSub, 'submodule must be reported');
|
|
102
|
+
assert.strictEqual(isolatedSub.sha, isolatedSha);
|
|
103
|
+
assert.strictEqual(isolatedSub.detached, true, 'submodule update should leave HEAD detached');
|
|
104
|
+
assert.strictEqual(isolatedSub.perWorktreeModuleDir, true);
|
|
105
|
+
assert.strictEqual(isolatedSub.reachableFromRemote, false);
|
|
106
|
+
assert.strictEqual(isolatedSub.presentInPrimary, false);
|
|
107
|
+
assert.strictEqual(isolatedSub.externallyReachable, false);
|
|
108
|
+
|
|
109
|
+
// A local branch name alone does not make the commit externally reachable.
|
|
110
|
+
git(linkedSub, ['switch', '-c', 'task']);
|
|
111
|
+
const namedLocal = readJsonRun(worktree);
|
|
112
|
+
assert.strictEqual(namedLocal.status, 1, 'a named but unpublished branch must still fail');
|
|
113
|
+
const namedLocalSub = namedLocal.parsed.submodules.find(entry => entry.path === 'libs/sub');
|
|
114
|
+
assert.strictEqual(namedLocalSub.detached, false);
|
|
115
|
+
assert.strictEqual(namedLocalSub.reachableFromRemote, false);
|
|
116
|
+
assert.strictEqual(namedLocalSub.presentInPrimary, false);
|
|
117
|
+
assert.strictEqual(namedLocalSub.externallyReachable, false);
|
|
118
|
+
|
|
119
|
+
// Recovery path: name the commit, fetch it into the primary checkout's submodule object store.
|
|
120
|
+
const primarySub = path.join(superRepo, 'libs', 'sub');
|
|
121
|
+
git(primarySub, ['fetch', linkedSub, 'task:task']);
|
|
122
|
+
const recovered = readJsonRun(worktree);
|
|
123
|
+
assert.strictEqual(recovered.status, 0, 'a commit present in the primary checkout must pass');
|
|
124
|
+
const recoveredSub = recovered.parsed.submodules.find(entry => entry.path === 'libs/sub');
|
|
125
|
+
assert.strictEqual(recoveredSub.presentInPrimary, true);
|
|
126
|
+
assert.strictEqual(recoveredSub.externallyReachable, true);
|
|
127
|
+
|
|
128
|
+
// Remote path: make a new commit that is absent from primary, then publish it.
|
|
129
|
+
git(linkedSub, ['switch', 'task']);
|
|
130
|
+
git(linkedSub, ['commit', '--allow-empty', '-m', 'remote child commit']);
|
|
131
|
+
const remoteSha = git(linkedSub, ['rev-parse', 'HEAD']).stdout.trim();
|
|
132
|
+
git(linkedSub, ['push', '-u', 'origin', 'task']);
|
|
133
|
+
const remote = readJsonRun(worktree);
|
|
134
|
+
assert.strictEqual(remote.status, 0, 'a commit reachable from a remote-tracking ref must pass');
|
|
135
|
+
const remoteSub = remote.parsed.submodules.find(entry => entry.path === 'libs/sub');
|
|
136
|
+
assert.strictEqual(remoteSub.sha, remoteSha);
|
|
137
|
+
assert.strictEqual(remoteSub.reachableFromRemote, true);
|
|
138
|
+
assert.strictEqual(remoteSub.externallyReachable, true);
|
|
139
|
+
|
|
140
|
+
// A published working HEAD must not hide an unpublished staged/committed gitlink.
|
|
141
|
+
git(linkedSub, ['commit', '--allow-empty', '-m', 'recorded but unpublished']);
|
|
142
|
+
const recordedSha = git(linkedSub, ['rev-parse', 'HEAD']).stdout.trim();
|
|
143
|
+
git(worktree, ['add', 'libs/sub']);
|
|
144
|
+
git(linkedSub, ['checkout', '--detach', seedSha]);
|
|
145
|
+
const stagedOnly = readJsonRun(worktree);
|
|
146
|
+
assert.deepStrictEqual(readJsonRun(worktree).parsed, stagedOnly.parsed, 'identical topology must produce identical evidence');
|
|
147
|
+
assert.strictEqual(stagedOnly.status, 1, 'the unpublished index SHA must fail even with a published working HEAD');
|
|
148
|
+
let reference = stagedOnly.parsed.submodules[0].referencedCommits.find(item => item.sha === recordedSha);
|
|
149
|
+
assert.deepStrictEqual(reference.sources, ['index']);
|
|
150
|
+
assert.strictEqual(reference.externallyReachable, false);
|
|
151
|
+
|
|
152
|
+
git(worktree, ['commit', '-m', 'record unpublished gitlink']);
|
|
153
|
+
// Stage the safe SHA over it: the unsafe committed SHA must still be checked.
|
|
154
|
+
git(worktree, ['add', 'libs/sub']);
|
|
155
|
+
const committedOnly = readJsonRun(worktree);
|
|
156
|
+
assert.strictEqual(committedOnly.status, 1, 'the unpublished superproject HEAD SHA must fail independently of index and working HEAD');
|
|
157
|
+
reference = committedOnly.parsed.submodules[0].referencedCommits.find(item => item.sha === recordedSha);
|
|
158
|
+
assert.deepStrictEqual(reference.sources, ['HEAD']);
|
|
159
|
+
assert.strictEqual(reference.externallyReachable, false);
|
|
160
|
+
|
|
161
|
+
git(primarySub, ['fetch', linkedSub, 'task:recorded-recovery']);
|
|
162
|
+
const recordedRecovered = readJsonRun(worktree);
|
|
163
|
+
assert.deepStrictEqual(readJsonRun(worktree).parsed, recordedRecovered.parsed, 'recovered evidence must be deterministic');
|
|
164
|
+
assert.strictEqual(recordedRecovered.status, 0, 'recovering the recorded SHA in primary must allow the unchanged topology');
|
|
165
|
+
reference = recordedRecovered.parsed.submodules[0].referencedCommits.find(item => item.sha === recordedSha);
|
|
166
|
+
assert.strictEqual(reference.presentInPrimary, true);
|
|
167
|
+
assert.strictEqual(reference.externallyReachable, true);
|
|
168
|
+
|
|
169
|
+
// Nested recovery: Git stores a nested submodule under the parent's module
|
|
170
|
+
// repository (.../modules/<parent>/modules/<child>), not by flattening the
|
|
171
|
+
// full worktree path below the top-level common dir.
|
|
172
|
+
const nestedLeafOrigin = path.join(base, 'nested-leaf-origin');
|
|
173
|
+
const nestedParentOrigin = path.join(base, 'nested-parent-origin');
|
|
174
|
+
const nestedSuper = path.join(base, 'nested-super');
|
|
175
|
+
const nestedWorktree = path.join(base, 'nested-super-wt');
|
|
176
|
+
|
|
177
|
+
initRepo(nestedLeafOrigin);
|
|
178
|
+
fs.writeFileSync(path.join(nestedLeafOrigin, 'leaf.txt'), 'leaf\n');
|
|
179
|
+
git(nestedLeafOrigin, ['add', 'leaf.txt']);
|
|
180
|
+
git(nestedLeafOrigin, ['commit', '-m', 'leaf seed']);
|
|
181
|
+
|
|
182
|
+
initRepo(nestedParentOrigin);
|
|
183
|
+
fs.writeFileSync(path.join(nestedParentOrigin, 'parent.txt'), 'parent\n');
|
|
184
|
+
git(nestedParentOrigin, ['add', 'parent.txt']);
|
|
185
|
+
git(nestedParentOrigin, ['commit', '-m', 'parent seed']);
|
|
186
|
+
git(nestedParentOrigin, ['-c', 'protocol.file.allow=always', 'submodule', 'add', nestedLeafOrigin, 'nested/leaf']);
|
|
187
|
+
git(nestedParentOrigin, ['commit', '-am', 'add nested leaf']);
|
|
188
|
+
|
|
189
|
+
initRepo(nestedSuper);
|
|
190
|
+
fs.writeFileSync(path.join(nestedSuper, 'README.md'), 'nested super\n');
|
|
191
|
+
git(nestedSuper, ['add', 'README.md']);
|
|
192
|
+
git(nestedSuper, ['commit', '-m', 'nested super seed']);
|
|
193
|
+
git(nestedSuper, ['-c', 'protocol.file.allow=always', 'submodule', 'add', nestedParentOrigin, 'libs/sub']);
|
|
194
|
+
git(nestedSuper, ['commit', '-am', 'add nested parent']);
|
|
195
|
+
git(nestedSuper, ['worktree', 'add', nestedWorktree, '-b', 'nested-feat']);
|
|
196
|
+
git(nestedWorktree, ['-c', 'protocol.file.allow=always', 'submodule', 'update', '--init', '--recursive']);
|
|
197
|
+
|
|
198
|
+
const linkedNestedLeaf = path.join(nestedWorktree, 'libs', 'sub', 'nested', 'leaf');
|
|
199
|
+
git(linkedNestedLeaf, ['config', 'user.name', 'Harness Test']);
|
|
200
|
+
git(linkedNestedLeaf, ['config', 'user.email', 'harness@example.invalid']);
|
|
201
|
+
git(linkedNestedLeaf, ['switch', '-c', 'nested-task']);
|
|
202
|
+
git(linkedNestedLeaf, ['commit', '--allow-empty', '-m', 'nested worktree-only commit']);
|
|
203
|
+
const nestedSha = git(linkedNestedLeaf, ['rev-parse', 'HEAD']).stdout.trim();
|
|
204
|
+
|
|
205
|
+
const primaryNestedParent = path.join(nestedSuper, 'libs', 'sub');
|
|
206
|
+
git(primaryNestedParent, ['-c', 'protocol.file.allow=always', 'submodule', 'update', '--init', '--recursive']);
|
|
207
|
+
const primaryNestedLeaf = path.join(primaryNestedParent, 'nested', 'leaf');
|
|
208
|
+
git(primaryNestedLeaf, ['fetch', linkedNestedLeaf, 'nested-task:nested-recovery']);
|
|
209
|
+
git(primaryNestedParent, ['submodule', 'deinit', '--force', '--', 'nested/leaf']);
|
|
210
|
+
|
|
211
|
+
const nestedRecovered = readJsonRun(nestedWorktree);
|
|
212
|
+
assert.strictEqual(nestedRecovered.status, 0, 'a nested commit recovered only in the primary module object store must pass');
|
|
213
|
+
const nestedEntry = nestedRecovered.parsed.submodules.find(entry => entry.path === 'libs/sub/nested/leaf');
|
|
214
|
+
assert.ok(nestedEntry, 'nested submodule must be reported');
|
|
215
|
+
assert.strictEqual(nestedEntry.sha, nestedSha);
|
|
216
|
+
assert.strictEqual(nestedEntry.reachableFromRemote, false);
|
|
217
|
+
assert.strictEqual(nestedEntry.presentInPrimary, true);
|
|
218
|
+
assert.strictEqual(nestedEntry.externallyReachable, true);
|
|
219
|
+
|
|
220
|
+
// No-submodule control.
|
|
221
|
+
const plain = path.join(base, 'plain');
|
|
222
|
+
initRepo(plain);
|
|
223
|
+
git(plain, ['commit', '--allow-empty', '-m', 'plain']);
|
|
224
|
+
const noSubmodule = readJsonRun(plain);
|
|
225
|
+
assert.strictEqual(noSubmodule.status, 0);
|
|
226
|
+
assert.strictEqual(noSubmodule.parsed.ok, true);
|
|
227
|
+
assert.deepStrictEqual(noSubmodule.parsed.submodules, []);
|
|
228
|
+
|
|
229
|
+
// A deinitialized submodule can make `git -C <submodule>` resolve to the
|
|
230
|
+
// superproject. Never use that unrelated HEAD or its remote refs as proof
|
|
231
|
+
// that the staged gitlink is reachable.
|
|
232
|
+
const uninitWorktree = path.join(base, 'super-wt-uninitialized');
|
|
233
|
+
git(superRepo, ['worktree', 'add', uninitWorktree, '-b', 'feat-uninitialized']);
|
|
234
|
+
git(uninitWorktree, ['-c', 'protocol.file.allow=always', 'submodule', 'update', '--init']);
|
|
235
|
+
const uninitializedSub = path.join(uninitWorktree, 'libs', 'sub');
|
|
236
|
+
git(uninitializedSub, ['config', 'user.name', 'Harness Test']);
|
|
237
|
+
git(uninitializedSub, ['config', 'user.email', 'harness@example.invalid']);
|
|
238
|
+
git(uninitializedSub, ['commit', '--allow-empty', '-m', 'uninitialized submodule commit']);
|
|
239
|
+
const uninitializedSha = git(uninitializedSub, ['rev-parse', 'HEAD']).stdout.trim();
|
|
240
|
+
git(uninitWorktree, ['add', 'libs/sub']);
|
|
241
|
+
const superSha = git(uninitWorktree, ['rev-parse', 'HEAD']).stdout.trim();
|
|
242
|
+
git(uninitWorktree, ['update-ref', 'refs/remotes/origin/main', superSha]);
|
|
243
|
+
git(uninitWorktree, ['submodule', 'deinit', '--force', '--', 'libs/sub']);
|
|
244
|
+
|
|
245
|
+
const uninitialized = readJsonRun(uninitWorktree);
|
|
246
|
+
assert.strictEqual(uninitialized.status, 1, 'an uninitialized staged gitlink without external reachability must fail');
|
|
247
|
+
const uninitializedEntry = uninitialized.parsed.submodules.find(entry => entry.path === 'libs/sub');
|
|
248
|
+
assert.ok(uninitializedEntry, 'uninitialized submodule must be reported');
|
|
249
|
+
assert.strictEqual(uninitializedEntry.initialized, false);
|
|
250
|
+
assert.strictEqual(uninitializedEntry.sha, uninitializedSha);
|
|
251
|
+
assert.strictEqual(uninitializedEntry.reachableFromRemote, false);
|
|
252
|
+
assert.strictEqual(uninitializedEntry.presentInPrimary, false);
|
|
253
|
+
assert.strictEqual(uninitializedEntry.externallyReachable, false);
|
|
254
|
+
|
|
255
|
+
// An unmerged gitlink has no single target SHA and must fail as an
|
|
256
|
+
// inspection error instead of checking the submodule's current HEAD.
|
|
257
|
+
const conflictWorktree = path.join(base, 'super-wt-conflict');
|
|
258
|
+
git(superRepo, ['worktree', 'add', conflictWorktree, '-b', 'feat-conflict']);
|
|
259
|
+
git(conflictWorktree, ['-c', 'protocol.file.allow=always', 'submodule', 'update', '--init']);
|
|
260
|
+
const zeroSha = '0'.repeat(seedSha.length);
|
|
261
|
+
const conflictIndex = [
|
|
262
|
+
`0 ${zeroSha} 0\tlibs/sub`,
|
|
263
|
+
`160000 ${seedSha} 1\tlibs/sub`,
|
|
264
|
+
`160000 ${isolatedSha} 2\tlibs/sub`,
|
|
265
|
+
`160000 ${remoteSha} 3\tlibs/sub`,
|
|
266
|
+
'',
|
|
267
|
+
].join('\n');
|
|
268
|
+
git(conflictWorktree, ['update-index', '--index-info'], { input: conflictIndex });
|
|
269
|
+
const conflictStatus = git(conflictWorktree, ['submodule', 'status', '--recursive']).stdout.trim();
|
|
270
|
+
assert.match(conflictStatus, /^U/, `fixture must produce an unmerged gitlink: ${conflictStatus}`);
|
|
271
|
+
const conflict = run(process.execPath, [HELPER, '--json'], conflictWorktree, { allowFailure: true });
|
|
272
|
+
assert.strictEqual(conflict.status, 2, `unmerged gitlink must fail inspection: ${conflict.stdout}\n${conflict.stderr}`);
|
|
273
|
+
assert.match(conflict.stderr, /unmerged submodule gitlink/i);
|
|
274
|
+
|
|
275
|
+
// A deinitialized primary submodule leaves an empty directory, so
|
|
276
|
+
// `git -C <primary>/<submodule>` resolves to the primary superproject. An
|
|
277
|
+
// object in that unrelated store is not proof of submodule reachability.
|
|
278
|
+
const fallbackWorktree = path.join(base, 'super-wt-primary-fallback');
|
|
279
|
+
git(superRepo, ['worktree', 'add', fallbackWorktree, '-b', 'feat-primary-fallback']);
|
|
280
|
+
git(fallbackWorktree, ['-c', 'protocol.file.allow=always', 'submodule', 'update', '--init']);
|
|
281
|
+
const fallbackSub = path.join(fallbackWorktree, 'libs', 'sub');
|
|
282
|
+
git(fallbackSub, ['config', 'user.name', 'Harness Test']);
|
|
283
|
+
git(fallbackSub, ['config', 'user.email', 'harness@example.invalid']);
|
|
284
|
+
git(fallbackSub, ['switch', '-c', 'fallback-task']);
|
|
285
|
+
git(fallbackSub, ['commit', '--allow-empty', '-m', 'superproject-store only commit']);
|
|
286
|
+
const fallbackSha = git(fallbackSub, ['rev-parse', 'HEAD']).stdout.trim();
|
|
287
|
+
git(fallbackWorktree, ['add', 'libs/sub']);
|
|
288
|
+
// Empty the primary checkout directory without touching shared submodule
|
|
289
|
+
// config, which would also uninitialize the linked worktree.
|
|
290
|
+
fs.rmSync(primarySub, { recursive: true, force: true });
|
|
291
|
+
fs.mkdirSync(primarySub);
|
|
292
|
+
fs.rmSync(path.join(superRepo, '.git', 'modules', 'libs', 'sub'), { recursive: true, force: true });
|
|
293
|
+
git(superRepo, ['fetch', fallbackSub, 'fallback-task:refs/foreign/fallback-task']);
|
|
294
|
+
assert.strictEqual(git(superRepo, ['cat-file', '-e', `${fallbackSha}^{commit}`], { allowFailure: true }).status, 0,
|
|
295
|
+
'fixture must place the commit only in the primary superproject object store');
|
|
296
|
+
const fallback = readJsonRun(fallbackWorktree);
|
|
297
|
+
assert.strictEqual(fallback.status, 1, 'a commit found only through git -C fallback to the superproject must fail');
|
|
298
|
+
const fallbackEntry = fallback.parsed.submodules.find(entry => entry.path === 'libs/sub');
|
|
299
|
+
assert.strictEqual(fallbackEntry.initialized, true);
|
|
300
|
+
assert.strictEqual(fallbackEntry.sha, fallbackSha);
|
|
301
|
+
assert.strictEqual(fallbackEntry.presentInPrimary, false);
|
|
302
|
+
assert.strictEqual(fallbackEntry.externallyReachable, false);
|
|
303
|
+
|
|
304
|
+
console.log('Worktree submodule reachability: detached, named-unpublished, uninitialized, and unmerged gitlinks fail safely; primary/remote reachability and no-submodule controls pass.');
|
|
305
|
+
} finally {
|
|
306
|
+
fs.rmSync(base, { recursive: true, force: true });
|
|
307
|
+
}
|
package/ci/mutation-check.js
CHANGED
|
@@ -15,6 +15,7 @@ const checks = [
|
|
|
15
15
|
['transcript command qualifiers', 'behavioral-evals/transcript-parser.js', 'ci/mechanism-2t-transcript-parser.test.js'],
|
|
16
16
|
['fixture path boundaries', 'behavioral-evals/run.js', 'ci/mechanism-2j-behavioral-runner.test.js'],
|
|
17
17
|
['unbound workspace identity', 'scripts/lib/workspace.js', 'ci/mechanism-2u-issue-42-regressions.test.js'],
|
|
18
|
+
['worktree submodule reachability', 'using-git-worktrees/scripts/submodule-reachability.js', 'ci/mechanism-34-worktree-submodule-reachability.test.js'],
|
|
18
19
|
['verification contract precedence', 'harness-everything/scripts/verify-gate.js', 'ci/mechanism-35-verification-contract.test.js'],
|
|
19
20
|
];
|
|
20
21
|
|
|
@@ -33,6 +34,7 @@ for (const [label, target, testFile] of checks) {
|
|
|
33
34
|
'behavioral-evals/transcript-parser.js': 'transcript-command',
|
|
34
35
|
'behavioral-evals/run.js': 'runner-fixture',
|
|
35
36
|
'scripts/lib/workspace.js': 'workspace-identity',
|
|
37
|
+
'using-git-worktrees/scripts/submodule-reachability.js': 'submodule-reachability',
|
|
36
38
|
'harness-everything/scripts/verify-gate.js': 'verify-contract-precedence',
|
|
37
39
|
}[target],
|
|
38
40
|
HARNESS_MUTATION_TARGET: path.join(ROOT, target),
|
package/ci/mutation-loader.js
CHANGED
|
@@ -40,6 +40,10 @@ const mutations = {
|
|
|
40
40
|
' if (!workspaceRoot) return UNBOUND_WORKSPACE_KEY;',
|
|
41
41
|
' if (!workspaceRoot) return path.basename(process.cwd()); // mutation: derive unbound identity from cwd'
|
|
42
42
|
),
|
|
43
|
+
'submodule-reachability': (source) => source.replace(
|
|
44
|
+
' const externallyReachable = !linkedWorktree || reachableFromRemote || presentInPrimary;',
|
|
45
|
+
' const externallyReachable = true; // mutation: accept isolated worktree-only commits'
|
|
46
|
+
),
|
|
43
47
|
'verify-contract-precedence': (source) => source.replace(
|
|
44
48
|
' finalize(contract ? contractPlan(contract) : discoveredPlan());',
|
|
45
49
|
' finalize(discoveredPlan()); // mutation: ignore the authoritative project contract'
|
package/git-commit/SKILL.md
CHANGED
|
@@ -4,7 +4,7 @@ description: "Generate Angular-style commit messages after verifying the environ
|
|
|
4
4
|
license: Apache-2.0
|
|
5
5
|
metadata:
|
|
6
6
|
author: Miya Daniel
|
|
7
|
-
version: 0.
|
|
7
|
+
version: 0.27.0
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# Git Commit (Angular Style)
|
|
@@ -20,7 +20,7 @@ metadata:
|
|
|
20
20
|
|
|
21
21
|
## Quick Workflow
|
|
22
22
|
|
|
23
|
-
1. **MUST** run `git status`; no repo → offer `git init` or skip; submodules changed → commit first per `<this-skill-dir>/guides/SUBMODULES.md
|
|
23
|
+
1. **MUST** run `git status`; no repo → offer `git init` or skip; submodules changed → commit first per `<this-skill-dir>/guides/SUBMODULES.md`, including its worktree reachability gate.
|
|
24
24
|
2. **MUST** inspect `git diff --cached` before committing. Nothing staged → prompt user / stage only user-authorized targeted files. Unrelated concerns **SHOULD** be split unless coupling or explicit user intent justifies one commit.
|
|
25
25
|
3. Format `<type>(<scope>): <subject>` per `<this-skill-dir>/guides/ANGULAR_STYLE.md`. Multiline/Windows → `.git-commit-msg.txt` + `git commit -F`, clean up.
|
|
26
26
|
4. `git commit -m "..."`, then **MUST** verify the result with `git log -1`.
|
|
@@ -14,6 +14,59 @@
|
|
|
14
14
|
3. Generation: For Sub Repos WITH staged changes, MUST execute `COMMIT_GENERATION.md`.
|
|
15
15
|
4. Validation: MUST verify generation success. IF ANY Sub Repo has remaining staged changes, MUST diagnose failure and halt.
|
|
16
16
|
|
|
17
|
+
## [Reachability Phase]
|
|
18
|
+
Before staging any changed submodule gitlink in the Main Repo, MUST run the preferred deterministic check:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
node <skills-repo-root>/using-git-worktrees/scripts/submodule-reachability.js --json
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
- Exit 0: continue to Indexing.
|
|
25
|
+
- Exit 1: MUST stop before staging and inspect the reported unreachable submodule(s); use the options below.
|
|
26
|
+
- Exit 2: MUST diagnose the inspection failure before staging.
|
|
27
|
+
|
|
28
|
+
If the helper is unavailable, use this raw fallback. First determine whether the Main Repo is a linked worktree:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
git rev-parse --git-dir
|
|
32
|
+
git rev-parse --git-common-dir
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
If the git dir and common dir differ, then for EACH changed Sub Repo:
|
|
36
|
+
|
|
37
|
+
1. Identify the repository that **owns** the gitlink and the submodule path relative to that owner. For a direct submodule the owner is the Main Repo; for a nested submodule it is the immediate parent submodule. If this cannot be identified reliably, MUST stop and use the helper rather than guess.
|
|
38
|
+
2. Collect every commit that can affect the recorded gitlink, not only the current working HEAD:
|
|
39
|
+
```bash
|
|
40
|
+
git -C <submodule> rev-parse HEAD
|
|
41
|
+
git -C <owner> ls-files --stage -- <relative-submodule-path>
|
|
42
|
+
git -C <owner> ls-tree HEAD -- <relative-submodule-path>
|
|
43
|
+
```
|
|
44
|
+
From the last two commands, only mode `160000` stage `0` entries are valid gitlinks. Any stage `1`–`3` entry is unmerged and MUST stop. Check every unique SHA from working HEAD, index, and owner HEAD.
|
|
45
|
+
3. Check whether the Sub Repo is detached:
|
|
46
|
+
```bash
|
|
47
|
+
git -C <submodule> branch --show-current
|
|
48
|
+
```
|
|
49
|
+
4. For EACH collected SHA, first check remote-tracking reachability:
|
|
50
|
+
```bash
|
|
51
|
+
git -C <submodule> branch -r --contains <sha>
|
|
52
|
+
```
|
|
53
|
+
If no remote-tracking ref contains it, prove that the **exact** primary-checkout submodule repository owns the object:
|
|
54
|
+
```bash
|
|
55
|
+
git -C <primary>/<submodule> rev-parse --show-toplevel
|
|
56
|
+
git -C <primary>/<submodule> cat-file -e <sha>^{commit}
|
|
57
|
+
```
|
|
58
|
+
The reported top-level path MUST equal the expected primary submodule path. A missing/deinitialized nested path can make `git -C` fall back to a parent repository; that is not proof. If the exact primary submodule cannot be proven, initialize/fetch it or use the helper, otherwise MUST fail closed.
|
|
59
|
+
|
|
60
|
+
A branch name alone does not publish a commit. If ANY working/index/HEAD gitlink SHA has neither a containing remote-tracking ref nor a proven exact primary-submodule object, it is an **unpublished gitlink risk**, whether the submodule is detached or on a named branch. MUST stop before `git add <submodule>` and present:
|
|
61
|
+
|
|
62
|
+
- **[1] Create/publish a branch** — hand off to `<skills-repo-root>/using-git-worktrees/references/submodules-in-worktrees.md`.
|
|
63
|
+
- **[2] Stage anyway** — only on explicit user choice; MUST record the unpublished gitlink risk in the handoff.
|
|
64
|
+
- **[3] Abort** — do not stage the gitlink.
|
|
65
|
+
|
|
66
|
+
`git-commit` MUST NOT create branches or push as part of this gate. It only detects the risk and hands off to the worktree reference when publication is needed.
|
|
67
|
+
|
|
68
|
+
If the Main Repo is not a linked worktree, or the commit is already externally reachable, continue normally.
|
|
69
|
+
|
|
17
70
|
## [Indexing Phase]
|
|
18
71
|
5. Preference Check: MUST read the resolved Harness memory file `git-commit-prefs.md` for the `auto_stage_submodules` setting. Prefer an existing `MEMORY.md`/`RULES.md`; otherwise use `.github/harness-everything/memories/git-commit-prefs.md`.
|
|
19
72
|
6. Execution:
|
|
@@ -24,4 +77,4 @@
|
|
|
24
77
|
- IF user selects [2]: MUST NOT run `git add`.
|
|
25
78
|
|
|
26
79
|
## [State Handoff]
|
|
27
|
-
7. Handoff: MUST summarize completed Sub Repo updates (including paths and logs) and explicitly carry them over when returning control to `SKILL.md`.
|
|
80
|
+
7. Handoff: MUST summarize completed Sub Repo updates (including paths, commit SHA, publication/reachability evidence, and logs) and explicitly carry them over when returning control to `SKILL.md`.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
3
3
|
"name": "harness-everything",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.27.0",
|
|
5
5
|
"description": "Invariant-first engineering harness for routing, verification, and failure recovery while preserving agent autonomy.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Miya Daniel",
|
|
@@ -4,7 +4,7 @@ description: "Generate Angular-style commit messages after verifying the environ
|
|
|
4
4
|
license: Apache-2.0
|
|
5
5
|
metadata:
|
|
6
6
|
author: Miya Daniel
|
|
7
|
-
version: 0.
|
|
7
|
+
version: 0.27.0
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# Git Commit (Angular Style)
|
|
@@ -20,7 +20,7 @@ metadata:
|
|
|
20
20
|
|
|
21
21
|
## Quick Workflow
|
|
22
22
|
|
|
23
|
-
1. **MUST** run `git status`; no repo → offer `git init` or skip; submodules changed → commit first per `<this-skill-dir>/guides/SUBMODULES.md
|
|
23
|
+
1. **MUST** run `git status`; no repo → offer `git init` or skip; submodules changed → commit first per `<this-skill-dir>/guides/SUBMODULES.md`, including its worktree reachability gate.
|
|
24
24
|
2. **MUST** inspect `git diff --cached` before committing. Nothing staged → prompt user / stage only user-authorized targeted files. Unrelated concerns **SHOULD** be split unless coupling or explicit user intent justifies one commit.
|
|
25
25
|
3. Format `<type>(<scope>): <subject>` per `<this-skill-dir>/guides/ANGULAR_STYLE.md`. Multiline/Windows → `.git-commit-msg.txt` + `git commit -F`, clean up.
|
|
26
26
|
4. `git commit -m "..."`, then **MUST** verify the result with `git log -1`.
|
|
@@ -14,6 +14,59 @@
|
|
|
14
14
|
3. Generation: For Sub Repos WITH staged changes, MUST execute `COMMIT_GENERATION.md`.
|
|
15
15
|
4. Validation: MUST verify generation success. IF ANY Sub Repo has remaining staged changes, MUST diagnose failure and halt.
|
|
16
16
|
|
|
17
|
+
## [Reachability Phase]
|
|
18
|
+
Before staging any changed submodule gitlink in the Main Repo, MUST run the preferred deterministic check:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
node <skills-repo-root>/using-git-worktrees/scripts/submodule-reachability.js --json
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
- Exit 0: continue to Indexing.
|
|
25
|
+
- Exit 1: MUST stop before staging and inspect the reported unreachable submodule(s); use the options below.
|
|
26
|
+
- Exit 2: MUST diagnose the inspection failure before staging.
|
|
27
|
+
|
|
28
|
+
If the helper is unavailable, use this raw fallback. First determine whether the Main Repo is a linked worktree:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
git rev-parse --git-dir
|
|
32
|
+
git rev-parse --git-common-dir
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
If the git dir and common dir differ, then for EACH changed Sub Repo:
|
|
36
|
+
|
|
37
|
+
1. Identify the repository that **owns** the gitlink and the submodule path relative to that owner. For a direct submodule the owner is the Main Repo; for a nested submodule it is the immediate parent submodule. If this cannot be identified reliably, MUST stop and use the helper rather than guess.
|
|
38
|
+
2. Collect every commit that can affect the recorded gitlink, not only the current working HEAD:
|
|
39
|
+
```bash
|
|
40
|
+
git -C <submodule> rev-parse HEAD
|
|
41
|
+
git -C <owner> ls-files --stage -- <relative-submodule-path>
|
|
42
|
+
git -C <owner> ls-tree HEAD -- <relative-submodule-path>
|
|
43
|
+
```
|
|
44
|
+
From the last two commands, only mode `160000` stage `0` entries are valid gitlinks. Any stage `1`–`3` entry is unmerged and MUST stop. Check every unique SHA from working HEAD, index, and owner HEAD.
|
|
45
|
+
3. Check whether the Sub Repo is detached:
|
|
46
|
+
```bash
|
|
47
|
+
git -C <submodule> branch --show-current
|
|
48
|
+
```
|
|
49
|
+
4. For EACH collected SHA, first check remote-tracking reachability:
|
|
50
|
+
```bash
|
|
51
|
+
git -C <submodule> branch -r --contains <sha>
|
|
52
|
+
```
|
|
53
|
+
If no remote-tracking ref contains it, prove that the **exact** primary-checkout submodule repository owns the object:
|
|
54
|
+
```bash
|
|
55
|
+
git -C <primary>/<submodule> rev-parse --show-toplevel
|
|
56
|
+
git -C <primary>/<submodule> cat-file -e <sha>^{commit}
|
|
57
|
+
```
|
|
58
|
+
The reported top-level path MUST equal the expected primary submodule path. A missing/deinitialized nested path can make `git -C` fall back to a parent repository; that is not proof. If the exact primary submodule cannot be proven, initialize/fetch it or use the helper, otherwise MUST fail closed.
|
|
59
|
+
|
|
60
|
+
A branch name alone does not publish a commit. If ANY working/index/HEAD gitlink SHA has neither a containing remote-tracking ref nor a proven exact primary-submodule object, it is an **unpublished gitlink risk**, whether the submodule is detached or on a named branch. MUST stop before `git add <submodule>` and present:
|
|
61
|
+
|
|
62
|
+
- **[1] Create/publish a branch** — hand off to `<skills-repo-root>/using-git-worktrees/references/submodules-in-worktrees.md`.
|
|
63
|
+
- **[2] Stage anyway** — only on explicit user choice; MUST record the unpublished gitlink risk in the handoff.
|
|
64
|
+
- **[3] Abort** — do not stage the gitlink.
|
|
65
|
+
|
|
66
|
+
`git-commit` MUST NOT create branches or push as part of this gate. It only detects the risk and hands off to the worktree reference when publication is needed.
|
|
67
|
+
|
|
68
|
+
If the Main Repo is not a linked worktree, or the commit is already externally reachable, continue normally.
|
|
69
|
+
|
|
17
70
|
## [Indexing Phase]
|
|
18
71
|
5. Preference Check: MUST read the resolved Harness memory file `git-commit-prefs.md` for the `auto_stage_submodules` setting. Prefer an existing `MEMORY.md`/`RULES.md`; otherwise use `.github/harness-everything/memories/git-commit-prefs.md`.
|
|
19
72
|
6. Execution:
|
|
@@ -24,4 +77,4 @@
|
|
|
24
77
|
- IF user selects [2]: MUST NOT run `git add`.
|
|
25
78
|
|
|
26
79
|
## [State Handoff]
|
|
27
|
-
7. Handoff: MUST summarize completed Sub Repo updates (including paths and logs) and explicitly carry them over when returning control to `SKILL.md`.
|
|
80
|
+
7. Handoff: MUST summarize completed Sub Repo updates (including paths, commit SHA, publication/reachability evidence, and logs) and explicitly carry them over when returning control to `SKILL.md`.
|
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 };
|
package/sbom.cdx.json
CHANGED
|
@@ -2,23 +2,23 @@
|
|
|
2
2
|
"$schema": "https://cyclonedx.org/schema/bom-1.7.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.7",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:0c1f9661-62c2-51a0-93aa-e317473be0d8",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-09-
|
|
8
|
+
"timestamp": "2026-09-27T17:23:19.452Z",
|
|
9
9
|
"tools": [
|
|
10
10
|
{
|
|
11
11
|
"vendor": "Harness Everything",
|
|
12
12
|
"name": "generate-sbom.js",
|
|
13
|
-
"version": "0.
|
|
13
|
+
"version": "0.27.0"
|
|
14
14
|
}
|
|
15
15
|
],
|
|
16
16
|
"component": {
|
|
17
17
|
"type": "application",
|
|
18
|
-
"bom-ref": "pkg:npm/harness-everything@0.
|
|
18
|
+
"bom-ref": "pkg:npm/harness-everything@0.27.0",
|
|
19
19
|
"name": "harness-everything",
|
|
20
|
-
"version": "0.
|
|
21
|
-
"purl": "pkg:npm/harness-everything@0.
|
|
20
|
+
"version": "0.27.0",
|
|
21
|
+
"purl": "pkg:npm/harness-everything@0.27.0"
|
|
22
22
|
}
|
|
23
23
|
},
|
|
24
24
|
"components": [
|
|
@@ -1999,7 +1999,7 @@
|
|
|
1999
1999
|
]
|
|
2000
2000
|
},
|
|
2001
2001
|
{
|
|
2002
|
-
"ref": "pkg:npm/harness-everything@0.
|
|
2002
|
+
"ref": "pkg:npm/harness-everything@0.27.0",
|
|
2003
2003
|
"dependsOn": [
|
|
2004
2004
|
"pkg:npm/%40commitlint/cli@19.8.1",
|
|
2005
2005
|
"pkg:npm/%40commitlint/config-conventional@19.8.1",
|
|
@@ -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>
|
|
@@ -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 };
|