ravensight-playtest 0.5.2 → 0.6.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/README.md +249 -14
- package/addons/ravensight_driver/driver.gd +406 -8
- package/addons/ravensight_simulate/simulate.gd +360 -0
- package/docs/godot-driver.md +70 -8
- package/package.json +4 -3
- package/src/api/README.md +4 -1
- package/src/api/client.js +92 -3
- package/src/api/errors.js +17 -1
- package/src/api/http.js +43 -17
- package/src/assets/formats.js +267 -0
- package/src/assets/index.js +260 -0
- package/src/assets/integration.js +429 -0
- package/src/assets/inventory.js +337 -0
- package/src/assets/measure.js +392 -0
- package/src/cli.js +165 -0
- package/src/commands/assets.js +62 -0
- package/src/commands/check.js +4 -1
- package/src/commands/create.js +734 -0
- package/src/commands/fakeCore.js +16 -1
- package/src/commands/feature.js +845 -0
- package/src/commands/fix.js +578 -68
- package/src/commands/init.js +19 -3
- package/src/commands/profile.js +25 -6
- package/src/commands/replay.js +204 -0
- package/src/commands/resume.js +7 -0
- package/src/commands/run.js +200 -24
- package/src/commands/template.js +70 -0
- package/src/commands/validate.js +414 -0
- package/src/commands/validateHosted.js +292 -0
- package/src/create/scaffold.js +192 -0
- package/src/run/acceptance.js +212 -0
- package/src/run/args.js +18 -1
- package/src/run/checks.js +705 -0
- package/src/run/dimension.js +95 -0
- package/src/run/drivers/godot-observation.js +59 -11
- package/src/run/drivers/godot-project.js +201 -31
- package/src/run/drivers/godot.js +532 -45
- package/src/run/drivers/web.js +18 -0
- package/src/run/evidence.js +170 -0
- package/src/run/featureLoop.js +288 -0
- package/src/run/fixLoop.js +82 -12
- package/src/run/git.js +110 -20
- package/src/run/json.js +65 -9
- package/src/run/labsOutcome.js +136 -0
- package/src/run/liveWork.js +103 -0
- package/src/run/model.js +105 -5
- package/src/run/openPr.js +107 -0
- package/src/run/paths.js +4 -1
- package/src/run/pendingRegister.js +81 -0
- package/src/run/personaLoop.js +111 -4
- package/src/run/profile.js +68 -7
- package/src/run/replay.js +236 -0
- package/src/run/repoTools.js +227 -27
- package/src/run/report.js +7 -1
- package/src/run/resume.js +19 -1
- package/src/run/secretScan.js +35 -0
- package/src/run/settle.js +41 -0
- package/src/run/tools.js +33 -4
- package/src/run/trace.js +124 -0
- package/src/simulation/godotPack.js +112 -0
- package/src/ui/index.js +61 -9
- package/src/upload/allowlist.js +6 -2
- package/src/upload/index.js +5 -4
- package/templates/godot-2d/README.md +36 -0
- package/templates/godot-2d/addons/ravensight/LICENSE +21 -0
- package/templates/godot-2d/addons/ravensight/Ravensight.gd +1823 -0
- package/templates/godot-2d/export_presets.cfg +27 -0
- package/templates/godot-2d/gitignore +10 -0
- package/templates/godot-2d/project.godot +58 -0
- package/templates/godot-2d/scenes/main.tscn +67 -0
- package/templates/godot-2d/scripts/autoload/game_config.gd +83 -0
- package/templates/godot-2d/scripts/autoload/game_state.gd +44 -0
- package/templates/godot-2d/scripts/autoload/telemetry.gd +37 -0
- package/templates/godot-2d/scripts/main.gd +32 -0
- package/templates/godot-2d/scripts/player.gd +10 -0
- package/templates/godot-2d/scripts/state_hook.gd +16 -0
- package/templates/godot-2d/template.json +14 -0
- package/templates/godot-2d/tools/inject_release_key.sh +19 -0
- package/templates/godot-2d/tools/selftest.gd +70 -0
- package/templates/godot-3d/README.md +49 -0
- package/templates/godot-3d/addons/ravensight/LICENSE +21 -0
- package/templates/godot-3d/addons/ravensight/Ravensight.gd +1823 -0
- package/templates/godot-3d/export_presets.cfg +27 -0
- package/templates/godot-3d/gitignore +10 -0
- package/templates/godot-3d/project.godot +56 -0
- package/templates/godot-3d/scenes/main.tscn +154 -0
- package/templates/godot-3d/scripts/autoload/game_config.gd +83 -0
- package/templates/godot-3d/scripts/autoload/game_state.gd +44 -0
- package/templates/godot-3d/scripts/autoload/telemetry.gd +37 -0
- package/templates/godot-3d/scripts/beacon.gd +20 -0
- package/templates/godot-3d/scripts/main.gd +49 -0
- package/templates/godot-3d/scripts/player.gd +19 -0
- package/templates/godot-3d/scripts/state_hook.gd +25 -0
- package/templates/godot-3d/template.json +16 -0
- package/templates/godot-3d/tools/inject_release_key.sh +19 -0
- package/templates/godot-3d/tools/selftest.gd +77 -0
package/src/run/profile.js
CHANGED
|
@@ -6,7 +6,7 @@ import { callStepWithRetry, STEPS } from './model.js';
|
|
|
6
6
|
import { JOB_FILES } from './paths.js';
|
|
7
7
|
import { parseJsonObject } from './json.js';
|
|
8
8
|
import { createRepoTools, PROFILE_TOOLS, READ_ONLY_TOOLS } from './repoTools.js';
|
|
9
|
-
import { assertNoSecrets } from './secretScan.js';
|
|
9
|
+
import { assertNoSecrets, redactSecrets } from './secretScan.js';
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* The `game_profile` module: read the developer's repository and answer what
|
|
@@ -38,6 +38,12 @@ export const MAX_TRIAGE_TURNS = 40;
|
|
|
38
38
|
export const TRIAGE_MAX_TOKENS = 1024;
|
|
39
39
|
export const SYNTHESIS_MAX_TOKENS = 4096;
|
|
40
40
|
|
|
41
|
+
/** Files the synthesis step may still read before it has to answer. */
|
|
42
|
+
export const MAX_SYNTHESIS_TOOL_TURNS = 3;
|
|
43
|
+
|
|
44
|
+
/** Where a failed synthesis leaves its raw answers, in the job directory. */
|
|
45
|
+
export const PROFILE_FAILURE_FILE = 'capability-report.failed.txt';
|
|
46
|
+
|
|
41
47
|
/**
|
|
42
48
|
* @param {Array<{name: string, content: Object}>} schemas
|
|
43
49
|
*/
|
|
@@ -159,7 +165,10 @@ export async function runGameProfile({
|
|
|
159
165
|
|
|
160
166
|
let report = null;
|
|
161
167
|
let lint = [];
|
|
162
|
-
|
|
168
|
+
// Every synthesis answer, kept so a failed profile leaves something to read.
|
|
169
|
+
const rawAnswers = [];
|
|
170
|
+
let toolTurns = 0;
|
|
171
|
+
for (let attempt = 1; attempt <= 2;) {
|
|
163
172
|
const answer = await callStepWithRetry({
|
|
164
173
|
client,
|
|
165
174
|
jobId,
|
|
@@ -171,12 +180,36 @@ export async function runGameProfile({
|
|
|
171
180
|
maxTokens: SYNTHESIS_MAX_TOKENS
|
|
172
181
|
});
|
|
173
182
|
if (ledger) ledger.record({ model: answer.model, usage: answer.usage });
|
|
183
|
+
rawAnswers.push({ attempt, stopReason: answer.stopReason || null, text: answer.text || '', tools: answer.toolUses.map(toolUse => toolUse.name) });
|
|
184
|
+
|
|
185
|
+
// The step is given read_file, so it may use it. An answer that reads a
|
|
186
|
+
// file instead of writing the report used to be judged as a report with
|
|
187
|
+
// no JSON in it, twice. Read it, hand the file back, and ask again, a
|
|
188
|
+
// bounded number of times, without spending the repair.
|
|
189
|
+
if (answer.toolUses.length > 0 && toolTurns < MAX_SYNTHESIS_TOOL_TURNS && !parseJsonObject(answer.text)) {
|
|
190
|
+
toolTurns += 1;
|
|
191
|
+
messages.push({
|
|
192
|
+
role: 'assistant',
|
|
193
|
+
content: [
|
|
194
|
+
...(answer.text.trim() ? [{ type: 'text', text: answer.text }] : []),
|
|
195
|
+
...answer.toolUses.map(toolUse => ({ type: 'tool_use', id: toolUse.id, name: toolUse.name, input: toolUse.malformed ? {} : toolUse.input }))
|
|
196
|
+
]
|
|
197
|
+
});
|
|
198
|
+
const results = [];
|
|
199
|
+
for (const toolUse of answer.toolUses) results.push((await repo.execute(toolUse)).result);
|
|
200
|
+
messages.push({
|
|
201
|
+
role: 'user',
|
|
202
|
+
content: [...results, { type: 'text', text: toolTurns >= MAX_SYNTHESIS_TOOL_TURNS ? 'That was the last file. Answer now with the capability report JSON object only.' : 'Answer with the capability report JSON object only when you have what you need.' }]
|
|
203
|
+
});
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
174
206
|
|
|
175
207
|
const parsed = parseJsonObject(answer.text);
|
|
176
208
|
const candidate = parsed ? { ...parsed, schema_version: parsed.schema_version || '1.0', job_id: jobId } : null;
|
|
209
|
+
const cutOff = answer.stopReason === 'max_tokens';
|
|
177
210
|
const validation = candidate
|
|
178
211
|
? validator.validate(candidate)
|
|
179
|
-
: { ok: false, details: [{ instancePath: '/', message: 'the answer was not a JSON object' }] };
|
|
212
|
+
: { ok: false, details: [{ instancePath: '/', message: cutOff ? `the answer was cut off at the output limit (${SYNTHESIS_MAX_TOKENS} tokens) before the JSON object closed` : 'the answer was not a JSON object' }] };
|
|
180
213
|
|
|
181
214
|
if (validation.ok) {
|
|
182
215
|
report = candidate;
|
|
@@ -184,13 +217,20 @@ export async function runGameProfile({
|
|
|
184
217
|
break;
|
|
185
218
|
}
|
|
186
219
|
lint = validation.details.map(detail => `${detail.instancePath}: ${detail.message}`);
|
|
187
|
-
if (attempt === 2)
|
|
220
|
+
if (attempt === 2) {
|
|
221
|
+
const error = new Error(`the capability report did not validate after one repair: ${lint.join('; ')}`);
|
|
222
|
+
error.rawAnswers = rawAnswers;
|
|
223
|
+
throw error;
|
|
224
|
+
}
|
|
188
225
|
|
|
189
|
-
messages.push({ role: 'assistant', content: [{ type: 'text', text: answer.text.slice(0, 2000) }] });
|
|
226
|
+
messages.push({ role: 'assistant', content: [{ type: 'text', text: answer.text.slice(0, 2000) || '(no text)' }] });
|
|
190
227
|
messages.push({
|
|
191
228
|
role: 'user',
|
|
192
|
-
content: [{ type: 'text', text:
|
|
229
|
+
content: [{ type: 'text', text: cutOff
|
|
230
|
+
? `That answer was cut off at the output limit. Answer again with the JSON only, shorter: keep every required field and cut reasons and warnings to one sentence each.\n${lint.join('\n')}`
|
|
231
|
+
: `That did not match capability-report.schema.json. Fix exactly these and answer with the JSON only:\n${lint.join('\n')}` }]
|
|
193
232
|
});
|
|
233
|
+
attempt += 1;
|
|
194
234
|
}
|
|
195
235
|
|
|
196
236
|
return { report, lint, turns };
|
|
@@ -209,6 +249,27 @@ export async function writeCapabilityReport(jobDirectory, report) {
|
|
|
209
249
|
return target;
|
|
210
250
|
}
|
|
211
251
|
|
|
252
|
+
/**
|
|
253
|
+
* Write a failed synthesis's raw answers next to the job, secrets redacted,
|
|
254
|
+
* so a profile that was charged and refunded still says what went wrong.
|
|
255
|
+
*
|
|
256
|
+
* @param {string} jobDirectory
|
|
257
|
+
* @param {Array<{attempt: number, stopReason: string|null, text: string}>} rawAnswers
|
|
258
|
+
* @param {string} reason
|
|
259
|
+
* @returns {Promise<string>} the file written
|
|
260
|
+
*/
|
|
261
|
+
export async function writeProfileFailure(jobDirectory, rawAnswers, reason) {
|
|
262
|
+
const lines = [`profile failed: ${reason}`, ''];
|
|
263
|
+
(rawAnswers || []).forEach((answer, index) => {
|
|
264
|
+
lines.push(`--- synthesis answer ${index + 1} (attempt ${answer.attempt}, stop reason ${answer.stopReason || 'unknown'}${answer.tools && answer.tools.length > 0 ? `, tools ${answer.tools.join(', ')}` : ''}) ---`);
|
|
265
|
+
lines.push(answer.text || '(no text)');
|
|
266
|
+
lines.push('');
|
|
267
|
+
});
|
|
268
|
+
const target = path.join(jobDirectory, PROFILE_FAILURE_FILE);
|
|
269
|
+
await writeFile(target, redactSecrets(lines.join('\n')), { encoding: 'utf8', mode: 0o600 });
|
|
270
|
+
return target;
|
|
271
|
+
}
|
|
272
|
+
|
|
212
273
|
export { parseJsonObject };
|
|
213
274
|
|
|
214
|
-
export default { runGameProfile, writeCapabilityReport, createCapabilityValidator, parseJsonObject };
|
|
275
|
+
export default { runGameProfile, writeCapabilityReport, writeProfileFailure, createCapabilityValidator, parseJsonObject };
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { execFile, spawn } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
|
|
7
|
+
import { DriverRpcError } from './drivers/godot-rpc.js';
|
|
8
|
+
import { allowlistedEnv, GodotDriver } from './drivers/godot.js';
|
|
9
|
+
import { detectGodotBinary, importProjectCopy } from './drivers/godot-project.js';
|
|
10
|
+
import { parseTrace } from './trace.js';
|
|
11
|
+
|
|
12
|
+
const execFileAsync = promisify(execFile);
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Plays a recorded trace against a build, with no model anywhere in the path,
|
|
16
|
+
* and says where it first stopped matching.
|
|
17
|
+
*
|
|
18
|
+
* The answer has the same shape as `simulation-replay`, so one reader handles
|
|
19
|
+
* both: `matched`, `matched_prefix`, `fully_replayed`, `failure_reproduced`, and
|
|
20
|
+
* `first_divergence` as `{ step, expected, actual }`, `{ step, reason }` or
|
|
21
|
+
* `{ step, actual_error }`.
|
|
22
|
+
*
|
|
23
|
+
* A match says this seed and these inputs produced the same recorded state. A
|
|
24
|
+
* divergence on a changed branch is only evidence about the change if the trace
|
|
25
|
+
* also matches on the code it was recorded on, which is what `--baseline` checks
|
|
26
|
+
* and what the report says when it was not checked.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** The compact state a divergence names, in the simulation format's spelling. */
|
|
30
|
+
function stateRecord(digest) {
|
|
31
|
+
if (!digest) return null;
|
|
32
|
+
return {
|
|
33
|
+
done: false,
|
|
34
|
+
state_hash: digest.state_hash,
|
|
35
|
+
scene: digest.scene,
|
|
36
|
+
nodes: digest.nodes,
|
|
37
|
+
...(digest.game_state ? { game_state: digest.game_state } : {}),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function differingKeys(left = {}, right = {}, limit = 20) {
|
|
42
|
+
const keys = [...new Set([...Object.keys(left), ...Object.keys(right)])].sort();
|
|
43
|
+
return keys.filter((key) => left[key] !== right[key]).slice(0, limit);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Which keys of the game's own state hook differ, with both values. */
|
|
47
|
+
function gameStateDiff(expected, actual) {
|
|
48
|
+
const out = {};
|
|
49
|
+
const hooks = new Set([...Object.keys(expected ?? {}), ...Object.keys(actual ?? {})]);
|
|
50
|
+
for (const hook of hooks) {
|
|
51
|
+
const left = expected?.[hook] ?? {};
|
|
52
|
+
const right = actual?.[hook] ?? {};
|
|
53
|
+
for (const key of new Set([...Object.keys(left), ...Object.keys(right)])) {
|
|
54
|
+
if (JSON.stringify(left[key]) !== JSON.stringify(right[key])) {
|
|
55
|
+
out[key] = { expected: left[key] ?? null, actual: right[key] ?? null };
|
|
56
|
+
}
|
|
57
|
+
if (Object.keys(out).length >= 20) return out;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function divergence(step, frame, expected, actual) {
|
|
64
|
+
const diff = gameStateDiff(expected?.game_state, actual?.game_state);
|
|
65
|
+
return {
|
|
66
|
+
step,
|
|
67
|
+
frame,
|
|
68
|
+
expected: stateRecord(expected),
|
|
69
|
+
actual: stateRecord(actual),
|
|
70
|
+
differing_subtrees: differingKeys(expected?.subtrees, actual?.subtrees),
|
|
71
|
+
...(Object.keys(diff).length ? { game_state_diff: diff } : {}),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @param {Object} input
|
|
77
|
+
* @param {string} input.text - the trace file's contents
|
|
78
|
+
* @param {string} input.projectDir - the project to replay against
|
|
79
|
+
* @param {string} [input.godotPath]
|
|
80
|
+
* @param {boolean} [input.headless] - default the mode it was recorded in
|
|
81
|
+
* @param {string} [input.runDir] - where godot.log goes
|
|
82
|
+
* @param {AbortSignal} [input.signal]
|
|
83
|
+
* @param {(options: Object) => Object} [input.makeDriver] - test seam
|
|
84
|
+
* @returns {Promise<Object>} the simulation replay's result shape, plus counts
|
|
85
|
+
*/
|
|
86
|
+
export async function replayTrace({ text, projectDir, godotPath, headless, runDir, signal, env, makeDriver = options => new GodotDriver(options) }) {
|
|
87
|
+
const trace = parseTrace(text);
|
|
88
|
+
const { header, boot, steps } = trace;
|
|
89
|
+
const driver = makeDriver({
|
|
90
|
+
projectDir,
|
|
91
|
+
runDir,
|
|
92
|
+
godotPath,
|
|
93
|
+
headless: headless ?? Boolean(header.headless),
|
|
94
|
+
viewport: header.viewport || undefined,
|
|
95
|
+
lockstep: true,
|
|
96
|
+
seed: header.seed,
|
|
97
|
+
recordTrace: false,
|
|
98
|
+
...(env ? { env } : {}),
|
|
99
|
+
});
|
|
100
|
+
const result = {
|
|
101
|
+
matched: true,
|
|
102
|
+
matched_prefix: false,
|
|
103
|
+
fully_replayed: false,
|
|
104
|
+
failure_reproduced: null,
|
|
105
|
+
first_divergence: null,
|
|
106
|
+
seed: header.seed,
|
|
107
|
+
steps_total: steps.length,
|
|
108
|
+
steps_compared: 0,
|
|
109
|
+
ticks: 0,
|
|
110
|
+
trace_truncated: trace.truncated,
|
|
111
|
+
stopped: null,
|
|
112
|
+
};
|
|
113
|
+
try {
|
|
114
|
+
await driver.launch({ signal });
|
|
115
|
+
} catch (error) {
|
|
116
|
+
result.matched = false;
|
|
117
|
+
result.first_divergence = { step: 0, frame: 0, actual_error: String(error?.message ?? error).slice(0, 500) };
|
|
118
|
+
result.boot_failed = true;
|
|
119
|
+
return result;
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
if (driver.ticksPerSecond !== header.ticks_per_second) {
|
|
123
|
+
result.matched = false;
|
|
124
|
+
result.first_divergence = { step: 0, frame: 0, reason: `The project runs at ${driver.ticksPerSecond} ticks a second; this trace was recorded at ${header.ticks_per_second}.` };
|
|
125
|
+
return result;
|
|
126
|
+
}
|
|
127
|
+
if (boot && boot.state_hash !== driver.bootDigest?.state_hash) {
|
|
128
|
+
result.matched = false;
|
|
129
|
+
result.first_divergence = divergence('boot', 0, boot, driver.bootDigest);
|
|
130
|
+
return result;
|
|
131
|
+
}
|
|
132
|
+
let compared = 0;
|
|
133
|
+
for (const step of steps) {
|
|
134
|
+
if (signal?.aborted) throw new Error('Replay canceled.');
|
|
135
|
+
if (step.redacted) {
|
|
136
|
+
result.stopped = 'redacted_input';
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
let answer;
|
|
140
|
+
try {
|
|
141
|
+
answer = await driver.step(step.events, step.ticks, { digest: Boolean(step.state) });
|
|
142
|
+
} catch (error) {
|
|
143
|
+
result.matched = false;
|
|
144
|
+
result.first_divergence = error instanceof DriverRpcError
|
|
145
|
+
? { step: step.i, frame: step.frame, reason: 'Recorded action is no longer legal.', actual_error: error.rpcMessage }
|
|
146
|
+
: { step: step.i, frame: step.frame, actual_error: String(error?.message ?? error).slice(0, 500) };
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
result.ticks = answer.tick;
|
|
150
|
+
if (answer.tick !== step.frame + step.ticks) {
|
|
151
|
+
result.matched = false;
|
|
152
|
+
result.first_divergence = { step: step.i, frame: step.frame, reason: `The game was at tick ${answer.tick} where the trace was at ${step.frame + step.ticks}.` };
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
if (step.state) {
|
|
156
|
+
compared += 1;
|
|
157
|
+
if (step.state.state_hash !== answer.digest?.state_hash) {
|
|
158
|
+
result.matched = false;
|
|
159
|
+
result.first_divergence = divergence(step.i, step.frame, step.state, answer.digest);
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
result.steps_compared = compared;
|
|
165
|
+
result.errors = driver.errorCount;
|
|
166
|
+
// A held key the desktop dropped and the driver put back: this replay may be a
|
|
167
|
+
// tick off for a reason that has nothing to do with the game.
|
|
168
|
+
result.inputs_reasserted = driver.reasserted ?? 0;
|
|
169
|
+
if (result.matched) {
|
|
170
|
+
result.matched_prefix = true;
|
|
171
|
+
result.fully_replayed = !trace.truncated && !result.stopped;
|
|
172
|
+
}
|
|
173
|
+
return result;
|
|
174
|
+
} finally {
|
|
175
|
+
await driver.stop({ reason: 'replay' }).catch(() => {});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The checkout's commit when its tree is clean, else null.
|
|
181
|
+
*
|
|
182
|
+
* @param {string} repoRoot
|
|
183
|
+
* @returns {Promise<string|null>}
|
|
184
|
+
*/
|
|
185
|
+
export async function cleanCommit(repoRoot) {
|
|
186
|
+
try {
|
|
187
|
+
const { stdout: status } = await execFileAsync('git', ['-C', repoRoot, 'status', '--porcelain', '--', '.', ':(exclude).ravensight']);
|
|
188
|
+
if (status.trim()) return null;
|
|
189
|
+
const { stdout } = await execFileAsync('git', ['-C', repoRoot, 'rev-parse', 'HEAD']);
|
|
190
|
+
return stdout.trim();
|
|
191
|
+
} catch {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Extract one commit to a temp directory without touching the developer's
|
|
198
|
+
* checkout, index or worktree list: `git archive`, so nothing is written to
|
|
199
|
+
* their repository at all. Untracked and ignored files are not in it, which
|
|
200
|
+
* includes `.godot/` (imported here, once) and any ignored key file.
|
|
201
|
+
*
|
|
202
|
+
* @param {{repoRoot: string, ref: string, projectSubdir?: string, godotPath?: string, importProject?: Function, env?: Object}} input
|
|
203
|
+
* @returns {Promise<{sha: string, ref: string, root: string, projectDir: string, imported: Object, cleanup: () => Promise<void>}>}
|
|
204
|
+
*/
|
|
205
|
+
export async function checkoutRef({ repoRoot, ref, projectSubdir = '', godotPath, importProject = importProjectCopy, env = process.env }) {
|
|
206
|
+
let sha;
|
|
207
|
+
try {
|
|
208
|
+
({ stdout: sha } = await execFileAsync('git', ['-C', repoRoot, 'rev-parse', '--verify', `${ref}^{commit}`]));
|
|
209
|
+
} catch {
|
|
210
|
+
throw new Error(`There is no commit called "${ref}" in this repository.`);
|
|
211
|
+
}
|
|
212
|
+
sha = sha.trim();
|
|
213
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ravensight-ref-'));
|
|
214
|
+
const cleanup = () => fs.rm(root, { recursive: true, force: true }).catch(() => {});
|
|
215
|
+
try {
|
|
216
|
+
await new Promise((resolve, reject) => {
|
|
217
|
+
const archive = spawn('git', ['-C', repoRoot, 'archive', '--format=tar', sha], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
218
|
+
const tar = spawn('tar', ['-x', '-C', root], { stdio: ['pipe', 'ignore', 'ignore'] });
|
|
219
|
+
archive.stdout.pipe(tar.stdin);
|
|
220
|
+
archive.on('error', reject);
|
|
221
|
+
tar.on('error', reject);
|
|
222
|
+
tar.on('exit', code => (code === 0 ? resolve() : reject(new Error(`Extracting ${ref} failed.`))));
|
|
223
|
+
});
|
|
224
|
+
const projectDir = path.join(root, projectSubdir);
|
|
225
|
+
await fs.access(path.join(projectDir, 'project.godot')).catch(() => {
|
|
226
|
+
throw new Error(`${ref} has no Godot project at ${projectSubdir || 'the repository root'}.`);
|
|
227
|
+
});
|
|
228
|
+
const godot = await detectGodotBinary({ explicitPath: godotPath, env, projectDir });
|
|
229
|
+
// A branch's editor plugins run during this import. They get what a game gets.
|
|
230
|
+
const imported = await importProject({ godotPath: godot.path, projectCopy: projectDir, env: allowlistedEnv(env), logPath: null });
|
|
231
|
+
return { sha, ref, root, projectDir, imported, cleanup };
|
|
232
|
+
} catch (error) {
|
|
233
|
+
await cleanup();
|
|
234
|
+
throw error;
|
|
235
|
+
}
|
|
236
|
+
}
|