@ikunin/sprintpilot 1.0.5 → 2.0.5
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 +48 -1
- package/_Sprintpilot/Sprintpilot.md +14 -1
- package/_Sprintpilot/manifest.yaml +1 -1
- package/_Sprintpilot/modules/autopilot/config.yaml +22 -0
- package/_Sprintpilot/modules/autopilot/profiles/_base.yaml +45 -0
- package/_Sprintpilot/modules/autopilot/profiles/large.yaml +22 -0
- package/_Sprintpilot/modules/autopilot/profiles/legacy.yaml +35 -0
- package/_Sprintpilot/modules/autopilot/profiles/medium.yaml +5 -0
- package/_Sprintpilot/modules/autopilot/profiles/nano.yaml +35 -0
- package/_Sprintpilot/modules/autopilot/profiles/small.yaml +5 -0
- package/_Sprintpilot/modules/git/config.yaml +8 -0
- package/_Sprintpilot/modules/ma/config.yaml +42 -0
- package/_Sprintpilot/scripts/agent-adapter.js +247 -0
- package/_Sprintpilot/scripts/cached-read.js +238 -0
- package/_Sprintpilot/scripts/check-prereqs.js +139 -0
- package/_Sprintpilot/scripts/dispatch-layer.js +192 -0
- package/_Sprintpilot/scripts/git-portable.js +219 -0
- package/_Sprintpilot/scripts/infer-dependencies.js +594 -0
- package/_Sprintpilot/scripts/inject-tasks-section.js +279 -0
- package/_Sprintpilot/scripts/list-remaining-stories.js +295 -0
- package/_Sprintpilot/scripts/log-timing.js +425 -0
- package/_Sprintpilot/scripts/mark-done-stories-tasks.js +254 -0
- package/_Sprintpilot/scripts/merge-shards.js +339 -0
- package/_Sprintpilot/scripts/preflight-merge.js +235 -0
- package/_Sprintpilot/scripts/resolve-dag.js +559 -0
- package/_Sprintpilot/scripts/resolve-profile.js +355 -0
- package/_Sprintpilot/scripts/state-shard.js +602 -0
- package/_Sprintpilot/scripts/submodule-lock.js +130 -0
- package/_Sprintpilot/scripts/summarize-timings.js +362 -0
- package/_Sprintpilot/scripts/sync-status.js +13 -0
- package/_Sprintpilot/scripts/with-retry.js +145 -0
- package/_Sprintpilot/skills/sprint-autopilot-on/workflow.md +572 -42
- package/bin/sprintpilot.js +4 -0
- package/lib/commands/install.js +157 -1
- package/package.json +1 -1
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// merge-shards.js — coordinator merge of per-story shards into the
|
|
4
|
+
// authoritative project-level state + decision-log YAMLs.
|
|
5
|
+
//
|
|
6
|
+
// Usage:
|
|
7
|
+
// merge-shards.js [--project-root <path>] [--layer <id>] [--archive]
|
|
8
|
+
// [--dry-run]
|
|
9
|
+
//
|
|
10
|
+
// Reads every shard in:
|
|
11
|
+
// _bmad-output/implementation-artifacts/.autopilot-state/<story>.yaml
|
|
12
|
+
// _bmad-output/implementation-artifacts/.decision-log/<story>.yaml
|
|
13
|
+
//
|
|
14
|
+
// Writes merged files:
|
|
15
|
+
// _bmad-output/implementation-artifacts/autopilot-state.yaml
|
|
16
|
+
// _bmad-output/implementation-artifacts/decision-log.yaml
|
|
17
|
+
//
|
|
18
|
+
// Merge rules:
|
|
19
|
+
// - State: keyed by story. Last writer wins per key using
|
|
20
|
+
// updated_at.monotonic (intra-process, NTP-safe) and falling back to
|
|
21
|
+
// updated_at.wall for cross-process tiebreaks.
|
|
22
|
+
// - Decision log: concatenate entries, sort by ts ascending, dedupe by id.
|
|
23
|
+
//
|
|
24
|
+
// Corruption recovery:
|
|
25
|
+
// A shard that fails to parse OR lacks updated_at is moved to
|
|
26
|
+
// .archive/corrupt/<story>-<ts>.yaml and a marker is appended to
|
|
27
|
+
// the merged state + decision log. Never deleted.
|
|
28
|
+
//
|
|
29
|
+
// Idempotent: merging twice produces the same result.
|
|
30
|
+
|
|
31
|
+
const fs = require('node:fs');
|
|
32
|
+
const path = require('node:path');
|
|
33
|
+
|
|
34
|
+
const { parseArgs } = require('../lib/runtime/args');
|
|
35
|
+
const log = require('../lib/runtime/log');
|
|
36
|
+
const shardMod = require('./state-shard.js');
|
|
37
|
+
|
|
38
|
+
const { yamlDump, yamlLoad, KIND_DIR, listShardStories, stripReservedKeys } = shardMod;
|
|
39
|
+
|
|
40
|
+
function help() {
|
|
41
|
+
log.out(
|
|
42
|
+
[
|
|
43
|
+
'Usage:',
|
|
44
|
+
' merge-shards.js [--project-root <path>] [--layer <id>] [--archive]',
|
|
45
|
+
' [--dry-run]',
|
|
46
|
+
'',
|
|
47
|
+
' --layer <id> Archive subdirectory name when --archive is set.',
|
|
48
|
+
' --archive Move merged shards to .archive/layer-<id>/ after merge.',
|
|
49
|
+
' --dry-run Compute the merge but do not write files.',
|
|
50
|
+
].join('\n'),
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function implArtifactsDir(projectRoot) {
|
|
55
|
+
return path.join(projectRoot, '_bmad-output', 'implementation-artifacts');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function readShardFile(file) {
|
|
59
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
60
|
+
return yamlLoad(raw);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isValidShard(shard) {
|
|
64
|
+
if (!shard || typeof shard !== 'object' || Array.isArray(shard)) return false;
|
|
65
|
+
if (!shard.updated_at) return false;
|
|
66
|
+
// updated_at may be an object {wall, monotonic} — require at least wall.
|
|
67
|
+
if (typeof shard.updated_at !== 'object') return false;
|
|
68
|
+
if (!shard.updated_at.wall) return false;
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function compareStamps(a, b) {
|
|
73
|
+
// Prefer monotonic when both sides have one AND we believe they came
|
|
74
|
+
// from the same process (same-string arithmetic). Monotonic numbers
|
|
75
|
+
// across different processes are meaningless, so if one side is
|
|
76
|
+
// missing we fall back to wall-clock.
|
|
77
|
+
if (a && b && a.monotonic && b.monotonic) {
|
|
78
|
+
try {
|
|
79
|
+
const ai = BigInt(a.monotonic);
|
|
80
|
+
const bi = BigInt(b.monotonic);
|
|
81
|
+
if (ai > bi) return 1;
|
|
82
|
+
if (ai < bi) return -1;
|
|
83
|
+
} catch {
|
|
84
|
+
// fall through to wall
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const aw = a && a.wall ? Date.parse(a.wall) : 0;
|
|
88
|
+
const bw = b && b.wall ? Date.parse(b.wall) : 0;
|
|
89
|
+
if (aw > bw) return 1;
|
|
90
|
+
if (aw < bw) return -1;
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function mergeStateShards(projectRoot) {
|
|
95
|
+
// Returns { byStory: { [storyKey]: shard }, corrupt: [...], invalid: [...] }
|
|
96
|
+
const dir = path.join(implArtifactsDir(projectRoot), KIND_DIR.state);
|
|
97
|
+
if (!fs.existsSync(dir)) return { byStory: {}, corrupt: [], invalid: [] };
|
|
98
|
+
const stories = listShardStories(projectRoot, 'state');
|
|
99
|
+
const byStory = {};
|
|
100
|
+
const corrupt = [];
|
|
101
|
+
const invalid = [];
|
|
102
|
+
for (const story of stories) {
|
|
103
|
+
const file = path.join(dir, `${story}.yaml`);
|
|
104
|
+
let shard;
|
|
105
|
+
try {
|
|
106
|
+
shard = readShardFile(file);
|
|
107
|
+
} catch (e) {
|
|
108
|
+
corrupt.push({ story, file, error: e.message });
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (!isValidShard(shard)) {
|
|
112
|
+
invalid.push({ story, file, reason: 'missing updated_at or bad shape' });
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
byStory[story] = shard;
|
|
116
|
+
}
|
|
117
|
+
return { byStory, corrupt, invalid };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function mergeDecisionShards(projectRoot) {
|
|
121
|
+
const dir = path.join(implArtifactsDir(projectRoot), KIND_DIR['decision-log']);
|
|
122
|
+
if (!fs.existsSync(dir)) return { entries: [], corrupt: [], invalid: [] };
|
|
123
|
+
const stories = listShardStories(projectRoot, 'decision-log');
|
|
124
|
+
const entries = [];
|
|
125
|
+
const corrupt = [];
|
|
126
|
+
const invalid = [];
|
|
127
|
+
for (const story of stories) {
|
|
128
|
+
const file = path.join(dir, `${story}.yaml`);
|
|
129
|
+
let shard;
|
|
130
|
+
try {
|
|
131
|
+
shard = readShardFile(file);
|
|
132
|
+
} catch (e) {
|
|
133
|
+
corrupt.push({ story, file, error: e.message });
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (!isValidShard(shard)) {
|
|
137
|
+
invalid.push({ story, file, reason: 'missing updated_at or bad shape' });
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const items = Array.isArray(shard.entries) ? shard.entries : [];
|
|
141
|
+
for (const item of items) {
|
|
142
|
+
if (!item || typeof item !== 'object') continue;
|
|
143
|
+
entries.push({ ...item, _story: story });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// Dedupe by id (if present), otherwise keep all. Sort by ts ascending.
|
|
147
|
+
const seen = new Set();
|
|
148
|
+
const deduped = [];
|
|
149
|
+
for (const e of entries) {
|
|
150
|
+
if (e.id !== undefined && e.id !== null && seen.has(String(e.id))) continue;
|
|
151
|
+
if (e.id !== undefined && e.id !== null) seen.add(String(e.id));
|
|
152
|
+
deduped.push(e);
|
|
153
|
+
}
|
|
154
|
+
deduped.sort((a, b) => {
|
|
155
|
+
const aw = a.ts ? Date.parse(a.ts) : 0;
|
|
156
|
+
const bw = b.ts ? Date.parse(b.ts) : 0;
|
|
157
|
+
if (aw !== bw) return aw - bw;
|
|
158
|
+
// Tiebreak alphabetically by id then story for determinism.
|
|
159
|
+
const ai = a.id !== undefined ? String(a.id) : '';
|
|
160
|
+
const bi = b.id !== undefined ? String(b.id) : '';
|
|
161
|
+
if (ai !== bi) return ai < bi ? -1 : 1;
|
|
162
|
+
return (a._story || '').localeCompare(b._story || '');
|
|
163
|
+
});
|
|
164
|
+
return { entries: deduped.map((e) => {
|
|
165
|
+
const { _story, ...rest } = e;
|
|
166
|
+
return rest;
|
|
167
|
+
}), corrupt, invalid };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function archiveCorrupt(projectRoot, kind, story, file, reason) {
|
|
171
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
172
|
+
const dest = path.join(
|
|
173
|
+
implArtifactsDir(projectRoot),
|
|
174
|
+
'.archive',
|
|
175
|
+
'corrupt',
|
|
176
|
+
`${KIND_DIR[kind]}-${story}-${ts}.yaml`,
|
|
177
|
+
);
|
|
178
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
179
|
+
try {
|
|
180
|
+
fs.renameSync(file, dest);
|
|
181
|
+
} catch {
|
|
182
|
+
// If rename crosses FS (rare — same dir tree here), fall back to copy+unlink.
|
|
183
|
+
fs.copyFileSync(file, dest);
|
|
184
|
+
fs.unlinkSync(file);
|
|
185
|
+
}
|
|
186
|
+
return { archived: dest, reason };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function archiveShardsToLayer(projectRoot, layerId, storyKeys) {
|
|
190
|
+
const ts = layerId || new Date().toISOString().replace(/[:.]/g, '-');
|
|
191
|
+
const base = path.join(implArtifactsDir(projectRoot), '.archive', `layer-${ts}`);
|
|
192
|
+
fs.mkdirSync(base, { recursive: true });
|
|
193
|
+
for (const kind of ['state', 'decision-log']) {
|
|
194
|
+
const src = path.join(implArtifactsDir(projectRoot), KIND_DIR[kind]);
|
|
195
|
+
if (!fs.existsSync(src)) continue;
|
|
196
|
+
const destDir = path.join(base, KIND_DIR[kind]);
|
|
197
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
198
|
+
for (const story of storyKeys) {
|
|
199
|
+
const file = path.join(src, `${story}.yaml`);
|
|
200
|
+
if (!fs.existsSync(file)) continue;
|
|
201
|
+
const dest = path.join(destDir, `${story}.yaml`);
|
|
202
|
+
try {
|
|
203
|
+
fs.renameSync(file, dest);
|
|
204
|
+
} catch {
|
|
205
|
+
fs.copyFileSync(file, dest);
|
|
206
|
+
fs.unlinkSync(file);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return base;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function writeAuthoritative(projectRoot, filename, body, { dryRun } = {}) {
|
|
214
|
+
const file = path.join(implArtifactsDir(projectRoot), filename);
|
|
215
|
+
if (dryRun) return { file, wrote: false };
|
|
216
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
217
|
+
const tmp = `${file}.tmp.${process.pid}.${process.hrtime.bigint().toString(36)}`;
|
|
218
|
+
fs.writeFileSync(tmp, body);
|
|
219
|
+
fs.renameSync(tmp, file);
|
|
220
|
+
return { file, wrote: true };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function composeStateYaml(stateMerge) {
|
|
224
|
+
// Flatten to one top-level `stories:` key carrying each story's shard
|
|
225
|
+
// payload (minus the reserved keys story/schema_version/updated_at).
|
|
226
|
+
// Also preserve updated_at per story so future merges pick the newer
|
|
227
|
+
// entry if shards conflict.
|
|
228
|
+
const stories = {};
|
|
229
|
+
for (const key of Object.keys(stateMerge.byStory).sort()) {
|
|
230
|
+
const shard = stateMerge.byStory[key];
|
|
231
|
+
stories[key] = {
|
|
232
|
+
updated_at: shard.updated_at,
|
|
233
|
+
...stripReservedKeys(shard),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
const doc = {
|
|
237
|
+
schema_version: 1,
|
|
238
|
+
merged_at: new Date().toISOString(),
|
|
239
|
+
stories,
|
|
240
|
+
};
|
|
241
|
+
if (stateMerge.corrupt.length + stateMerge.invalid.length > 0) {
|
|
242
|
+
doc.shard_problems = [
|
|
243
|
+
...stateMerge.corrupt.map((c) => ({ story: c.story, kind: 'parse-error', detail: c.error })),
|
|
244
|
+
...stateMerge.invalid.map((c) => ({ story: c.story, kind: 'invalid-shape', detail: c.reason })),
|
|
245
|
+
];
|
|
246
|
+
}
|
|
247
|
+
return `${yamlDump(doc)}\n`;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function composeDecisionYaml(decisionMerge) {
|
|
251
|
+
const doc = {
|
|
252
|
+
schema_version: 1,
|
|
253
|
+
merged_at: new Date().toISOString(),
|
|
254
|
+
entries: decisionMerge.entries,
|
|
255
|
+
};
|
|
256
|
+
if (decisionMerge.corrupt.length + decisionMerge.invalid.length > 0) {
|
|
257
|
+
doc.shard_problems = [
|
|
258
|
+
...decisionMerge.corrupt.map((c) => ({ story: c.story, kind: 'parse-error', detail: c.error })),
|
|
259
|
+
...decisionMerge.invalid.map((c) => ({ story: c.story, kind: 'invalid-shape', detail: c.reason })),
|
|
260
|
+
];
|
|
261
|
+
}
|
|
262
|
+
return `${yamlDump(doc)}\n`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function merge(projectRoot, { layerId, archive, dryRun } = {}) {
|
|
266
|
+
const state = mergeStateShards(projectRoot);
|
|
267
|
+
const decisions = mergeDecisionShards(projectRoot);
|
|
268
|
+
|
|
269
|
+
// Archive corrupt shards before writing merged files so subsequent
|
|
270
|
+
// merges don't re-surface the same errors.
|
|
271
|
+
const archivedCorrupt = [];
|
|
272
|
+
if (!dryRun) {
|
|
273
|
+
for (const c of state.corrupt.concat(state.invalid)) {
|
|
274
|
+
const arch = archiveCorrupt(projectRoot, 'state', c.story, c.file, c.error || c.reason);
|
|
275
|
+
archivedCorrupt.push({ kind: 'state', story: c.story, ...arch });
|
|
276
|
+
}
|
|
277
|
+
for (const c of decisions.corrupt.concat(decisions.invalid)) {
|
|
278
|
+
const arch = archiveCorrupt(projectRoot, 'decision-log', c.story, c.file, c.error || c.reason);
|
|
279
|
+
archivedCorrupt.push({ kind: 'decision-log', story: c.story, ...arch });
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const stateBody = composeStateYaml(state);
|
|
284
|
+
const decisionBody = composeDecisionYaml(decisions);
|
|
285
|
+
|
|
286
|
+
const stateWrite = writeAuthoritative(projectRoot, 'autopilot-state.yaml', stateBody, { dryRun });
|
|
287
|
+
const decisionWrite = writeAuthoritative(projectRoot, 'decision-log.yaml', decisionBody, { dryRun });
|
|
288
|
+
|
|
289
|
+
let archiveDir = null;
|
|
290
|
+
if (archive && !dryRun) {
|
|
291
|
+
const storyKeys = Object.keys(state.byStory);
|
|
292
|
+
archiveDir = archiveShardsToLayer(projectRoot, layerId, storyKeys);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return {
|
|
296
|
+
state: { stories: Object.keys(state.byStory).length, problems: state.corrupt.length + state.invalid.length },
|
|
297
|
+
decisions: { entries: decisions.entries.length, problems: decisions.corrupt.length + decisions.invalid.length },
|
|
298
|
+
files: { state: stateWrite.file, decisions: decisionWrite.file },
|
|
299
|
+
archived_corrupt: archivedCorrupt,
|
|
300
|
+
archive_dir: archiveDir,
|
|
301
|
+
dry_run: !!dryRun,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function main() {
|
|
306
|
+
const { opts } = parseArgs(process.argv.slice(2), { booleanFlags: ['archive', 'dry-run'] });
|
|
307
|
+
if (opts.help) {
|
|
308
|
+
help();
|
|
309
|
+
process.exit(0);
|
|
310
|
+
}
|
|
311
|
+
const projectRoot = opts['project-root'] || process.cwd();
|
|
312
|
+
const layerId = opts.layer || null;
|
|
313
|
+
const archive = opts.archive === true;
|
|
314
|
+
const dryRun = opts['dry-run'] === true;
|
|
315
|
+
|
|
316
|
+
try {
|
|
317
|
+
const result = merge(projectRoot, { layerId, archive, dryRun });
|
|
318
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
319
|
+
} catch (e) {
|
|
320
|
+
log.error(`merge failed: ${e.message}`);
|
|
321
|
+
process.exit(1);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
module.exports = {
|
|
326
|
+
mergeStateShards,
|
|
327
|
+
mergeDecisionShards,
|
|
328
|
+
compareStamps,
|
|
329
|
+
isValidShard,
|
|
330
|
+
merge,
|
|
331
|
+
composeStateYaml,
|
|
332
|
+
composeDecisionYaml,
|
|
333
|
+
archiveShardsToLayer,
|
|
334
|
+
archiveCorrupt,
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
if (require.main === module) {
|
|
338
|
+
main();
|
|
339
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// preflight-merge.js — dry-run merge conflict detection for cross-epic
|
|
4
|
+
// parallel execution (PR 12, experimental).
|
|
5
|
+
//
|
|
6
|
+
// Usage:
|
|
7
|
+
// preflight-merge.js --epics <id,id,...> --base <branch>
|
|
8
|
+
// [--project-root <path>] [--branch-prefix <str>]
|
|
9
|
+
// [--lock-timeout-sec <n>]
|
|
10
|
+
//
|
|
11
|
+
// For every pair of epic branches (epic-<id>), attempts a no-commit
|
|
12
|
+
// dry-run merge against --base. If both merges succeed (no conflicts),
|
|
13
|
+
// the pair is "safe". Otherwise it's recorded as a conflict pair.
|
|
14
|
+
//
|
|
15
|
+
// Safety rails:
|
|
16
|
+
// 1. Lock acquisition via lock.js --file .sprintpilot/preflight.lock.
|
|
17
|
+
// Only one preflight runs at a time; default timeout 60s.
|
|
18
|
+
// 2. Startup cleanup — if __sprintpilot_preflight branch exists from a
|
|
19
|
+
// prior crashed run, force-delete it before proceeding.
|
|
20
|
+
// 3. Every merge attempt is followed by `git merge --abort` regardless
|
|
21
|
+
// of outcome, ensuring the base branch is never left mid-merge.
|
|
22
|
+
// 4. Try/finally release the lock even on fatal errors.
|
|
23
|
+
//
|
|
24
|
+
// Output (stdout, JSON):
|
|
25
|
+
// { safe_pairs: [["1","3"], ...], conflict_pairs: [["2","4"], ...] }
|
|
26
|
+
|
|
27
|
+
const fs = require('node:fs');
|
|
28
|
+
const path = require('node:path');
|
|
29
|
+
const { spawnSync } = require('node:child_process');
|
|
30
|
+
|
|
31
|
+
const { parseArgs } = require('../lib/runtime/args');
|
|
32
|
+
const log = require('../lib/runtime/log');
|
|
33
|
+
|
|
34
|
+
const PREFLIGHT_BRANCH = '__sprintpilot_preflight';
|
|
35
|
+
const DEFAULT_LOCK_TIMEOUT_SEC = 60;
|
|
36
|
+
const LOCK_PATH_REL = path.join('.sprintpilot', 'preflight.lock');
|
|
37
|
+
|
|
38
|
+
function help() {
|
|
39
|
+
log.out(
|
|
40
|
+
[
|
|
41
|
+
'Usage:',
|
|
42
|
+
' preflight-merge.js --epics <id,id,...> --base <branch>',
|
|
43
|
+
' [--project-root <path>] [--branch-prefix <str>]',
|
|
44
|
+
' [--lock-timeout-sec <n>]',
|
|
45
|
+
].join('\n'),
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parseEpics(raw) {
|
|
50
|
+
if (!raw) return { ok: false, error: '--epics is required' };
|
|
51
|
+
const ids = String(raw)
|
|
52
|
+
.split(',')
|
|
53
|
+
.map((s) => s.trim())
|
|
54
|
+
.filter(Boolean);
|
|
55
|
+
if (ids.length < 2) {
|
|
56
|
+
return { ok: false, error: '--epics must contain at least two IDs' };
|
|
57
|
+
}
|
|
58
|
+
for (const id of ids) {
|
|
59
|
+
if (!/^[a-z0-9][a-z0-9-]*$/i.test(id)) {
|
|
60
|
+
return { ok: false, error: `invalid epic id '${id}': must match ^[a-z0-9][a-z0-9-]*$` };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return { ok: true, value: ids };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function pairs(arr) {
|
|
67
|
+
const out = [];
|
|
68
|
+
for (let i = 0; i < arr.length; i++) {
|
|
69
|
+
for (let j = i + 1; j < arr.length; j++) out.push([arr[i], arr[j]]);
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function git(projectRoot, args, { allowFail = false } = {}) {
|
|
75
|
+
const res = spawnSync('git', ['-C', projectRoot, ...args], {
|
|
76
|
+
encoding: 'utf8',
|
|
77
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
78
|
+
});
|
|
79
|
+
if (!allowFail && res.status !== 0) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
`git ${args.join(' ')} failed (status ${res.status}):\n${(res.stderr || '').trim()}`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
status: res.status === null ? 1 : res.status,
|
|
86
|
+
stdout: (res.stdout || '').trim(),
|
|
87
|
+
stderr: (res.stderr || '').trim(),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function acquireLock(projectRoot, timeoutSec) {
|
|
92
|
+
const lockFile = path.join(projectRoot, LOCK_PATH_REL);
|
|
93
|
+
fs.mkdirSync(path.dirname(lockFile), { recursive: true });
|
|
94
|
+
const lockScript = path.join(__dirname, 'lock.js');
|
|
95
|
+
const deadline = Date.now() + Math.max(1, timeoutSec) * 1000;
|
|
96
|
+
// Single attempt with a few retries inside the timeout window — kept
|
|
97
|
+
// synchronous with a tight busy-wait via spawnSync sleep.
|
|
98
|
+
while (Date.now() < deadline) {
|
|
99
|
+
const res = spawnSync(
|
|
100
|
+
process.execPath,
|
|
101
|
+
[lockScript, 'acquire', '--file', lockFile, '--stale-minutes', '30'],
|
|
102
|
+
{ encoding: 'utf8' },
|
|
103
|
+
);
|
|
104
|
+
const stdout = (res.stdout || '').trim();
|
|
105
|
+
if (res.status === 0 && stdout.startsWith('ACQUIRED')) {
|
|
106
|
+
return lockFile;
|
|
107
|
+
}
|
|
108
|
+
// Brief pause before retrying.
|
|
109
|
+
spawnSync(process.execPath, ['-e', 'setTimeout(()=>process.exit(0), 200)'], {
|
|
110
|
+
stdio: 'ignore',
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
throw new Error(`preflight lock not acquired within ${timeoutSec}s (held by another preflight)`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function releaseLock(lockFile) {
|
|
117
|
+
if (!lockFile) return;
|
|
118
|
+
const lockScript = path.join(__dirname, 'lock.js');
|
|
119
|
+
spawnSync(process.execPath, [lockScript, 'release', '--file', lockFile], {
|
|
120
|
+
encoding: 'utf8',
|
|
121
|
+
stdio: 'ignore',
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function startupCleanup(projectRoot) {
|
|
126
|
+
// Refuse to run if HEAD is already on the preflight branch (paranoia).
|
|
127
|
+
const head = git(projectRoot, ['rev-parse', '--abbrev-ref', 'HEAD'], { allowFail: true });
|
|
128
|
+
if (head.stdout === PREFLIGHT_BRANCH) {
|
|
129
|
+
throw new Error(`refusing to run preflight: HEAD is on ${PREFLIGHT_BRANCH}. Switch first.`);
|
|
130
|
+
}
|
|
131
|
+
// Delete stale preflight branch if present.
|
|
132
|
+
const exists = git(projectRoot, ['rev-parse', '--verify', PREFLIGHT_BRANCH], { allowFail: true });
|
|
133
|
+
if (exists.status === 0) {
|
|
134
|
+
git(projectRoot, ['branch', '-D', PREFLIGHT_BRANCH], { allowFail: true });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function tryMergePair(projectRoot, base, branchA, branchB) {
|
|
139
|
+
// Fresh preflight branch off base.
|
|
140
|
+
git(projectRoot, ['checkout', '-B', PREFLIGHT_BRANCH, base]);
|
|
141
|
+
try {
|
|
142
|
+
// Merge A first (producing a merge commit). `--no-edit` keeps the
|
|
143
|
+
// default message; a conflict leaves files in the index and we abort.
|
|
144
|
+
const m1 = git(projectRoot, ['merge', '--no-ff', '--no-edit', branchA], { allowFail: true });
|
|
145
|
+
if (m1.status !== 0) {
|
|
146
|
+
git(projectRoot, ['merge', '--abort'], { allowFail: true });
|
|
147
|
+
return { safe: false, conflict_at: branchA, detail: m1.stderr };
|
|
148
|
+
}
|
|
149
|
+
// Now merge B on top of the A-merged preflight.
|
|
150
|
+
const m2 = git(projectRoot, ['merge', '--no-ff', '--no-edit', branchB], { allowFail: true });
|
|
151
|
+
if (m2.status !== 0) {
|
|
152
|
+
git(projectRoot, ['merge', '--abort'], { allowFail: true });
|
|
153
|
+
return { safe: false, conflict_at: branchB, detail: m2.stderr };
|
|
154
|
+
}
|
|
155
|
+
return { safe: true };
|
|
156
|
+
} finally {
|
|
157
|
+
// Always return to base and drop the preflight branch (force-delete
|
|
158
|
+
// because it contains commits that aren't on base).
|
|
159
|
+
git(projectRoot, ['checkout', base], { allowFail: true });
|
|
160
|
+
git(projectRoot, ['branch', '-D', PREFLIGHT_BRANCH], { allowFail: true });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function preflight({ projectRoot, epics, base, branchPrefix, lockTimeoutSec }) {
|
|
165
|
+
const lock = acquireLock(projectRoot, lockTimeoutSec);
|
|
166
|
+
const safe_pairs = [];
|
|
167
|
+
const conflict_pairs = [];
|
|
168
|
+
try {
|
|
169
|
+
startupCleanup(projectRoot);
|
|
170
|
+
for (const [a, b] of pairs(epics)) {
|
|
171
|
+
const bA = `${branchPrefix}epic-${a}`;
|
|
172
|
+
const bB = `${branchPrefix}epic-${b}`;
|
|
173
|
+
const res = tryMergePair(projectRoot, base, bA, bB);
|
|
174
|
+
if (res.safe) safe_pairs.push([a, b]);
|
|
175
|
+
else conflict_pairs.push([a, b]);
|
|
176
|
+
}
|
|
177
|
+
} finally {
|
|
178
|
+
releaseLock(lock);
|
|
179
|
+
}
|
|
180
|
+
return { safe_pairs, conflict_pairs, checked: pairs(epics).length };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function main() {
|
|
184
|
+
const { opts } = parseArgs(process.argv.slice(2));
|
|
185
|
+
if (opts.help) {
|
|
186
|
+
help();
|
|
187
|
+
process.exit(0);
|
|
188
|
+
}
|
|
189
|
+
const epics = parseEpics(opts.epics);
|
|
190
|
+
if (!epics.ok) {
|
|
191
|
+
log.error(epics.error);
|
|
192
|
+
process.exit(1);
|
|
193
|
+
}
|
|
194
|
+
if (!opts.base) {
|
|
195
|
+
log.error('--base is required');
|
|
196
|
+
process.exit(1);
|
|
197
|
+
}
|
|
198
|
+
const projectRoot = opts['project-root'] || process.cwd();
|
|
199
|
+
const branchPrefix = opts['branch-prefix'] !== undefined ? String(opts['branch-prefix']) : 'story/';
|
|
200
|
+
const timeout = opts['lock-timeout-sec']
|
|
201
|
+
? Number.parseInt(String(opts['lock-timeout-sec']), 10)
|
|
202
|
+
: DEFAULT_LOCK_TIMEOUT_SEC;
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
const result = preflight({
|
|
206
|
+
projectRoot,
|
|
207
|
+
epics: epics.value,
|
|
208
|
+
base: String(opts.base),
|
|
209
|
+
branchPrefix,
|
|
210
|
+
lockTimeoutSec: timeout,
|
|
211
|
+
});
|
|
212
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
213
|
+
process.exit(0);
|
|
214
|
+
} catch (e) {
|
|
215
|
+
log.error(`preflight failed: ${e.message}`);
|
|
216
|
+
process.exit(1);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
module.exports = {
|
|
221
|
+
PREFLIGHT_BRANCH,
|
|
222
|
+
DEFAULT_LOCK_TIMEOUT_SEC,
|
|
223
|
+
LOCK_PATH_REL,
|
|
224
|
+
parseEpics,
|
|
225
|
+
pairs,
|
|
226
|
+
preflight,
|
|
227
|
+
tryMergePair,
|
|
228
|
+
startupCleanup,
|
|
229
|
+
acquireLock,
|
|
230
|
+
releaseLock,
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
if (require.main === module) {
|
|
234
|
+
main();
|
|
235
|
+
}
|