@namewta/speculo 0.8.3 → 0.8.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/package.json +1 -1
- package/template/.speculo/README.md +8 -6
- package/template/commands/archive-and-consolidate.md +4 -4
- package/template/commands/docs-sync.md +4 -4
- package/template/commands/git-repository-audit.md +7 -7
- package/template/commands/handoff.md +2 -2
- package/template/commands/retro.md +6 -6
- package/template/commands/status.md +4 -4
- package/template/skills/archive-and-consolidate/SKILL.md +3 -3
- package/template/skills/docs-sync/assets/state-template.json +1 -1
- package/template/skills/docs-sync/assets/workflow-scope-template.json +1 -1
- package/template/skills/docs-sync/references/git-state-contract.md +2 -2
- package/template/skills/docs-sync/references/workflow-scope-contract.md +3 -3
- package/template/skills/github-npm-ops/references/preflight-checklist.md +1 -1
- package/template/skills/github-npm-ops/references/release-notes-injection.md +1 -1
- package/template/skills/github-npm-ops/references/release-pipeline.md +4 -4
- package/template/skills/github-npm-ops/references/version-bump-flow.md +2 -2
- package/template/skills/speculo-retro/references/friction-taxonomy.md +2 -2
- package/template/skills/speculo-retro/references/issue-drafting-sop.md +5 -5
- package/template/skills/upstream-fork-sync/SKILL.md +83 -0
- package/template/skills/upstream-fork-sync/references/report-contract.md +40 -0
- package/template/skills/upstream-fork-sync/references/repository-contract.md +54 -0
- package/template/skills/upstream-fork-sync/references/state-schema.md +55 -0
- package/template/skills/upstream-fork-sync/scripts/upstream-sync.mjs +740 -0
|
@@ -0,0 +1,740 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import {
|
|
6
|
+
closeSync,
|
|
7
|
+
existsSync,
|
|
8
|
+
fsyncSync,
|
|
9
|
+
lstatSync,
|
|
10
|
+
mkdirSync,
|
|
11
|
+
openSync,
|
|
12
|
+
readFileSync,
|
|
13
|
+
renameSync,
|
|
14
|
+
rmSync,
|
|
15
|
+
unlinkSync,
|
|
16
|
+
writeFileSync,
|
|
17
|
+
} from 'node:fs';
|
|
18
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
|
|
21
|
+
export const SCHEMA_VERSION = 1;
|
|
22
|
+
export class SyncError extends Error {}
|
|
23
|
+
|
|
24
|
+
function nowRfc3339() {
|
|
25
|
+
const date = new Date();
|
|
26
|
+
const pad = (value) => String(value).padStart(2, '0');
|
|
27
|
+
const offset = -date.getTimezoneOffset();
|
|
28
|
+
const sign = offset >= 0 ? '+' : '-';
|
|
29
|
+
const absolute = Math.abs(offset);
|
|
30
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
|
31
|
+
+ `T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
|
32
|
+
+ `${sign}${pad(Math.floor(absolute / 60))}:${pad(absolute % 60)}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function run(cwd, command, args, { allowed = [0], timeout = 120_000 } = {}) {
|
|
36
|
+
const result = spawnSync(command, args, {
|
|
37
|
+
cwd,
|
|
38
|
+
encoding: 'utf8',
|
|
39
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
40
|
+
timeout,
|
|
41
|
+
});
|
|
42
|
+
if (result.error) {
|
|
43
|
+
const reason = result.error.code === 'ETIMEDOUT' ? 'command timed out' : result.error.message;
|
|
44
|
+
throw new SyncError(`${reason} in ${cwd}: ${[command, ...args].join(' ')}`);
|
|
45
|
+
}
|
|
46
|
+
const status = result.status ?? 1;
|
|
47
|
+
if (!allowed.includes(status)) {
|
|
48
|
+
const detail = (result.stderr || result.stdout || 'no output').trim();
|
|
49
|
+
throw new SyncError(`command failed (${status}) in ${cwd}: ${[command, ...args].join(' ')}\n${detail}`);
|
|
50
|
+
}
|
|
51
|
+
return { status, stdout: result.stdout ?? '', stderr: result.stderr ?? '' };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function git(repo, ...args) {
|
|
55
|
+
let options = {};
|
|
56
|
+
if (args.length && typeof args.at(-1) === 'object') options = args.pop();
|
|
57
|
+
return run(repo, 'git', args, options);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function ensureRepository(path) {
|
|
61
|
+
if (!existsSync(path)) throw new SyncError(`repository path does not exist: ${path}`);
|
|
62
|
+
if (lstatSync(path).isSymbolicLink()) throw new SyncError(`repository path must not be a symlink: ${path}`);
|
|
63
|
+
if (git(path, 'rev-parse', '--is-inside-work-tree').stdout.trim() !== 'true') {
|
|
64
|
+
throw new SyncError(`not a Git worktree: ${path}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function inside(root, value, label, { allowRoot = false } = {}) {
|
|
69
|
+
const target = resolve(root, value);
|
|
70
|
+
const relation = relative(root, target);
|
|
71
|
+
if (isAbsolute(relation) || relation === '..' || relation.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`)) {
|
|
72
|
+
throw new SyncError(`${label} must stay under project root: ${target}`);
|
|
73
|
+
}
|
|
74
|
+
if (!allowRoot && !relation) throw new SyncError(`${label} must not equal project root`);
|
|
75
|
+
let current = root;
|
|
76
|
+
for (const segment of relation.split(/[\\/]/).filter(Boolean)) {
|
|
77
|
+
current = join(current, segment);
|
|
78
|
+
if (!existsSync(current)) break;
|
|
79
|
+
if (lstatSync(current).isSymbolicLink()) throw new SyncError(`${label} must not traverse a symlink: ${current}`);
|
|
80
|
+
}
|
|
81
|
+
return target;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function resolveCommit(repo, ref, required = true) {
|
|
85
|
+
const result = git(repo, 'rev-parse', '--verify', `${ref}^{commit}`, { allowed: [0, 128] });
|
|
86
|
+
if (result.status === 0) return result.stdout.trim();
|
|
87
|
+
if (required) throw new SyncError(`missing commit ref in ${repo}: ${ref}`);
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isAncestor(repo, ancestor, descendant) {
|
|
92
|
+
return git(repo, 'merge-base', '--is-ancestor', ancestor, descendant, { allowed: [0, 1] }).status === 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function uniqueMergeBase(repo, left, right) {
|
|
96
|
+
const bases = git(repo, 'merge-base', '--all', left, right).stdout.split('\n').filter(Boolean);
|
|
97
|
+
if (bases.length !== 1) throw new SyncError(`expected one merge base in ${repo}, found ${bases.length} for ${left} and ${right}`);
|
|
98
|
+
return bases[0];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function commitParents(repo, commit) {
|
|
102
|
+
const value = git(repo, 'show', '-s', '--format=%P', commit).stdout.trim();
|
|
103
|
+
return value ? value.split(/\s+/) : [];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function dirtyPaths(repo) {
|
|
107
|
+
const tokens = git(repo, 'status', '--porcelain=v1', '-z').stdout.split('\0');
|
|
108
|
+
const paths = new Set();
|
|
109
|
+
for (let index = 0; index < tokens.length;) {
|
|
110
|
+
const token = tokens[index++];
|
|
111
|
+
if (!token || token.length < 4) continue;
|
|
112
|
+
const status = token.slice(0, 2);
|
|
113
|
+
paths.add(token.slice(3));
|
|
114
|
+
if ((status.includes('R') || status.includes('C')) && tokens[index]) paths.add(tokens[index++]);
|
|
115
|
+
}
|
|
116
|
+
return [...paths].sort();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function revCounts(repo, left, right) {
|
|
120
|
+
const values = git(repo, 'rev-list', '--left-right', '--count', `${left}...${right}`).stdout.trim().split(/\s+/);
|
|
121
|
+
if (values.length !== 2) throw new SyncError(`unexpected rev-list count output in ${repo}`);
|
|
122
|
+
return { left_only: Number(values[0]), right_only: Number(values[1]) };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function fetchRemote(repo, remote) {
|
|
126
|
+
const result = git(repo, 'fetch', '--prune', '--tags', remote, {
|
|
127
|
+
allowed: Array.from({ length: 256 }, (_, index) => index),
|
|
128
|
+
timeout: 180_000,
|
|
129
|
+
});
|
|
130
|
+
return result.status === 0
|
|
131
|
+
? { ok: true, error: null }
|
|
132
|
+
: {
|
|
133
|
+
ok: false,
|
|
134
|
+
error: (result.stderr || result.stdout || `exit ${result.status}`).trim()
|
|
135
|
+
.replace(/\b(?:https?|ssh):\/\/\S+/gi, '<redacted-url>'),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function requireNullableString(value, label) {
|
|
140
|
+
if (value !== null && typeof value !== 'string') throw new SyncError(`${label} must be a string or null`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function loadRepositoryMap(path, projectRoot) {
|
|
144
|
+
if (existsSync(path) && lstatSync(path).isSymbolicLink()) throw new SyncError(`repository map must not be a symlink: ${path}`);
|
|
145
|
+
let data;
|
|
146
|
+
try { data = JSON.parse(readFileSync(path, 'utf8')); }
|
|
147
|
+
catch (error) { throw new SyncError(`cannot read repository map ${path}: ${error.message}`); }
|
|
148
|
+
if (Object.keys(data).sort().join(',') !== 'repositories,schema_version' || data.schema_version !== SCHEMA_VERSION || !Array.isArray(data.repositories)) {
|
|
149
|
+
throw new SyncError(`unsupported or malformed repository map: ${path}`);
|
|
150
|
+
}
|
|
151
|
+
if (!data.repositories.length) throw new SyncError(`repository map has no repositories: ${path}`);
|
|
152
|
+
const ids = new Set();
|
|
153
|
+
const allowedKeys = [
|
|
154
|
+
'baseline_ref', 'id', 'mirror_ref', 'origin_ref', 'origin_remote', 'path',
|
|
155
|
+
'product_ref', 'risk_paths', 'upstream_ref', 'upstream_remote',
|
|
156
|
+
].sort().join(',');
|
|
157
|
+
for (const item of data.repositories) {
|
|
158
|
+
if (!item || Object.keys(item).sort().join(',') !== allowedKeys) throw new SyncError(`malformed repository entry in ${path}`);
|
|
159
|
+
if (typeof item.id !== 'string' || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(item.id) || ids.has(item.id)) {
|
|
160
|
+
throw new SyncError(`repository id must be unique kebab-case: ${item.id}`);
|
|
161
|
+
}
|
|
162
|
+
ids.add(item.id);
|
|
163
|
+
if (typeof item.path !== 'string' || !item.path || item.path.includes('\\') || isAbsolute(item.path)
|
|
164
|
+
|| item.path.split('/').includes('..')) throw new SyncError(`invalid repository path for ${item.id}: ${item.path}`);
|
|
165
|
+
item.absolute_path = inside(projectRoot, item.path, `repository ${item.id}`, { allowRoot: true });
|
|
166
|
+
for (const key of ['product_ref', 'upstream_ref']) {
|
|
167
|
+
if (typeof item[key] !== 'string' || !item[key].startsWith('refs/')) throw new SyncError(`${item.id}.${key} must be a full ref`);
|
|
168
|
+
}
|
|
169
|
+
for (const key of ['origin_ref', 'mirror_ref', 'baseline_ref', 'origin_remote']) requireNullableString(item[key], `${item.id}.${key}`);
|
|
170
|
+
for (const key of ['origin_remote', 'upstream_remote']) {
|
|
171
|
+
if (item[key] !== null && (typeof item[key] !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(item[key]))) {
|
|
172
|
+
throw new SyncError(`${item.id}.${key} must be a safe remote name`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (!item.upstream_remote) throw new SyncError(`${item.id}.upstream_remote is required`);
|
|
176
|
+
if (!Array.isArray(item.risk_paths) || !item.risk_paths.every((value) => typeof value === 'string' && value
|
|
177
|
+
&& !value.includes('\\') && !isAbsolute(value) && !value.split('/').includes('..'))) {
|
|
178
|
+
throw new SyncError(`${item.id}.risk_paths must contain safe project-relative globs`);
|
|
179
|
+
}
|
|
180
|
+
ensureRepository(item.absolute_path);
|
|
181
|
+
}
|
|
182
|
+
return data;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function validateRepositoryState(path, id, item) {
|
|
186
|
+
if (!item || Object.keys(item).sort().join(',') !== 'integrated_upstream_sha,main_merge_sha,observed_upstream_sha'
|
|
187
|
+
|| typeof item.integrated_upstream_sha !== 'string' || typeof item.observed_upstream_sha !== 'string'
|
|
188
|
+
|| (item.main_merge_sha !== null && typeof item.main_merge_sha !== 'string')) {
|
|
189
|
+
throw new SyncError(`malformed repository state in ${path}: ${id}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function loadState(path, repositoryIds) {
|
|
194
|
+
if (!existsSync(path)) return { schema_version: SCHEMA_VERSION, updated_at: null, current_change: null, repositories: {} };
|
|
195
|
+
if (lstatSync(path).isSymbolicLink()) throw new SyncError(`state file must not be a symlink: ${path}`);
|
|
196
|
+
let data;
|
|
197
|
+
try { data = JSON.parse(readFileSync(path, 'utf8')); }
|
|
198
|
+
catch (error) { throw new SyncError(`cannot read state ${path}: ${error.message}`); }
|
|
199
|
+
if (Object.keys(data).sort().join(',') !== 'current_change,repositories,schema_version,updated_at'
|
|
200
|
+
|| data.schema_version !== SCHEMA_VERSION || typeof data.repositories !== 'object' || Array.isArray(data.repositories)
|
|
201
|
+
|| (data.updated_at !== null && typeof data.updated_at !== 'string')
|
|
202
|
+
|| (data.current_change !== null && typeof data.current_change !== 'string')) {
|
|
203
|
+
throw new SyncError(`unsupported or malformed state: ${path}`);
|
|
204
|
+
}
|
|
205
|
+
for (const [id, item] of Object.entries(data.repositories)) {
|
|
206
|
+
if (!repositoryIds.has(id)) throw new SyncError(`state references unknown repository: ${id}`);
|
|
207
|
+
validateRepositoryState(path, id, item);
|
|
208
|
+
}
|
|
209
|
+
return data;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function bootstrapIntegration(repo, productSha, upstreamSha, baselineSha, timestamp) {
|
|
213
|
+
const range = baselineSha ? `${baselineSha}..${productSha}` : productSha;
|
|
214
|
+
const merges = git(repo, 'rev-list', '--first-parent', '--merges', range).stdout.split('\n').filter(Boolean);
|
|
215
|
+
for (const mergeCommit of merges) {
|
|
216
|
+
for (const parent of commitParents(repo, mergeCommit).slice(1)) {
|
|
217
|
+
if (isAncestor(repo, parent, upstreamSha)) {
|
|
218
|
+
return { upstream_sha: parent, product_merge_commit_sha: mergeCommit, source: 'graph-merge', confirmed_at: timestamp };
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
upstream_sha: uniqueMergeBase(repo, productSha, upstreamSha),
|
|
224
|
+
product_merge_commit_sha: null,
|
|
225
|
+
source: 'derived-merge-base',
|
|
226
|
+
confirmed_at: timestamp,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function validateSavedIntegration(repo, item, productSha, upstreamSha) {
|
|
231
|
+
resolveCommit(repo, item.integrated_upstream_sha);
|
|
232
|
+
if (!isAncestor(repo, item.integrated_upstream_sha, productSha)) {
|
|
233
|
+
throw new SyncError(`saved checkpoint is no longer in product history in ${repo}: ${item.integrated_upstream_sha}`);
|
|
234
|
+
}
|
|
235
|
+
if (!isAncestor(repo, item.integrated_upstream_sha, upstreamSha)) {
|
|
236
|
+
throw new SyncError(`upstream history no longer contains saved checkpoint in ${repo}: ${item.integrated_upstream_sha}`);
|
|
237
|
+
}
|
|
238
|
+
if (item.main_merge_sha) {
|
|
239
|
+
resolveCommit(repo, item.main_merge_sha);
|
|
240
|
+
if (!isAncestor(repo, item.main_merge_sha, productSha)) {
|
|
241
|
+
throw new SyncError(`saved integration merge is no longer in product history in ${repo}: ${item.main_merge_sha}`);
|
|
242
|
+
}
|
|
243
|
+
if (!commitParents(repo, item.main_merge_sha).slice(1).includes(item.integrated_upstream_sha)) {
|
|
244
|
+
throw new SyncError(`saved checkpoint is not an exact non-first merge parent in ${repo}: ${item.integrated_upstream_sha}`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function parseCommits(repo, start, end) {
|
|
250
|
+
if (start === end) return [];
|
|
251
|
+
return git(repo, 'log', '--reverse', '--format=%H%x1f%h%x1f%ad%x1f%s', '--date=short', `${start}..${end}`).stdout
|
|
252
|
+
.split('\n').filter(Boolean).map((line) => {
|
|
253
|
+
const [sha, short_sha, date, subject] = line.split('\x1f', 4);
|
|
254
|
+
return { sha, short_sha, date, subject };
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function parseNameStatus(repo, start, end) {
|
|
259
|
+
if (start === end) return [];
|
|
260
|
+
const tokens = git(repo, 'diff', '--name-status', '-z', '--find-renames', start, end).stdout.split('\0');
|
|
261
|
+
const files = [];
|
|
262
|
+
for (let index = 0; index < tokens.length;) {
|
|
263
|
+
const status = tokens[index++];
|
|
264
|
+
if (!status) continue;
|
|
265
|
+
const firstPath = tokens[index++];
|
|
266
|
+
if (status.startsWith('R') || status.startsWith('C')) files.push({ status, old_path: firstPath, path: tokens[index++] });
|
|
267
|
+
else files.push({ status, old_path: null, path: firstPath });
|
|
268
|
+
}
|
|
269
|
+
return files;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function parseNumstat(repo, start, end) {
|
|
273
|
+
if (start === end) return {};
|
|
274
|
+
const tokens = git(repo, 'diff', '--numstat', '-z', '--find-renames', start, end).stdout.split('\0');
|
|
275
|
+
const stats = {};
|
|
276
|
+
for (let index = 0; index < tokens.length;) {
|
|
277
|
+
const token = tokens[index++];
|
|
278
|
+
if (!token) continue;
|
|
279
|
+
const [additions, deletions, path] = token.split('\t', 3);
|
|
280
|
+
if (path) stats[path] = { additions, deletions };
|
|
281
|
+
else {
|
|
282
|
+
index += 1;
|
|
283
|
+
const newPath = tokens[index++];
|
|
284
|
+
stats[newPath] = { additions, deletions };
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return stats;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function globRegex(glob) {
|
|
291
|
+
let output = '^';
|
|
292
|
+
for (let index = 0; index < glob.length; index += 1) {
|
|
293
|
+
const char = glob[index];
|
|
294
|
+
if (char === '*' && glob[index + 1] === '*') { output += '.*'; index += 1; }
|
|
295
|
+
else if (char === '*') output += '[^/]*';
|
|
296
|
+
else if (char === '?') output += '[^/]';
|
|
297
|
+
else output += char.replace(/[|\\{}()[\]^$+?.]/g, '\\$&');
|
|
298
|
+
}
|
|
299
|
+
return new RegExp(`${output}$`);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function mergeTree(repo, productSha, upstreamSha) {
|
|
303
|
+
const result = git(repo, 'merge-tree', '--write-tree', '--messages', productSha, upstreamSha, { allowed: [0, 1] });
|
|
304
|
+
const lines = result.stdout.split('\n');
|
|
305
|
+
const treeSha = /^[0-9a-f]{40,64}$/.test(lines[0]?.trim()) ? lines[0].trim() : null;
|
|
306
|
+
const conflicts = new Set();
|
|
307
|
+
const messages = [];
|
|
308
|
+
for (const line of lines.slice(1)) {
|
|
309
|
+
const match = line.match(/^\d{6} [0-9a-f]{40,64} [123]\t(.+)$/);
|
|
310
|
+
if (match) conflicts.add(match[1]);
|
|
311
|
+
else if (line.startsWith('CONFLICT') || line.startsWith('Auto-merging')) messages.push(line);
|
|
312
|
+
}
|
|
313
|
+
return {
|
|
314
|
+
status: result.status === 0 ? 'clean' : conflicts.size ? 'conflicted' : 'conflicted-unparsed',
|
|
315
|
+
exit_code: result.status,
|
|
316
|
+
tree_sha: treeSha,
|
|
317
|
+
conflict_paths: [...conflicts].sort(),
|
|
318
|
+
messages,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function collectRepository(config, saved, freshness, fetchError, timestamp) {
|
|
323
|
+
const repo = config.absolute_path;
|
|
324
|
+
const productSha = resolveCommit(repo, config.product_ref);
|
|
325
|
+
const upstreamSha = resolveCommit(repo, config.upstream_ref);
|
|
326
|
+
const baselineSha = config.baseline_ref ? resolveCommit(repo, config.baseline_ref) : null;
|
|
327
|
+
const originSha = config.origin_ref ? resolveCommit(repo, config.origin_ref, false) : null;
|
|
328
|
+
const mirrorSha = config.mirror_ref ? resolveCommit(repo, config.mirror_ref, false) : null;
|
|
329
|
+
if (baselineSha && (!isAncestor(repo, baselineSha, productSha) || !isAncestor(repo, baselineSha, upstreamSha))) {
|
|
330
|
+
throw new SyncError(`baseline must be an ancestor of product and upstream in ${repo}: ${baselineSha}`);
|
|
331
|
+
}
|
|
332
|
+
if (mirrorSha && !isAncestor(repo, mirrorSha, upstreamSha)) {
|
|
333
|
+
throw new SyncError(`mirror is not an ancestor of observed upstream in ${repo}: ${mirrorSha} !<= ${upstreamSha}`);
|
|
334
|
+
}
|
|
335
|
+
if (saved) validateSavedIntegration(repo, saved, productSha, upstreamSha);
|
|
336
|
+
const integration = saved
|
|
337
|
+
? {
|
|
338
|
+
upstream_sha: saved.integrated_upstream_sha,
|
|
339
|
+
product_merge_commit_sha: saved.main_merge_sha,
|
|
340
|
+
source: saved.main_merge_sha ? 'recorded-merge' : 'derived-merge-base',
|
|
341
|
+
confirmed_at: null,
|
|
342
|
+
}
|
|
343
|
+
: bootstrapIntegration(repo, productSha, upstreamSha, baselineSha, timestamp);
|
|
344
|
+
const integratedSha = integration.upstream_sha;
|
|
345
|
+
const files = parseNameStatus(repo, integratedSha, upstreamSha);
|
|
346
|
+
const stats = parseNumstat(repo, integratedSha, upstreamSha);
|
|
347
|
+
const riskMatchers = config.risk_paths.map((pattern) => [pattern, globRegex(pattern)]);
|
|
348
|
+
for (const file of files) {
|
|
349
|
+
Object.assign(file, stats[file.path] ?? { additions: '?', deletions: '?' });
|
|
350
|
+
file.risk_patterns = riskMatchers.filter(([, regex]) => regex.test(file.path)).map(([pattern]) => pattern);
|
|
351
|
+
}
|
|
352
|
+
const upstreamPaths = new Set(files.map((item) => item.path));
|
|
353
|
+
const productPaths = new Set(parseNameStatus(repo, integratedSha, productSha).map((item) => item.path));
|
|
354
|
+
const overlaps = [...upstreamPaths].filter((path) => productPaths.has(path)).sort();
|
|
355
|
+
const dirty = dirtyPaths(repo);
|
|
356
|
+
const dirtySet = new Set(dirty);
|
|
357
|
+
const tree = mergeTree(repo, productSha, upstreamSha);
|
|
358
|
+
const conflictSet = new Set(tree.conflict_paths);
|
|
359
|
+
const riskPaths = files.filter((file) => file.risk_patterns.length)
|
|
360
|
+
.map((file) => ({ path: file.path, patterns: file.risk_patterns }));
|
|
361
|
+
const observation = {
|
|
362
|
+
id: config.id,
|
|
363
|
+
path: config.path,
|
|
364
|
+
freshness,
|
|
365
|
+
fetch_error: fetchError,
|
|
366
|
+
product_sha: productSha,
|
|
367
|
+
origin_sha: originSha,
|
|
368
|
+
upstream_sha: upstreamSha,
|
|
369
|
+
mirror_sha: mirrorSha,
|
|
370
|
+
baseline_sha: baselineSha,
|
|
371
|
+
merge_base_sha: uniqueMergeBase(repo, productSha, upstreamSha),
|
|
372
|
+
integration,
|
|
373
|
+
product_vs_upstream: revCounts(repo, productSha, upstreamSha),
|
|
374
|
+
product_vs_origin: originSha ? revCounts(repo, productSha, originSha) : null,
|
|
375
|
+
upstream_commits: parseCommits(repo, integratedSha, upstreamSha),
|
|
376
|
+
upstream_files: files,
|
|
377
|
+
upstream_shortstat: integratedSha === upstreamSha ? '' : git(repo, 'diff', '--shortstat', integratedSha, upstreamSha).stdout.trim(),
|
|
378
|
+
product_overlap_paths: overlaps,
|
|
379
|
+
automatic_overlap_paths: overlaps.filter((path) => !conflictSet.has(path)),
|
|
380
|
+
dirty_paths: dirty,
|
|
381
|
+
dirty_overlap_paths: [...upstreamPaths].filter((path) => dirtySet.has(path)).sort(),
|
|
382
|
+
risk_paths: riskPaths,
|
|
383
|
+
merge_tree: tree,
|
|
384
|
+
};
|
|
385
|
+
return [observation, {
|
|
386
|
+
integrated_upstream_sha: integratedSha,
|
|
387
|
+
main_merge_sha: integration.product_merge_commit_sha,
|
|
388
|
+
observed_upstream_sha: upstreamSha,
|
|
389
|
+
}];
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function markdown(value) { return String(value).replaceAll('|', '\\|').replaceAll('\n', ' '); }
|
|
393
|
+
function markdownPath(value) { return `\`${String(value).replaceAll('`', '').replaceAll('|', '\\|').replaceAll('\n', ' ')}\``; }
|
|
394
|
+
|
|
395
|
+
function renderDiffReport(snapshot) {
|
|
396
|
+
const lines = ['# 上游增量 Diff 报告', '', `- Change:\`${snapshot.change}\``, `- 生成时间:\`${snapshot.created_at}\``, ''];
|
|
397
|
+
for (const item of snapshot.repositories) {
|
|
398
|
+
lines.push(`## ${item.id}`, '', '| 固定项 | 值 |', '|---|---|',
|
|
399
|
+
`| 仓库路径 | ${markdownPath(item.path)} |`, `| Product SHA | \`${item.product_sha}\` |`,
|
|
400
|
+
`| 已集成 upstream SHA | \`${item.integration.upstream_sha}\` |`, `| Checkpoint 来源 | \`${item.integration.source}\` |`,
|
|
401
|
+
`| Product merge commit | \`${item.integration.product_merge_commit_sha ?? 'null'}\` |`, `| 观测 upstream SHA | \`${item.upstream_sha}\` |`,
|
|
402
|
+
`| Merge-base | \`${item.merge_base_sha}\` |`, `| Mirror SHA | \`${item.mirror_sha ?? 'null'}\` |`,
|
|
403
|
+
`| Freshness | \`${item.freshness}\` |`, '');
|
|
404
|
+
if (item.fetch_error) lines.push(`> Upstream fetch 失败:${markdown(item.fetch_error)}`, '');
|
|
405
|
+
lines.push(`### 上游新增提交(${item.upstream_commits.length})`, '');
|
|
406
|
+
if (item.upstream_commits.length) lines.push(...item.upstream_commits.map((entry) => `- \`${entry.short_sha}\` ${entry.date} ${markdown(entry.subject)}`));
|
|
407
|
+
else lines.push('- 无新增上游提交。');
|
|
408
|
+
lines.push('', '### 文件 Diff', '', `统计:${item.upstream_shortstat || '0 files changed'}`, '');
|
|
409
|
+
if (item.upstream_files.length) {
|
|
410
|
+
lines.push('| 状态 | 文件 | 新增 | 删除 | 风险规则 |', '|---|---|---:|---:|---|');
|
|
411
|
+
for (const file of item.upstream_files) {
|
|
412
|
+
const display = file.old_path ? `${file.old_path} -> ${file.path}` : file.path;
|
|
413
|
+
lines.push(`| \`${file.status}\` | ${markdownPath(display)} | ${file.additions} | ${file.deletions} | ${markdown(file.risk_patterns.join(', ') || '-')} |`);
|
|
414
|
+
}
|
|
415
|
+
} else lines.push('无文件变化。');
|
|
416
|
+
lines.push('', '### 产品重叠面', '');
|
|
417
|
+
if (item.product_overlap_paths.length) lines.push(...item.product_overlap_paths.map((path) => `- ${markdownPath(path)}`));
|
|
418
|
+
else lines.push('- 上游增量与产品自 checkpoint 后的文件变化无路径重叠。');
|
|
419
|
+
lines.push('', '### 定制风险路径', '');
|
|
420
|
+
if (item.risk_paths.length) lines.push(...item.risk_paths.map((entry) => `- ${markdownPath(entry.path)}: ${markdown(entry.patterns.join(', '))}`));
|
|
421
|
+
else lines.push('- 未命中 repository map 风险规则;仍需核对 customization map。');
|
|
422
|
+
lines.push('', '### 复现命令', '', '```bash',
|
|
423
|
+
`git -C ${item.path} log --oneline ${item.integration.upstream_sha}..${item.upstream_sha}`,
|
|
424
|
+
`git -C ${item.path} diff --name-status ${item.integration.upstream_sha}..${item.upstream_sha}`,
|
|
425
|
+
`git -C ${item.path} diff ${item.integration.upstream_sha}..${item.upstream_sha} -- <path>`, '```', '');
|
|
426
|
+
}
|
|
427
|
+
lines.push('## 现状清单', '', '| 仓库 | 上游增量 | Git 冲突 | 定制风险路径 | 当前处置 |', '|---|---:|---:|---:|---|');
|
|
428
|
+
for (const item of snapshot.repositories) {
|
|
429
|
+
const disposition = item.upstream_commits.length ? '等待用户选择后续 Work' : '无上游增量';
|
|
430
|
+
lines.push(`| ${item.id} | ${item.upstream_commits.length} commits / ${item.upstream_files.length} files | ${item.merge_tree.conflict_paths.length} | ${item.risk_paths.length} | ${disposition} |`);
|
|
431
|
+
}
|
|
432
|
+
lines.push('', '## 结论边界', '', '本报告只冻结评估证据,不授权或执行 merge、commit、push、tag、分支移动或后续 Work。', '');
|
|
433
|
+
return lines.join('\n');
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function appendPaths(lines, paths, empty) {
|
|
437
|
+
if (paths.length) lines.push(...paths.map((path) => `- ${markdownPath(path)}`));
|
|
438
|
+
else lines.push(`- ${empty}`);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function renderConflictReport(snapshot) {
|
|
442
|
+
const lines = ['# 上游合并冲突客观报告', '', `- Change:\`${snapshot.change}\``, `- 生成时间:\`${snapshot.created_at}\``,
|
|
443
|
+
'- 模拟方式:`git merge-tree --write-tree --messages <product-sha> <upstream-sha>`', ''];
|
|
444
|
+
for (const item of snapshot.repositories) {
|
|
445
|
+
const tree = item.merge_tree;
|
|
446
|
+
lines.push(`## ${item.id}`, '', '| 固定项 | 值 |', '|---|---|', `| Product SHA | \`${item.product_sha}\` |`,
|
|
447
|
+
`| Upstream SHA | \`${item.upstream_sha}\` |`, `| Merge-base | \`${item.merge_base_sha}\` |`,
|
|
448
|
+
`| Merge-tree 状态 | \`${tree.status}\` |`, `| Merge-tree exit code | \`${tree.exit_code}\` |`,
|
|
449
|
+
`| 结果 tree | \`${tree.tree_sha ?? 'null'}\` |`, `| Git 确认冲突数 | \`${tree.conflict_paths.length}\` |`, '',
|
|
450
|
+
'### Git 确认冲突', '');
|
|
451
|
+
appendPaths(lines, tree.conflict_paths, 'Git 未报告文本或树冲突。');
|
|
452
|
+
if (tree.messages.length) lines.push('', '```text', ...tree.messages, '```');
|
|
453
|
+
lines.push('', '### 可自动合并的双方重叠', '');
|
|
454
|
+
appendPaths(lines, item.automatic_overlap_paths, '没有双方同时修改但可自动合并的路径。');
|
|
455
|
+
lines.push('', '### 定制合同风险', '');
|
|
456
|
+
if (item.risk_paths.length) lines.push(...item.risk_paths.map((entry) => `- ${markdownPath(entry.path)}: ${markdown(entry.patterns.join(', '))}`));
|
|
457
|
+
else lines.push('- 未命中 repository map 风险规则;仍须核对 customization map。');
|
|
458
|
+
lines.push('', '### 未提交工作树重叠', '');
|
|
459
|
+
appendPaths(lines, item.dirty_overlap_paths, '未提交路径与本次上游增量无交集。');
|
|
460
|
+
lines.push('', '### 工作树状态', '');
|
|
461
|
+
appendPaths(lines, item.dirty_paths, '工作树 clean。');
|
|
462
|
+
lines.push('', '### 复现命令', '', '```bash',
|
|
463
|
+
`git -C ${item.path} merge-tree --write-tree --messages ${item.product_sha} ${item.upstream_sha}`, '```', '');
|
|
464
|
+
}
|
|
465
|
+
lines.push('## 局限', '', '零文本冲突不代表编译、运行时、API、权限、数据迁移或业务语义安全。', '');
|
|
466
|
+
return lines.join('\n');
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function atomicText(path, content) {
|
|
470
|
+
if (existsSync(path) && lstatSync(path).isSymbolicLink()) throw new SyncError(`refusing to replace symlink state file: ${path}`);
|
|
471
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
472
|
+
const temporary = join(dirname(path), `.${basename(path)}.${process.pid}.${Date.now()}.tmp`);
|
|
473
|
+
let descriptor;
|
|
474
|
+
try {
|
|
475
|
+
descriptor = openSync(temporary, 'wx');
|
|
476
|
+
writeFileSync(descriptor, content, 'utf8');
|
|
477
|
+
fsyncSync(descriptor);
|
|
478
|
+
closeSync(descriptor);
|
|
479
|
+
descriptor = undefined;
|
|
480
|
+
renameSync(temporary, path);
|
|
481
|
+
} finally {
|
|
482
|
+
if (descriptor !== undefined) closeSync(descriptor);
|
|
483
|
+
if (existsSync(temporary)) unlinkSync(temporary);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function atomicJson(path, value) { atomicText(path, `${JSON.stringify(value, null, 2)}\n`); }
|
|
488
|
+
function sha256(path) { return createHash('sha256').update(readFileSync(path)).digest('hex'); }
|
|
489
|
+
|
|
490
|
+
function sanitizeTopic(value) {
|
|
491
|
+
const topic = value.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
492
|
+
if (!topic) throw new SyncError('topic must contain ASCII letters or digits');
|
|
493
|
+
return topic;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function chooseChange(stateRoot, date, topic) {
|
|
497
|
+
const base = `${date}-${topic}`;
|
|
498
|
+
let name = base;
|
|
499
|
+
for (let counter = 1; existsSync(join(stateRoot, name)); counter += 1) name = `${base}-${String(counter).padStart(2, '0')}`;
|
|
500
|
+
return name;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function pathsFromOptions(options) {
|
|
504
|
+
const root = resolve(options.root ?? '.');
|
|
505
|
+
ensureRepository(root);
|
|
506
|
+
if (!options['state-root'] || !options['repository-map']) throw new SyncError('--state-root and --repository-map are required');
|
|
507
|
+
return {
|
|
508
|
+
root,
|
|
509
|
+
stateRoot: inside(root, options['state-root'], 'state root'),
|
|
510
|
+
repositoryMap: inside(root, options['repository-map'], 'repository map'),
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
export function assess(options) {
|
|
515
|
+
const { root, stateRoot, repositoryMap } = pathsFromOptions(options);
|
|
516
|
+
if (!options.topic) throw new SyncError('--topic is required');
|
|
517
|
+
const topic = sanitizeTopic(options.topic);
|
|
518
|
+
const timestamp = nowRfc3339();
|
|
519
|
+
const date = options.date ?? timestamp.slice(0, 10);
|
|
520
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) throw new SyncError(`date must use YYYY-MM-DD: ${date}`);
|
|
521
|
+
const map = loadRepositoryMap(repositoryMap, root);
|
|
522
|
+
const stateFile = join(stateRoot, 'state.json');
|
|
523
|
+
const ids = new Set(map.repositories.map((item) => item.id));
|
|
524
|
+
const state = loadState(stateFile, ids);
|
|
525
|
+
const fetchResults = {};
|
|
526
|
+
if (options.fetch) {
|
|
527
|
+
for (const config of map.repositories) {
|
|
528
|
+
const upstream = fetchRemote(config.absolute_path, config.upstream_remote);
|
|
529
|
+
let origin = null;
|
|
530
|
+
if (config.origin_remote && config.origin_remote !== config.upstream_remote) origin = fetchRemote(config.absolute_path, config.origin_remote);
|
|
531
|
+
fetchResults[config.id] = { upstream, origin };
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
const observations = [];
|
|
535
|
+
const nextRepositoryState = {};
|
|
536
|
+
for (const config of map.repositories) {
|
|
537
|
+
const fetched = fetchResults[config.id]?.upstream;
|
|
538
|
+
const freshness = !options.fetch ? 'cached' : fetched?.ok ? 'fresh' : 'stale';
|
|
539
|
+
const [observation, compact] = collectRepository(config, state.repositories[config.id], freshness, fetched?.error ?? null, timestamp);
|
|
540
|
+
observations.push(observation);
|
|
541
|
+
nextRepositoryState[config.id] = compact;
|
|
542
|
+
}
|
|
543
|
+
const change = chooseChange(stateRoot, date, topic);
|
|
544
|
+
const snapshot = { change, created_at: timestamp, topic, fetch_requested: Boolean(options.fetch), fetch_results: fetchResults, repositories: observations };
|
|
545
|
+
if (options['dry-run']) return { dryRun: true, result: snapshot };
|
|
546
|
+
|
|
547
|
+
mkdirSync(stateRoot, { recursive: true });
|
|
548
|
+
const finalDir = join(stateRoot, change);
|
|
549
|
+
const temporary = join(stateRoot, `.${change}.${process.pid}.${Date.now()}.tmp`);
|
|
550
|
+
try {
|
|
551
|
+
mkdirSync(temporary);
|
|
552
|
+
const repositories = {};
|
|
553
|
+
for (const item of observations) {
|
|
554
|
+
if (item.integration.upstream_sha !== item.upstream_sha) {
|
|
555
|
+
repositories[item.id] = {
|
|
556
|
+
product_sha: item.product_sha,
|
|
557
|
+
upstream_sha: item.upstream_sha,
|
|
558
|
+
integrated_upstream_sha: item.integration.upstream_sha,
|
|
559
|
+
main_merge_sha: null,
|
|
560
|
+
recorded_at: null,
|
|
561
|
+
verification: [],
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
atomicJson(join(temporary, 'state.json'), {
|
|
566
|
+
schema_version: SCHEMA_VERSION,
|
|
567
|
+
created_at: timestamp,
|
|
568
|
+
repository_map_sha256: sha256(repositoryMap),
|
|
569
|
+
repositories,
|
|
570
|
+
});
|
|
571
|
+
atomicText(join(temporary, 'diff-report.md'), renderDiffReport(snapshot));
|
|
572
|
+
atomicText(join(temporary, 'conflict-report.md'), renderConflictReport(snapshot));
|
|
573
|
+
if (existsSync(finalDir)) throw new SyncError(`change directory already exists: ${finalDir}`);
|
|
574
|
+
renameSync(temporary, finalDir);
|
|
575
|
+
atomicJson(stateFile, {
|
|
576
|
+
schema_version: SCHEMA_VERSION,
|
|
577
|
+
updated_at: timestamp,
|
|
578
|
+
current_change: change,
|
|
579
|
+
repositories: nextRepositoryState,
|
|
580
|
+
});
|
|
581
|
+
} finally {
|
|
582
|
+
if (existsSync(temporary)) rmSync(temporary, { recursive: true, force: true });
|
|
583
|
+
}
|
|
584
|
+
return { dryRun: false, result: { state: stateFile, change, change_dir: finalDir } };
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function loadChangeState(path, repositoryIds) {
|
|
588
|
+
if (existsSync(path) && lstatSync(path).isSymbolicLink()) throw new SyncError(`change state must not be a symlink: ${path}`);
|
|
589
|
+
let data;
|
|
590
|
+
try { data = JSON.parse(readFileSync(path, 'utf8')); }
|
|
591
|
+
catch (error) { throw new SyncError(`cannot read change state ${path}: ${error.message}`); }
|
|
592
|
+
if (Object.keys(data).sort().join(',') !== 'created_at,repositories,repository_map_sha256,schema_version'
|
|
593
|
+
|| data.schema_version !== SCHEMA_VERSION || typeof data.created_at !== 'string'
|
|
594
|
+
|| typeof data.repository_map_sha256 !== 'string' || typeof data.repositories !== 'object' || Array.isArray(data.repositories)) {
|
|
595
|
+
throw new SyncError(`malformed change state: ${path}`);
|
|
596
|
+
}
|
|
597
|
+
const keys = 'integrated_upstream_sha,main_merge_sha,product_sha,recorded_at,upstream_sha,verification';
|
|
598
|
+
for (const [id, item] of Object.entries(data.repositories)) {
|
|
599
|
+
if (!repositoryIds.has(id) || !item || Object.keys(item).sort().join(',') !== keys
|
|
600
|
+
|| !['product_sha', 'upstream_sha', 'integrated_upstream_sha'].every((key) => typeof item[key] === 'string')
|
|
601
|
+
|| (item.main_merge_sha !== null && typeof item.main_merge_sha !== 'string')
|
|
602
|
+
|| (item.recorded_at !== null && typeof item.recorded_at !== 'string')
|
|
603
|
+
|| !Array.isArray(item.verification) || !item.verification.every((value) => typeof value === 'string')) {
|
|
604
|
+
throw new SyncError(`malformed change repository state: ${id}`);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
return data;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
export function recordIntegration(options) {
|
|
611
|
+
const { root, stateRoot, repositoryMap } = pathsFromOptions(options);
|
|
612
|
+
const map = loadRepositoryMap(repositoryMap, root);
|
|
613
|
+
const ids = new Set(map.repositories.map((item) => item.id));
|
|
614
|
+
const config = map.repositories.find((item) => item.id === options.repository);
|
|
615
|
+
if (!config) throw new SyncError(`unknown --repository: ${options.repository}`);
|
|
616
|
+
if (!options['merge-commit'] || !options['upstream-sha']) throw new SyncError('--merge-commit and --upstream-sha are required');
|
|
617
|
+
if (!options.verification?.length) throw new SyncError('at least one --verification is required');
|
|
618
|
+
const stateFile = join(stateRoot, 'state.json');
|
|
619
|
+
const state = loadState(stateFile, ids);
|
|
620
|
+
const change = options.change ?? state.current_change;
|
|
621
|
+
if (typeof change !== 'string' || basename(change) !== change || change === '.' || change === '..') throw new SyncError('a safe --change is required');
|
|
622
|
+
const changeFile = inside(root, join(stateRoot, change, 'state.json'), 'change state');
|
|
623
|
+
const changeState = loadChangeState(changeFile, ids);
|
|
624
|
+
if (changeState.repository_map_sha256 !== sha256(repositoryMap)) throw new SyncError('repository map changed after assessment');
|
|
625
|
+
const pending = changeState.repositories[config.id];
|
|
626
|
+
if (!pending) throw new SyncError(`repository is not pending in change: ${config.id}`);
|
|
627
|
+
const repo = config.absolute_path;
|
|
628
|
+
const mergeSha = resolveCommit(repo, options['merge-commit']);
|
|
629
|
+
const upstreamSha = resolveCommit(repo, options['upstream-sha']);
|
|
630
|
+
const productSha = resolveCommit(repo, config.product_ref);
|
|
631
|
+
const observedUpstream = resolveCommit(repo, config.upstream_ref);
|
|
632
|
+
if (!isAncestor(repo, mergeSha, productSha)) throw new SyncError(`merge commit is not reachable from product ref: ${mergeSha}`);
|
|
633
|
+
const parents = commitParents(repo, mergeSha);
|
|
634
|
+
if (parents.length < 2) throw new SyncError(`integration commit is not a merge commit: ${mergeSha}`);
|
|
635
|
+
if (!parents.slice(1).includes(upstreamSha)) throw new SyncError(`upstream SHA is not an exact non-first parent: ${upstreamSha}`);
|
|
636
|
+
if (!isAncestor(repo, upstreamSha, observedUpstream)) throw new SyncError(`upstream SHA is not in observed upstream history: ${upstreamSha}`);
|
|
637
|
+
if (pending.upstream_sha !== upstreamSha) throw new SyncError(`upstream SHA does not match frozen target: ${upstreamSha} != ${pending.upstream_sha}`);
|
|
638
|
+
const compact = state.repositories[config.id];
|
|
639
|
+
if (!compact) throw new SyncError(`root state is missing repository: ${config.id}`);
|
|
640
|
+
resolveCommit(repo, compact.integrated_upstream_sha);
|
|
641
|
+
if (!isAncestor(repo, compact.integrated_upstream_sha, upstreamSha)) {
|
|
642
|
+
if (isAncestor(repo, upstreamSha, compact.integrated_upstream_sha)) {
|
|
643
|
+
throw new SyncError(`frozen target would regress the current checkpoint: ${upstreamSha} < ${compact.integrated_upstream_sha}`);
|
|
644
|
+
}
|
|
645
|
+
throw new SyncError(`frozen target diverges from the current checkpoint: ${upstreamSha} and ${compact.integrated_upstream_sha}`);
|
|
646
|
+
}
|
|
647
|
+
const timestamp = nowRfc3339();
|
|
648
|
+
pending.main_merge_sha = mergeSha;
|
|
649
|
+
pending.recorded_at = timestamp;
|
|
650
|
+
pending.verification = [...options.verification];
|
|
651
|
+
compact.integrated_upstream_sha = upstreamSha;
|
|
652
|
+
compact.main_merge_sha = mergeSha;
|
|
653
|
+
state.updated_at = timestamp;
|
|
654
|
+
atomicJson(changeFile, changeState);
|
|
655
|
+
atomicJson(stateFile, state);
|
|
656
|
+
return { repository: config.id, change, recorded_at: timestamp, merge_commit_sha: mergeSha, upstream_sha: upstreamSha };
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function parseArguments(argv) {
|
|
660
|
+
const command = argv[0];
|
|
661
|
+
if (!['assess', 'record-integration'].includes(command)) throw new SyncError('expected command: assess or record-integration');
|
|
662
|
+
const options = { root: '.', verification: [] };
|
|
663
|
+
const booleans = new Set(['fetch', 'dry-run']);
|
|
664
|
+
const allowed = new Set([
|
|
665
|
+
'root', 'state-root', 'repository-map', 'topic', 'date', 'fetch', 'dry-run', 'change',
|
|
666
|
+
'repository', 'merge-commit', 'upstream-sha', 'verification',
|
|
667
|
+
]);
|
|
668
|
+
for (let index = 1; index < argv.length; index += 1) {
|
|
669
|
+
const token = argv[index];
|
|
670
|
+
if (!token.startsWith('--')) throw new SyncError(`unexpected argument: ${token}`);
|
|
671
|
+
const key = token.slice(2);
|
|
672
|
+
if (!allowed.has(key)) throw new SyncError(`unknown option: --${key}`);
|
|
673
|
+
if (booleans.has(key)) options[key] = true;
|
|
674
|
+
else {
|
|
675
|
+
const value = argv[++index];
|
|
676
|
+
if (value === undefined || value.startsWith('--')) throw new SyncError(`missing value for --${key}`);
|
|
677
|
+
if (key === 'verification') options.verification.push(value);
|
|
678
|
+
else options[key] = value;
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
return { command, options };
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function help(command) {
|
|
685
|
+
if (command === 'assess') return `Usage:
|
|
686
|
+
upstream-sync.mjs assess --root <project> --state-root <path> --repository-map <file> --topic <topic> [options]
|
|
687
|
+
|
|
688
|
+
Options:
|
|
689
|
+
--date YYYY-MM-DD Override the change date for reproducible runs.
|
|
690
|
+
--fetch Refresh configured remote refs before assessment.
|
|
691
|
+
--dry-run Print the frozen JSON snapshot without writing state or reports.
|
|
692
|
+
|
|
693
|
+
Output:
|
|
694
|
+
Creates a non-overwriting change under --state-root and atomically updates state.json.
|
|
695
|
+
|
|
696
|
+
Example:
|
|
697
|
+
node upstream-sync.mjs assess --root . --state-root <resolved-state-root>/skills/upstream-fork-sync --repository-map <resolved-state-root>/skills/upstream-fork-sync/repository-map.json --topic upstream-refresh
|
|
698
|
+
`;
|
|
699
|
+
if (command === 'record-integration') return `Usage:
|
|
700
|
+
upstream-sync.mjs record-integration --root <project> --state-root <path> --repository-map <file> --repository <id> --merge-commit <sha> --upstream-sha <sha> --verification '<command>: exit 0' [--change <name>]
|
|
701
|
+
|
|
702
|
+
Output:
|
|
703
|
+
Updates the selected change and compact checkpoint only after exact Git ancestry validation.
|
|
704
|
+
|
|
705
|
+
Example:
|
|
706
|
+
node upstream-sync.mjs record-integration --root . --state-root <resolved-state-root>/skills/upstream-fork-sync --repository-map <resolved-state-root>/skills/upstream-fork-sync/repository-map.json --repository app --merge-commit <sha> --upstream-sha <sha> --verification 'pnpm test: exit 0'
|
|
707
|
+
`;
|
|
708
|
+
return `Usage:
|
|
709
|
+
upstream-sync.mjs assess [options]
|
|
710
|
+
upstream-sync.mjs record-integration [options]
|
|
711
|
+
|
|
712
|
+
Run "upstream-sync.mjs <command> --help" for command-specific inputs, outputs, and examples.
|
|
713
|
+
`;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
export function main(argv = process.argv.slice(2)) {
|
|
717
|
+
try {
|
|
718
|
+
if (argv.length === 1 && new Set(['--help', '-h']).has(argv[0])) {
|
|
719
|
+
process.stdout.write(help());
|
|
720
|
+
return 0;
|
|
721
|
+
}
|
|
722
|
+
if (argv.length === 2 && new Set(['--help', '-h']).has(argv[1])) {
|
|
723
|
+
if (!['assess', 'record-integration'].includes(argv[0])) throw new SyncError(`unknown command: ${argv[0]}`);
|
|
724
|
+
process.stdout.write(help(argv[0]));
|
|
725
|
+
return 0;
|
|
726
|
+
}
|
|
727
|
+
const { command, options } = parseArguments(argv);
|
|
728
|
+
if (command === 'assess') {
|
|
729
|
+
const output = assess(options);
|
|
730
|
+
process.stdout.write(`${JSON.stringify(output.result, null, output.dryRun ? 2 : 0)}\n`);
|
|
731
|
+
} else process.stdout.write(`${JSON.stringify(recordIntegration(options), null, 2)}\n`);
|
|
732
|
+
return 0;
|
|
733
|
+
} catch (error) {
|
|
734
|
+
process.stderr.write(`error: ${error.message}\n`);
|
|
735
|
+
return 2;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
const invoked = process.argv[1] ? resolve(process.argv[1]) : null;
|
|
740
|
+
if (invoked === fileURLToPath(import.meta.url)) process.exitCode = main();
|