git-jev-stage 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +11 -0
- package/.claude-plugin/plugin.json +10 -0
- package/LICENSE +21 -0
- package/README.md +114 -0
- package/dist/git-jev-stage.js +2945 -0
- package/dist/index.d.ts +361 -0
- package/dist/index.js +1851 -0
- package/package.json +69 -0
- package/skills/git-jev-stage/SKILL.md +34 -0
|
@@ -0,0 +1,2945 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli/main.ts
|
|
4
|
+
import { realpathSync } from "node:fs";
|
|
5
|
+
import { createInterface } from "node:readline";
|
|
6
|
+
import { pathToFileURL } from "node:url";
|
|
7
|
+
|
|
8
|
+
// src/core/errors.ts
|
|
9
|
+
var JevCoreError = class extends Error {
|
|
10
|
+
};
|
|
11
|
+
var ProviderError = class extends JevCoreError {
|
|
12
|
+
code = "provider";
|
|
13
|
+
status;
|
|
14
|
+
requestId;
|
|
15
|
+
retryable;
|
|
16
|
+
constructor(message, options) {
|
|
17
|
+
super(message, options);
|
|
18
|
+
this.name = "ProviderError";
|
|
19
|
+
this.status = options?.status;
|
|
20
|
+
this.requestId = options?.requestId;
|
|
21
|
+
this.retryable = options?.retryable ?? false;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
var ProviderConfigError = class extends JevCoreError {
|
|
25
|
+
code = "provider-config";
|
|
26
|
+
constructor(message, options) {
|
|
27
|
+
super(message, options);
|
|
28
|
+
this.name = "ProviderConfigError";
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// src/core/fakeProvider.ts
|
|
33
|
+
import { readFileSync } from "node:fs";
|
|
34
|
+
|
|
35
|
+
// src/core/jevClient.ts
|
|
36
|
+
import process2 from "node:process";
|
|
37
|
+
|
|
38
|
+
// node_modules/@typesafe-ai/sdk/dist/index.mjs
|
|
39
|
+
var requestIdFrom = (headers) => headers.get("x-typesafe-request-id") ?? void 0;
|
|
40
|
+
var APIPromise = class APIPromise2 extends Promise {
|
|
41
|
+
#responsePromise;
|
|
42
|
+
#parseResponse;
|
|
43
|
+
#parsed;
|
|
44
|
+
constructor(responsePromise, parseResponse) {
|
|
45
|
+
super((resolve3) => resolve3(void 0));
|
|
46
|
+
this.#responsePromise = responsePromise;
|
|
47
|
+
this.#parseResponse = parseResponse;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Resolves to the raw `Response` without parsing the body. SDK requests buffer the full
|
|
51
|
+
* body under the request timeout before handoff; reading it afterwards is caller-owned.
|
|
52
|
+
* The caller owns the body; don't also `await` the parsed result on the same promise.
|
|
53
|
+
*/
|
|
54
|
+
asResponse() {
|
|
55
|
+
return this.#responsePromise;
|
|
56
|
+
}
|
|
57
|
+
/** Return the parsed result, HTTP response, and request ID. */
|
|
58
|
+
async withResponse() {
|
|
59
|
+
const [data, response] = await Promise.all([this.#parse(), this.#responsePromise]);
|
|
60
|
+
return {
|
|
61
|
+
data,
|
|
62
|
+
response,
|
|
63
|
+
requestId: requestIdFrom(response.headers)
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/** Transform the parsed result, sharing the HTTP response and a single body parse. */
|
|
67
|
+
map(fn) {
|
|
68
|
+
return new APIPromise2(this.#responsePromise, () => this.#parse().then(fn));
|
|
69
|
+
}
|
|
70
|
+
#parse() {
|
|
71
|
+
this.#parsed ??= this.#responsePromise.then(this.#parseResponse);
|
|
72
|
+
return this.#parsed;
|
|
73
|
+
}
|
|
74
|
+
then(onfulfilled, onrejected) {
|
|
75
|
+
return this.#parse().then(onfulfilled, onrejected);
|
|
76
|
+
}
|
|
77
|
+
catch(onrejected) {
|
|
78
|
+
return this.#parse().catch(onrejected);
|
|
79
|
+
}
|
|
80
|
+
finally(onfinally) {
|
|
81
|
+
return this.#parse().finally(onfinally);
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
var ENV = {
|
|
85
|
+
/** Required API key; used when `apiKey` is omitted. */
|
|
86
|
+
apiKey: "TYPESAFE_API_KEY",
|
|
87
|
+
/** API root; defaults to `https://api.typesafe.ai`. */
|
|
88
|
+
baseURL: "TYPESAFE_BASE_URL",
|
|
89
|
+
/** Default model name; defaults to `jev-latest`. */
|
|
90
|
+
defaultModel: "TYPESAFE_DEFAULT_MODEL",
|
|
91
|
+
/** Log level; defaults to `warn`. */
|
|
92
|
+
logLevel: "TYPESAFE_LOG_LEVEL"
|
|
93
|
+
};
|
|
94
|
+
var readEnv = (name) => {
|
|
95
|
+
if (typeof process === "undefined" || !process.env) return void 0;
|
|
96
|
+
return process.env[name]?.trim() || void 0;
|
|
97
|
+
};
|
|
98
|
+
var fromCodeOrEnv = (fromCode, envVar) => fromCode ?? readEnv(envVar);
|
|
99
|
+
var range = (from, to) => Array.from({ length: to - from }, (_, i) => from + i);
|
|
100
|
+
var DEFAULT_RETRY_POLICY = {
|
|
101
|
+
maxRetries: 2,
|
|
102
|
+
backoffInitialMs: 500,
|
|
103
|
+
backoffMaxMs: 5e3,
|
|
104
|
+
backoffJitter: 0.25,
|
|
105
|
+
/** HTTP 408, 429, and 5xx responses. */
|
|
106
|
+
httpStatuses: /* @__PURE__ */ new Set([
|
|
107
|
+
408,
|
|
108
|
+
429,
|
|
109
|
+
...range(500, 600)
|
|
110
|
+
]),
|
|
111
|
+
respectRetryAfter: true,
|
|
112
|
+
/** Maximum server retry delay before falling back to backoff. */
|
|
113
|
+
maxRetryAfterMs: 6e4,
|
|
114
|
+
apiConnectionError: true,
|
|
115
|
+
apiTimeoutError: true
|
|
116
|
+
};
|
|
117
|
+
DEFAULT_RETRY_POLICY.maxRetries;
|
|
118
|
+
var isRetryableStatus = (status, policy = DEFAULT_RETRY_POLICY) => policy.httpStatuses.has(status);
|
|
119
|
+
var parseRetryAfter = (headers, now = Date.now()) => {
|
|
120
|
+
const ms = Number(headers.get("retry-after-ms"));
|
|
121
|
+
if (headers.has("retry-after-ms") && Number.isFinite(ms) && ms >= 0) return ms;
|
|
122
|
+
const raw = headers.get("retry-after");
|
|
123
|
+
if (raw === null) return void 0;
|
|
124
|
+
const seconds = Number(raw);
|
|
125
|
+
if (Number.isFinite(seconds)) return seconds >= 0 ? seconds * 1e3 : void 0;
|
|
126
|
+
const date = Date.parse(raw);
|
|
127
|
+
if (!Number.isNaN(date)) return Math.max(0, date - now);
|
|
128
|
+
};
|
|
129
|
+
var retryDelayMs = (attempt, headers, policy = DEFAULT_RETRY_POLICY, random = Math.random) => {
|
|
130
|
+
if (policy.respectRetryAfter && headers !== void 0) {
|
|
131
|
+
const retryAfter = parseRetryAfter(headers);
|
|
132
|
+
if (retryAfter !== void 0 && retryAfter <= policy.maxRetryAfterMs) return retryAfter;
|
|
133
|
+
}
|
|
134
|
+
const exponential = Math.min(policy.backoffInitialMs * 2 ** attempt, policy.backoffMaxMs);
|
|
135
|
+
return Math.round(exponential * (1 - random() * policy.backoffJitter));
|
|
136
|
+
};
|
|
137
|
+
var sleep = (ms, signal) => new Promise((resolve3, reject) => {
|
|
138
|
+
if (signal?.aborted) return reject(signal.reason);
|
|
139
|
+
const onAbort = () => {
|
|
140
|
+
clearTimeout(timer);
|
|
141
|
+
reject(signal?.reason);
|
|
142
|
+
};
|
|
143
|
+
const timer = setTimeout(() => {
|
|
144
|
+
signal?.removeEventListener("abort", onAbort);
|
|
145
|
+
resolve3();
|
|
146
|
+
}, ms);
|
|
147
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
148
|
+
});
|
|
149
|
+
var TypeSafeError = class extends Error {
|
|
150
|
+
constructor(message, options) {
|
|
151
|
+
super(message, options);
|
|
152
|
+
this.name = new.target.name;
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
var isRecord = (value) => typeof value === "object" && value !== null;
|
|
156
|
+
var extractMessage = (body) => {
|
|
157
|
+
if (typeof body === "string") return body || void 0;
|
|
158
|
+
if (!isRecord(body)) return void 0;
|
|
159
|
+
const { error, message, detail } = body;
|
|
160
|
+
if (typeof error === "string") return error;
|
|
161
|
+
if (isRecord(error) && typeof error.message === "string") return error.message;
|
|
162
|
+
if (typeof message === "string") return message;
|
|
163
|
+
if (typeof detail === "string") return detail;
|
|
164
|
+
if (isRecord(detail) && typeof detail.message === "string") return detail.message;
|
|
165
|
+
if (Array.isArray(detail)) return describeValidationErrors(detail);
|
|
166
|
+
};
|
|
167
|
+
var describeValidationErrors = (errors) => {
|
|
168
|
+
const parts = errors.flatMap((e) => {
|
|
169
|
+
if (!isRecord(e) || typeof e.msg !== "string") return [];
|
|
170
|
+
const loc = Array.isArray(e.loc) ? e.loc.filter((x) => x !== "body").join(".") : "";
|
|
171
|
+
return [loc ? `${loc}: ${e.msg}` : e.msg];
|
|
172
|
+
});
|
|
173
|
+
return parts.length > 0 ? parts.join("; ") : void 0;
|
|
174
|
+
};
|
|
175
|
+
var MAX_RAW_BODY_IN_MESSAGE = 200;
|
|
176
|
+
var APIError = class APIError2 extends TypeSafeError {
|
|
177
|
+
/** HTTP response status code. */
|
|
178
|
+
status;
|
|
179
|
+
/** HTTP response headers. */
|
|
180
|
+
headers;
|
|
181
|
+
/** Parsed JSON, response text, or `undefined` for an empty body. */
|
|
182
|
+
body;
|
|
183
|
+
/** Request ID from `x-typesafe-request-id`, or `undefined` when absent. */
|
|
184
|
+
requestId;
|
|
185
|
+
constructor(status, body, headers, message) {
|
|
186
|
+
super(message ?? APIError2.describe(status, body));
|
|
187
|
+
this.status = status;
|
|
188
|
+
this.body = body;
|
|
189
|
+
this.headers = headers;
|
|
190
|
+
this.requestId = requestIdFrom(headers);
|
|
191
|
+
}
|
|
192
|
+
static describe(status, body) {
|
|
193
|
+
const detail = extractMessage(body);
|
|
194
|
+
if (detail) return `${status} ${detail}`;
|
|
195
|
+
if (body === void 0) return `${status} status code (no body)`;
|
|
196
|
+
const raw = typeof body === "string" ? body : JSON.stringify(body);
|
|
197
|
+
return `${status} ${raw.length > MAX_RAW_BODY_IN_MESSAGE ? `${raw.slice(0, MAX_RAW_BODY_IN_MESSAGE)}\u2026` : raw}`;
|
|
198
|
+
}
|
|
199
|
+
/** Create the error subclass for an HTTP status code. */
|
|
200
|
+
static fromResponse(status, body, headers) {
|
|
201
|
+
if (status === 400) return new BadRequestError(status, body, headers);
|
|
202
|
+
if (status === 401) return new AuthenticationError(status, body, headers);
|
|
203
|
+
if (status === 403) return new PermissionDeniedError(status, body, headers);
|
|
204
|
+
if (status === 404) return new NotFoundError(status, body, headers);
|
|
205
|
+
if (status === 422) return new UnprocessableEntityError(status, body, headers);
|
|
206
|
+
if (status === 429) return new RateLimitError(status, body, headers);
|
|
207
|
+
if (status >= 500) return new InternalServerError(status, body, headers);
|
|
208
|
+
return new APIError2(status, body, headers);
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
var BadRequestError = class extends APIError {
|
|
212
|
+
};
|
|
213
|
+
var AuthenticationError = class extends APIError {
|
|
214
|
+
};
|
|
215
|
+
var PermissionDeniedError = class extends APIError {
|
|
216
|
+
};
|
|
217
|
+
var NotFoundError = class extends APIError {
|
|
218
|
+
};
|
|
219
|
+
var UnprocessableEntityError = class extends APIError {
|
|
220
|
+
};
|
|
221
|
+
var RateLimitError = class extends APIError {
|
|
222
|
+
/** Server retry delay in milliseconds, or `undefined` when absent or invalid. */
|
|
223
|
+
retryAfterMs = parseRetryAfter(this.headers);
|
|
224
|
+
};
|
|
225
|
+
var InternalServerError = class extends APIError {
|
|
226
|
+
};
|
|
227
|
+
var APIConnectionError = class extends TypeSafeError {
|
|
228
|
+
constructor(message = "Connection error.", options) {
|
|
229
|
+
super(message, options);
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
var APITimeoutError = class extends APIConnectionError {
|
|
233
|
+
/** Configured timeout in milliseconds. */
|
|
234
|
+
timeoutMs;
|
|
235
|
+
constructor(timeoutMs, options) {
|
|
236
|
+
super(`Request timed out after ${timeoutMs}ms.`, options);
|
|
237
|
+
this.timeoutMs = timeoutMs;
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
var APIUserAbortError = class extends TypeSafeError {
|
|
241
|
+
constructor(message = "Request was aborted.", options) {
|
|
242
|
+
super(message, options);
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
var LOG_LEVELS = [
|
|
246
|
+
"debug",
|
|
247
|
+
"info",
|
|
248
|
+
"warn",
|
|
249
|
+
"error",
|
|
250
|
+
"off"
|
|
251
|
+
];
|
|
252
|
+
var DEFAULT_LOG_LEVEL = "warn";
|
|
253
|
+
var isLogLevel = (value) => LOG_LEVELS.includes(value);
|
|
254
|
+
var parseLogLevel = (value, source) => {
|
|
255
|
+
if (isLogLevel(value)) return value;
|
|
256
|
+
throw new TypeSafeError(`Invalid log level "${value}" from ${source}. Expected one of: ${LOG_LEVELS.join(", ")}.`);
|
|
257
|
+
};
|
|
258
|
+
var PREFIX = "[typesafe-sdk]";
|
|
259
|
+
var consoleLogger = {
|
|
260
|
+
debug: (message, ...args) => console.debug(`${PREFIX} ${message}`, ...args),
|
|
261
|
+
info: (message, ...args) => console.info(`${PREFIX} ${message}`, ...args),
|
|
262
|
+
warn: (message, ...args) => console.warn(`${PREFIX} ${message}`, ...args),
|
|
263
|
+
error: (message, ...args) => console.error(`${PREFIX} ${message}`, ...args)
|
|
264
|
+
};
|
|
265
|
+
var RANK = {
|
|
266
|
+
debug: 0,
|
|
267
|
+
info: 1,
|
|
268
|
+
warn: 2,
|
|
269
|
+
error: 3,
|
|
270
|
+
off: 4
|
|
271
|
+
};
|
|
272
|
+
var drop = () => {
|
|
273
|
+
};
|
|
274
|
+
var withLevel = (sink, level) => {
|
|
275
|
+
const enabled = (at) => RANK[at] >= RANK[level];
|
|
276
|
+
return {
|
|
277
|
+
debug: enabled("debug") ? (message, ...args) => sink.debug(message, ...args) : drop,
|
|
278
|
+
info: enabled("info") ? (message, ...args) => sink.info(message, ...args) : drop,
|
|
279
|
+
warn: enabled("warn") ? (message, ...args) => sink.warn(message, ...args) : drop,
|
|
280
|
+
error: enabled("error") ? (message, ...args) => sink.error(message, ...args) : drop
|
|
281
|
+
};
|
|
282
|
+
};
|
|
283
|
+
var KEY_HEADERS = /* @__PURE__ */ new Set([
|
|
284
|
+
"authorization",
|
|
285
|
+
"proxy-authorization",
|
|
286
|
+
"x-api-key"
|
|
287
|
+
]);
|
|
288
|
+
var OPAQUE_HEADERS = /* @__PURE__ */ new Set(["cookie", "set-cookie"]);
|
|
289
|
+
var redactKey = (value) => {
|
|
290
|
+
const [scheme, secret] = value.includes(" ") ? value.split(/\s+/, 2) : [void 0, value];
|
|
291
|
+
const tail = secret && secret.length > 8 ? secret.slice(-4) : "";
|
|
292
|
+
return `${scheme ? `${scheme} ` : ""}***${tail}`;
|
|
293
|
+
};
|
|
294
|
+
var redact = (name, value) => {
|
|
295
|
+
const lower = name.toLowerCase();
|
|
296
|
+
if (KEY_HEADERS.has(lower)) return redactKey(value);
|
|
297
|
+
if (OPAQUE_HEADERS.has(lower)) return "***";
|
|
298
|
+
return value;
|
|
299
|
+
};
|
|
300
|
+
var redactHeaders = (headers) => Object.fromEntries(Object.entries(headers).map(([name, value]) => [name, redact(name, value)]));
|
|
301
|
+
var choice = (instructions, criteria) => {
|
|
302
|
+
if (Array.isArray(criteria)) throw new TypeSafeError("Choice criteria must be a map of labels to descriptions, not a list.");
|
|
303
|
+
return {
|
|
304
|
+
type: "choice",
|
|
305
|
+
instructions,
|
|
306
|
+
criteria
|
|
307
|
+
};
|
|
308
|
+
};
|
|
309
|
+
var validateQuestions = (questions) => {
|
|
310
|
+
if (Object.keys(questions).length === 0) throw new TypeSafeError("At least one question is required.");
|
|
311
|
+
for (const [name, question] of Object.entries(questions)) {
|
|
312
|
+
if (question.type !== "score") continue;
|
|
313
|
+
if (!Array.isArray(question.criteria)) throw new TypeSafeError(`Score question "${name}" has criteria that are not a list; score criteria must be a list of descriptions indexed by score from zero.`);
|
|
314
|
+
if (question.criteria.length < 2) throw new TypeSafeError(`Score question "${name}" has ${question.criteria.length} criteria; at least two scores are required.`);
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
var Models = class {
|
|
318
|
+
#transport;
|
|
319
|
+
constructor(transport) {
|
|
320
|
+
this.#transport = transport;
|
|
321
|
+
}
|
|
322
|
+
/** List the models available to the account. */
|
|
323
|
+
list(options = {}) {
|
|
324
|
+
return this.#transport.request("GET", "/v1/models", options).map(unwrapModels);
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
var unwrapModels = (wire) => {
|
|
328
|
+
if (Array.isArray(wire?.models)) return wire.models;
|
|
329
|
+
throw new TypeSafeError("Unexpected response shape from GET /v1/models; expected { models: [...] }.");
|
|
330
|
+
};
|
|
331
|
+
var g = globalThis;
|
|
332
|
+
var isBrowser = () => typeof g.window !== "undefined" && typeof g.window.document !== "undefined" && typeof g.navigator !== "undefined";
|
|
333
|
+
var describeRuntime = () => {
|
|
334
|
+
const platform = g.process?.platform && g.process?.arch ? ` (${g.process.platform}; ${g.process.arch})` : "";
|
|
335
|
+
if (g.Bun?.version) return `bun/${g.Bun.version}${platform}`;
|
|
336
|
+
if (g.Deno?.version?.deno) return `deno/${g.Deno.version.deno}${platform}`;
|
|
337
|
+
if (g.EdgeRuntime !== void 0) return "vercel-edge";
|
|
338
|
+
if (g.navigator?.userAgent === "Cloudflare-Workers") return "cloudflare-workers";
|
|
339
|
+
if (g.process?.versions?.node) return `node/${g.process.versions.node}${platform}`;
|
|
340
|
+
if (isBrowser()) return "browser";
|
|
341
|
+
return "unknown";
|
|
342
|
+
};
|
|
343
|
+
var VERSION = "0.6.0";
|
|
344
|
+
var missingApiKey = () => {
|
|
345
|
+
throw new TypeSafeError(`No API key was provided. Pass \`apiKey\` to the TypeSafeClient constructor or set the ${ENV.apiKey} environment variable.`);
|
|
346
|
+
};
|
|
347
|
+
var missingFetch = () => {
|
|
348
|
+
throw new TypeSafeError("No global `fetch` is available in this runtime. Pass a `fetch` implementation to the TypeSafeClient constructor.");
|
|
349
|
+
};
|
|
350
|
+
var refuseBrowser = () => {
|
|
351
|
+
throw new TypeSafeError("TypeSafeClient is running in a browser, which would expose your API key to anyone using the page. Call the API from a server instead, or pass `dangerouslyAllowBrowser: true` if you understand the risk.");
|
|
352
|
+
};
|
|
353
|
+
var defaultFetch = (input, init) => globalThis.fetch(input, init);
|
|
354
|
+
var assertNonNegativeInteger = (name, value) => {
|
|
355
|
+
if (!Number.isInteger(value) || value < 0) throw new TypeSafeError(`\`${name}\` must be a non-negative integer, got ${String(value)}.`);
|
|
356
|
+
return value;
|
|
357
|
+
};
|
|
358
|
+
var assertPositiveMs = (name, value) => {
|
|
359
|
+
if (!Number.isFinite(value) || value <= 0) throw new TypeSafeError(`\`${name}\` must be a positive number of milliseconds, got ${String(value)}.`);
|
|
360
|
+
return value;
|
|
361
|
+
};
|
|
362
|
+
var assertNonNegativeMs = (name, value) => {
|
|
363
|
+
if (!Number.isFinite(value) || value < 0) throw new TypeSafeError(`\`${name}\` must be a non-negative number of milliseconds, got ${String(value)}.`);
|
|
364
|
+
return value;
|
|
365
|
+
};
|
|
366
|
+
var assertFraction = (name, value) => {
|
|
367
|
+
if (!Number.isFinite(value) || value < 0 || value > 1) throw new TypeSafeError(`\`${name}\` must be between 0 and 1, got ${String(value)}.`);
|
|
368
|
+
return value;
|
|
369
|
+
};
|
|
370
|
+
var assertStatusSet = (name, statuses) => {
|
|
371
|
+
for (const status of statuses) if (!Number.isInteger(status) || status < 100 || status > 999) throw new TypeSafeError(`\`${name}\` must contain HTTP status codes, got ${String(status)}.`);
|
|
372
|
+
return statuses;
|
|
373
|
+
};
|
|
374
|
+
var resolveRetryPolicy = (base, overrides) => {
|
|
375
|
+
const o = overrides ?? {};
|
|
376
|
+
return {
|
|
377
|
+
maxRetries: o.maxRetries === void 0 ? base.maxRetries : assertNonNegativeInteger("retry.maxRetries", o.maxRetries),
|
|
378
|
+
backoffInitialMs: o.backoffInitialMs === void 0 ? base.backoffInitialMs : assertNonNegativeMs("retry.backoffInitialMs", o.backoffInitialMs),
|
|
379
|
+
backoffMaxMs: o.backoffMaxMs === void 0 ? base.backoffMaxMs : assertNonNegativeMs("retry.backoffMaxMs", o.backoffMaxMs),
|
|
380
|
+
backoffJitter: o.backoffJitter === void 0 ? base.backoffJitter : assertFraction("retry.backoffJitter", o.backoffJitter),
|
|
381
|
+
httpStatuses: new Set(o.httpStatuses === void 0 ? base.httpStatuses : assertStatusSet("retry.httpStatuses", o.httpStatuses)),
|
|
382
|
+
respectRetryAfter: o.respectRetryAfter ?? base.respectRetryAfter,
|
|
383
|
+
maxRetryAfterMs: o.maxRetryAfterMs === void 0 ? base.maxRetryAfterMs : assertNonNegativeMs("retry.maxRetryAfterMs", o.maxRetryAfterMs),
|
|
384
|
+
apiConnectionError: o.apiConnectionError ?? base.apiConnectionError,
|
|
385
|
+
apiTimeoutError: o.apiTimeoutError ?? base.apiTimeoutError
|
|
386
|
+
};
|
|
387
|
+
};
|
|
388
|
+
var isRetryableError = (err, policy) => {
|
|
389
|
+
if (err instanceof APITimeoutError) return policy.apiTimeoutError;
|
|
390
|
+
if (err instanceof APIConnectionError) return policy.apiConnectionError;
|
|
391
|
+
return false;
|
|
392
|
+
};
|
|
393
|
+
var resolveLogLevel = (fromCode) => {
|
|
394
|
+
if (fromCode !== void 0) return parseLogLevel(fromCode, "the `logLevel` option");
|
|
395
|
+
const fromEnv = readEnv(ENV.logLevel);
|
|
396
|
+
if (fromEnv !== void 0) return parseLogLevel(fromEnv, ENV.logLevel);
|
|
397
|
+
return DEFAULT_LOG_LEVEL;
|
|
398
|
+
};
|
|
399
|
+
var stripTrailingSlashes = (url) => url.replace(/\/+$/, "");
|
|
400
|
+
var mergeHeaders = (...sources) => {
|
|
401
|
+
const entries = /* @__PURE__ */ new Map();
|
|
402
|
+
for (const source of sources) for (const [name, value] of Object.entries(source)) if (value === void 0) entries.delete(name.toLowerCase());
|
|
403
|
+
else entries.set(name.toLowerCase(), [name, value]);
|
|
404
|
+
return Object.fromEntries(entries.values());
|
|
405
|
+
};
|
|
406
|
+
var bufferResponse = async (response, signal) => {
|
|
407
|
+
const reader = response.clone().body?.getReader();
|
|
408
|
+
if (!reader) return;
|
|
409
|
+
const cancel = () => {
|
|
410
|
+
reader.cancel(signal.reason).catch(() => {
|
|
411
|
+
});
|
|
412
|
+
response.body?.cancel(signal.reason).catch(() => {
|
|
413
|
+
});
|
|
414
|
+
};
|
|
415
|
+
signal.addEventListener("abort", cancel, { once: true });
|
|
416
|
+
try {
|
|
417
|
+
if (signal.aborted) cancel();
|
|
418
|
+
signal.throwIfAborted();
|
|
419
|
+
while (!(await reader.read()).done) signal.throwIfAborted();
|
|
420
|
+
signal.throwIfAborted();
|
|
421
|
+
} finally {
|
|
422
|
+
signal.removeEventListener("abort", cancel);
|
|
423
|
+
reader.releaseLock();
|
|
424
|
+
}
|
|
425
|
+
};
|
|
426
|
+
var RUNTIME = describeRuntime();
|
|
427
|
+
var TypeSafeClient = class {
|
|
428
|
+
/** API key excluded from serialization and public properties. */
|
|
429
|
+
#apiKey;
|
|
430
|
+
/** API root with trailing slashes removed. */
|
|
431
|
+
baseURL;
|
|
432
|
+
/** Model used when a request omits `model`. */
|
|
433
|
+
defaultModel;
|
|
434
|
+
/** Configured log verbosity. */
|
|
435
|
+
logLevel;
|
|
436
|
+
/** The configured logger, filtered to `logLevel`. */
|
|
437
|
+
logger;
|
|
438
|
+
/** Retry settings with constructor overrides applied. */
|
|
439
|
+
retry;
|
|
440
|
+
/** Timeout per attempt in milliseconds. */
|
|
441
|
+
timeout;
|
|
442
|
+
/** Additional headers sent with each request. */
|
|
443
|
+
defaultHeaders;
|
|
444
|
+
/** HTTP fetch implementation. */
|
|
445
|
+
fetch;
|
|
446
|
+
/** The models available to the account. */
|
|
447
|
+
models;
|
|
448
|
+
#requestCount = 0;
|
|
449
|
+
/**
|
|
450
|
+
* Create a client for the TypeSafe AI API.
|
|
451
|
+
*
|
|
452
|
+
* Explicit options take precedence over environment variables, then SDK defaults.
|
|
453
|
+
* Empty or whitespace-only environment values are ignored.
|
|
454
|
+
*
|
|
455
|
+
* @throws {TypeSafeError} The API key is missing, configuration is invalid, or the runtime is unsupported.
|
|
456
|
+
*/
|
|
457
|
+
constructor(config = {}) {
|
|
458
|
+
if (isBrowser() && !config.dangerouslyAllowBrowser) refuseBrowser();
|
|
459
|
+
this.#apiKey = fromCodeOrEnv(config.apiKey, ENV.apiKey) ?? missingApiKey();
|
|
460
|
+
this.baseURL = stripTrailingSlashes(fromCodeOrEnv(config.baseURL, ENV.baseURL) ?? "https://api.typesafe.ai");
|
|
461
|
+
this.defaultModel = fromCodeOrEnv(config.defaultModel, ENV.defaultModel) ?? "jev-latest";
|
|
462
|
+
this.logLevel = resolveLogLevel(config.logLevel);
|
|
463
|
+
this.logger = withLevel(config.logger ?? consoleLogger, this.logLevel);
|
|
464
|
+
this.retry = resolveRetryPolicy(DEFAULT_RETRY_POLICY, config.retry);
|
|
465
|
+
this.timeout = assertPositiveMs("timeout", config.timeout ?? 1e4);
|
|
466
|
+
this.defaultHeaders = { ...config.defaultHeaders };
|
|
467
|
+
if (config.fetch === void 0 && typeof globalThis.fetch !== "function") missingFetch();
|
|
468
|
+
this.fetch = config.fetch ?? defaultFetch;
|
|
469
|
+
const transport = {
|
|
470
|
+
request: (method, path, options) => this.#request(method, path, options),
|
|
471
|
+
defaultModel: this.defaultModel
|
|
472
|
+
};
|
|
473
|
+
this.models = new Models(transport);
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Answer named questions about text or structured state.
|
|
477
|
+
*
|
|
478
|
+
* @param request - State, questions, and an optional model override.
|
|
479
|
+
* @param options - Per-call timeout, retry, headers, and cancellation settings.
|
|
480
|
+
* @returns Answers typed by question name and criteria, with model and token usage.
|
|
481
|
+
* @throws {TypeSafeError} Questions are empty, or score criteria are not a list of at least two entries.
|
|
482
|
+
* @throws {APIError} The server returns a non-2xx response after retries.
|
|
483
|
+
* @throws {APIConnectionError} The request cannot connect or times out after retries.
|
|
484
|
+
* @throws {APIUserAbortError} The caller aborts the request.
|
|
485
|
+
*
|
|
486
|
+
* @example
|
|
487
|
+
* ```ts
|
|
488
|
+
* const { answers } = await client.systemOne({
|
|
489
|
+
* state: "I was charged twice. Please help.",
|
|
490
|
+
* questions: { billing: noul("Is this about billing?") },
|
|
491
|
+
* });
|
|
492
|
+
* console.log(answers.billing.noul);
|
|
493
|
+
* ```
|
|
494
|
+
*/
|
|
495
|
+
systemOne(request, options = {}) {
|
|
496
|
+
validateQuestions(request.questions);
|
|
497
|
+
const body = {
|
|
498
|
+
...request,
|
|
499
|
+
model: request.model ?? this.defaultModel
|
|
500
|
+
};
|
|
501
|
+
return this.#request("POST", "/v1/systemone", {
|
|
502
|
+
...options,
|
|
503
|
+
body
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
/** Send a request and parse its response body. */
|
|
507
|
+
#request(method, path, options = {}) {
|
|
508
|
+
const resolved = {
|
|
509
|
+
method,
|
|
510
|
+
path,
|
|
511
|
+
body: options.body,
|
|
512
|
+
headers: mergeHeaders(this.defaultHeaders, options.headers ?? {}),
|
|
513
|
+
signal: options.signal,
|
|
514
|
+
timeout: options.timeout === void 0 ? this.timeout : assertPositiveMs("timeout", options.timeout),
|
|
515
|
+
retry: resolveRetryPolicy(this.retry, options.retry)
|
|
516
|
+
};
|
|
517
|
+
const tag = `#${++this.#requestCount} ${method} ${path}`;
|
|
518
|
+
return new APIPromise(this.fetchWithRetries(tag, resolved), async (res) => {
|
|
519
|
+
const parsed = await parseBody(res);
|
|
520
|
+
this.logger.debug(`${tag} <- body`, parsed);
|
|
521
|
+
return parsed;
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
/** Retry eligible failures, logging attempt summaries at `info` and headers and bodies at `debug`. */
|
|
525
|
+
async fetchWithRetries(tag, req) {
|
|
526
|
+
const url = `${this.baseURL}${req.path}`;
|
|
527
|
+
const headers = mergeHeaders(req.headers, {
|
|
528
|
+
Authorization: `Bearer ${this.#apiKey}`,
|
|
529
|
+
Accept: "application/json",
|
|
530
|
+
"User-Agent": `typesafe-sdk/${VERSION}`,
|
|
531
|
+
"X-TypeSafe-SDK": `typesafe-sdk/${VERSION}`,
|
|
532
|
+
"X-TypeSafe-Runtime": RUNTIME,
|
|
533
|
+
"Content-Type": req.body === void 0 ? void 0 : "application/json",
|
|
534
|
+
"X-TypeSafe-Retry-Count": void 0
|
|
535
|
+
});
|
|
536
|
+
const body = req.body === void 0 ? void 0 : JSON.stringify(req.body);
|
|
537
|
+
for (let attempt = 0; ; attempt++) {
|
|
538
|
+
const retriesLeft = req.retry.maxRetries - attempt;
|
|
539
|
+
const attemptHeaders = attempt === 0 ? headers : {
|
|
540
|
+
...headers,
|
|
541
|
+
"X-TypeSafe-Retry-Count": String(attempt)
|
|
542
|
+
};
|
|
543
|
+
this.logger.debug(`${tag} -> ${url}`, {
|
|
544
|
+
headers: redactHeaders(attemptHeaders),
|
|
545
|
+
body: req.body
|
|
546
|
+
});
|
|
547
|
+
const started = Date.now();
|
|
548
|
+
let res;
|
|
549
|
+
try {
|
|
550
|
+
res = await this.attempt(tag, url, {
|
|
551
|
+
method: req.method,
|
|
552
|
+
headers: attemptHeaders,
|
|
553
|
+
body
|
|
554
|
+
}, req);
|
|
555
|
+
} catch (err) {
|
|
556
|
+
if (err instanceof APIUserAbortError || retriesLeft <= 0) throw err;
|
|
557
|
+
if (!isRetryableError(err, req.retry)) throw err;
|
|
558
|
+
await this.backOff(tag, attempt, retriesLeft, err.message, void 0, req);
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
const requestId = requestIdFrom(res.headers);
|
|
562
|
+
this.logger.info(`${tag} <- ${res.status} in ${Date.now() - started}ms${requestId ? ` (request ${requestId})` : ""}`);
|
|
563
|
+
if (res.ok) return res;
|
|
564
|
+
const errorBody = await parseBody(res);
|
|
565
|
+
this.logger.debug(`${tag} <- error body`, errorBody);
|
|
566
|
+
const error = APIError.fromResponse(res.status, errorBody, res.headers);
|
|
567
|
+
if (retriesLeft <= 0 || !isRetryableStatus(res.status, req.retry)) throw error;
|
|
568
|
+
await this.backOff(tag, attempt, retriesLeft, `${res.status}`, res.headers, req);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
/**
|
|
572
|
+
* One HTTP round trip, including body delivery, with a timeout. The caller's signal and our
|
|
573
|
+
* timer both abort the same controller; we check which fired to choose the error class.
|
|
574
|
+
*/
|
|
575
|
+
async attempt(tag, url, init, { signal, timeout }) {
|
|
576
|
+
const controller = new AbortController();
|
|
577
|
+
const abortFromCaller = () => controller.abort(signal?.reason);
|
|
578
|
+
if (signal?.aborted) abortFromCaller();
|
|
579
|
+
signal?.addEventListener("abort", abortFromCaller, { once: true });
|
|
580
|
+
let timedOut = false;
|
|
581
|
+
const timer = setTimeout(() => {
|
|
582
|
+
timedOut = true;
|
|
583
|
+
controller.abort();
|
|
584
|
+
}, timeout);
|
|
585
|
+
const started = Date.now();
|
|
586
|
+
const elapsed = () => `${Date.now() - started}ms`;
|
|
587
|
+
try {
|
|
588
|
+
const response = await this.fetch(url, {
|
|
589
|
+
...init,
|
|
590
|
+
signal: controller.signal
|
|
591
|
+
});
|
|
592
|
+
await bufferResponse(response, controller.signal);
|
|
593
|
+
return response;
|
|
594
|
+
} catch (err) {
|
|
595
|
+
if (signal?.aborted) {
|
|
596
|
+
this.logger.info(`${tag} aborted by caller after ${elapsed()}`);
|
|
597
|
+
throw new APIUserAbortError(void 0, { cause: err });
|
|
598
|
+
}
|
|
599
|
+
if (timedOut) {
|
|
600
|
+
this.logger.info(`${tag} timed out after ${elapsed()}`);
|
|
601
|
+
throw new APITimeoutError(timeout, { cause: err });
|
|
602
|
+
}
|
|
603
|
+
this.logger.info(`${tag} connection error after ${elapsed()}`, err);
|
|
604
|
+
throw new APIConnectionError(err instanceof Error ? `Connection error: ${err.message}` : void 0, { cause: err });
|
|
605
|
+
} finally {
|
|
606
|
+
clearTimeout(timer);
|
|
607
|
+
signal?.removeEventListener("abort", abortFromCaller);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
/** Wait before retrying; caller cancellation throws `APIUserAbortError`. */
|
|
611
|
+
async backOff(tag, attempt, retriesLeft, reason, headers, { retry, signal }) {
|
|
612
|
+
const delay2 = retryDelayMs(attempt, headers, retry);
|
|
613
|
+
const nth = attempt + 1;
|
|
614
|
+
const total2 = attempt + retriesLeft;
|
|
615
|
+
this.logger.info(`${tag} retrying in ${delay2}ms (retry ${nth}/${total2}) after ${reason}`);
|
|
616
|
+
try {
|
|
617
|
+
await sleep(delay2, signal);
|
|
618
|
+
} catch (err) {
|
|
619
|
+
this.logger.info(`${tag} aborted by caller while waiting to retry`);
|
|
620
|
+
throw new APIUserAbortError(void 0, { cause: err });
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
};
|
|
624
|
+
var parseBody = async (res) => {
|
|
625
|
+
const text = await res.text();
|
|
626
|
+
if (text.length === 0) return void 0;
|
|
627
|
+
if ((res.headers.get("content-type") ?? "").includes("application/json")) try {
|
|
628
|
+
return JSON.parse(text);
|
|
629
|
+
} catch {
|
|
630
|
+
return text;
|
|
631
|
+
}
|
|
632
|
+
try {
|
|
633
|
+
return JSON.parse(text);
|
|
634
|
+
} catch {
|
|
635
|
+
return text;
|
|
636
|
+
}
|
|
637
|
+
};
|
|
638
|
+
|
|
639
|
+
// src/core/jevClient.ts
|
|
640
|
+
var DEFAULT_MODEL = "jev-latest";
|
|
641
|
+
var DEFAULT_TIMEOUT_MS = 6e4;
|
|
642
|
+
var DEFAULT_MAX_RETRIES = 2;
|
|
643
|
+
var TypeSafeJevProvider = class {
|
|
644
|
+
model;
|
|
645
|
+
#client;
|
|
646
|
+
constructor(options = {}) {
|
|
647
|
+
const apiKey = options.apiKey?.trim() ?? "";
|
|
648
|
+
if (apiKey.length === 0) {
|
|
649
|
+
throw new ProviderConfigError("TYPESAFE_API_KEY is not set");
|
|
650
|
+
}
|
|
651
|
+
const model = options.model?.trim() ?? "";
|
|
652
|
+
this.model = model.length > 0 ? model : DEFAULT_MODEL;
|
|
653
|
+
const config = {
|
|
654
|
+
apiKey,
|
|
655
|
+
logLevel: "off",
|
|
656
|
+
timeout: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
657
|
+
retry: { maxRetries: options.maxRetries ?? DEFAULT_MAX_RETRIES }
|
|
658
|
+
};
|
|
659
|
+
const baseURL = options.baseURL?.trim() ?? "";
|
|
660
|
+
if (baseURL.length > 0) {
|
|
661
|
+
config.baseURL = baseURL;
|
|
662
|
+
}
|
|
663
|
+
if (options.fetch !== void 0) {
|
|
664
|
+
config.fetch = options.fetch;
|
|
665
|
+
}
|
|
666
|
+
this.#client = createClient(config);
|
|
667
|
+
}
|
|
668
|
+
async classify(request, options) {
|
|
669
|
+
const specs = Object.entries(request.questions);
|
|
670
|
+
if (specs.length === 0) {
|
|
671
|
+
return { outcomes: {}, usage: { inputTokens: 0, outputTokens: 0 }, model: this.model };
|
|
672
|
+
}
|
|
673
|
+
const questions = {};
|
|
674
|
+
for (const [id, spec] of specs) {
|
|
675
|
+
questions[id] = choice(spec.instructions, { ...spec.options });
|
|
676
|
+
}
|
|
677
|
+
const requestOptions = options?.signal === void 0 ? {} : { signal: options.signal };
|
|
678
|
+
let payload;
|
|
679
|
+
let requestId;
|
|
680
|
+
try {
|
|
681
|
+
const response = await this.#client.systemOne(
|
|
682
|
+
{ state: toStatePayload(request.state), questions, model: this.model },
|
|
683
|
+
requestOptions
|
|
684
|
+
).withResponse();
|
|
685
|
+
payload = response.data;
|
|
686
|
+
requestId = response.requestId;
|
|
687
|
+
} catch (error) {
|
|
688
|
+
throw toProviderError(error);
|
|
689
|
+
}
|
|
690
|
+
const answers = readAnswers(payload);
|
|
691
|
+
const outcomes = {};
|
|
692
|
+
for (const [id, spec] of specs) {
|
|
693
|
+
const raw = answers[id];
|
|
694
|
+
outcomes[id] = raw === void 0 ? { kind: "missing" } : validateChoiceAnswer(raw, Object.keys(spec.options));
|
|
695
|
+
}
|
|
696
|
+
return {
|
|
697
|
+
outcomes,
|
|
698
|
+
usage: readUsage(payload),
|
|
699
|
+
model: readModel(payload) ?? this.model,
|
|
700
|
+
...requestId === void 0 ? {} : { requestId }
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
};
|
|
704
|
+
function createProviderFromEnv(env = process2.env) {
|
|
705
|
+
const apiKey = env.TYPESAFE_API_KEY?.trim() ?? "";
|
|
706
|
+
if (apiKey.length === 0) {
|
|
707
|
+
return void 0;
|
|
708
|
+
}
|
|
709
|
+
const baseURL = env.TYPESAFE_BASE_URL?.trim() ?? "";
|
|
710
|
+
return new TypeSafeJevProvider({ apiKey, ...baseURL.length > 0 ? { baseURL } : {} });
|
|
711
|
+
}
|
|
712
|
+
function validateChoiceAnswer(raw, options) {
|
|
713
|
+
const labels = options;
|
|
714
|
+
if (!isRecord2(raw)) {
|
|
715
|
+
return { kind: "invalid", reason: "answer is not an object" };
|
|
716
|
+
}
|
|
717
|
+
if (raw.type !== "choice") {
|
|
718
|
+
return { kind: "invalid", reason: "answer type is not choice" };
|
|
719
|
+
}
|
|
720
|
+
const selected = raw.choice;
|
|
721
|
+
if (typeof selected !== "string" || !labels.includes(selected)) {
|
|
722
|
+
return { kind: "invalid", reason: "choice is not one of the options" };
|
|
723
|
+
}
|
|
724
|
+
const confidence = raw.confidence;
|
|
725
|
+
if (!isProbability(confidence)) {
|
|
726
|
+
return { kind: "invalid", reason: "confidence is not a number between 0 and 1" };
|
|
727
|
+
}
|
|
728
|
+
const rawProbabilities = raw.probabilities;
|
|
729
|
+
if (!isRecord2(rawProbabilities)) {
|
|
730
|
+
return { kind: "invalid", reason: "probabilities is not an object" };
|
|
731
|
+
}
|
|
732
|
+
const probabilities = {};
|
|
733
|
+
for (const [label, probability] of Object.entries(rawProbabilities)) {
|
|
734
|
+
if (!labels.includes(label)) {
|
|
735
|
+
return { kind: "invalid", reason: "probabilities has a label that is not an option" };
|
|
736
|
+
}
|
|
737
|
+
if (!isProbability(probability)) {
|
|
738
|
+
return {
|
|
739
|
+
kind: "invalid",
|
|
740
|
+
reason: "probabilities has a value that is not a number between 0 and 1"
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
probabilities[label] = probability;
|
|
744
|
+
}
|
|
745
|
+
return {
|
|
746
|
+
kind: "answer",
|
|
747
|
+
answer: {
|
|
748
|
+
choice: selected,
|
|
749
|
+
confidence,
|
|
750
|
+
probabilities
|
|
751
|
+
}
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
function createClient(config) {
|
|
755
|
+
try {
|
|
756
|
+
return new TypeSafeClient(config);
|
|
757
|
+
} catch (error) {
|
|
758
|
+
throw new ProviderConfigError("the TypeSafe client rejected its configuration", {
|
|
759
|
+
cause: error
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
function toProviderError(error) {
|
|
764
|
+
if (error instanceof APIError) {
|
|
765
|
+
return new ProviderError(`TypeSafe request failed (${error.status})`, {
|
|
766
|
+
status: error.status,
|
|
767
|
+
requestId: error.requestId,
|
|
768
|
+
retryable: error.status === 429 || error.status >= 500,
|
|
769
|
+
cause: error
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
if (error instanceof APIConnectionError) {
|
|
773
|
+
return new ProviderError("TypeSafe request failed (network)", {
|
|
774
|
+
retryable: true,
|
|
775
|
+
cause: error
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
return new ProviderError("TypeSafe request failed (network)", {
|
|
779
|
+
retryable: false,
|
|
780
|
+
cause: error
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
function toStatePayload(state) {
|
|
784
|
+
if (typeof state === "number" || typeof state === "boolean") {
|
|
785
|
+
return String(state);
|
|
786
|
+
}
|
|
787
|
+
return state;
|
|
788
|
+
}
|
|
789
|
+
function readAnswers(payload) {
|
|
790
|
+
if (!isRecord2(payload)) {
|
|
791
|
+
return {};
|
|
792
|
+
}
|
|
793
|
+
const answers = payload.answers;
|
|
794
|
+
return isRecord2(answers) ? answers : {};
|
|
795
|
+
}
|
|
796
|
+
function readUsage(payload) {
|
|
797
|
+
if (!isRecord2(payload)) {
|
|
798
|
+
return { inputTokens: 0, outputTokens: 0 };
|
|
799
|
+
}
|
|
800
|
+
const usage = payload.usage;
|
|
801
|
+
if (!isRecord2(usage)) {
|
|
802
|
+
return { inputTokens: 0, outputTokens: 0 };
|
|
803
|
+
}
|
|
804
|
+
return {
|
|
805
|
+
inputTokens: readCount(usage.input_tokens),
|
|
806
|
+
outputTokens: readCount(usage.output_tokens)
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
function readCount(value) {
|
|
810
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
|
|
811
|
+
}
|
|
812
|
+
function readModel(payload) {
|
|
813
|
+
if (!isRecord2(payload)) {
|
|
814
|
+
return void 0;
|
|
815
|
+
}
|
|
816
|
+
const model = payload.model;
|
|
817
|
+
return typeof model === "string" && model.length > 0 ? model : void 0;
|
|
818
|
+
}
|
|
819
|
+
function isProbability(value) {
|
|
820
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1;
|
|
821
|
+
}
|
|
822
|
+
function isRecord2(value) {
|
|
823
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// src/core/fakeProvider.ts
|
|
827
|
+
var FakeProvider = class _FakeProvider {
|
|
828
|
+
requests = [];
|
|
829
|
+
#script;
|
|
830
|
+
constructor(script = {}) {
|
|
831
|
+
this.#script = script;
|
|
832
|
+
}
|
|
833
|
+
async classify(request, options) {
|
|
834
|
+
const recorded = widenRequest(request);
|
|
835
|
+
this.requests.push(recorded);
|
|
836
|
+
this.#script.onRequest?.(recorded);
|
|
837
|
+
const delayMs = this.#script.delayMs ?? 0;
|
|
838
|
+
if (delayMs > 0) {
|
|
839
|
+
await delay(delayMs);
|
|
840
|
+
}
|
|
841
|
+
options?.signal?.throwIfAborted();
|
|
842
|
+
const scriptedError = this.#script.error;
|
|
843
|
+
if (scriptedError !== void 0) {
|
|
844
|
+
throw scriptedError;
|
|
845
|
+
}
|
|
846
|
+
const outcomes = {};
|
|
847
|
+
for (const [id, spec] of Object.entries(request.questions)) {
|
|
848
|
+
const scripted = this.#script.answers?.[id] ?? this.#script.defaultAnswer;
|
|
849
|
+
outcomes[id] = scripted === void 0 ? { kind: "missing" } : validateChoiceAnswer(toPayload(scripted), Object.keys(spec.options));
|
|
850
|
+
}
|
|
851
|
+
return { outcomes, usage: { inputTokens: 0, outputTokens: 0 }, model: "fake" };
|
|
852
|
+
}
|
|
853
|
+
static fromJsonFile(path) {
|
|
854
|
+
let parsed;
|
|
855
|
+
try {
|
|
856
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
857
|
+
} catch (error) {
|
|
858
|
+
throw new ProviderConfigError(`the fake provider script at ${path} could not be read`, {
|
|
859
|
+
cause: error
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
if (!isRecord3(parsed)) {
|
|
863
|
+
throw new ProviderConfigError(`the fake provider script at ${path} is not an object`);
|
|
864
|
+
}
|
|
865
|
+
const script = {};
|
|
866
|
+
const answers = parsed.answers;
|
|
867
|
+
if (answers !== void 0) {
|
|
868
|
+
if (!isRecord3(answers)) {
|
|
869
|
+
throw new ProviderConfigError(`answers in ${path} is not an object`);
|
|
870
|
+
}
|
|
871
|
+
const scripted = {};
|
|
872
|
+
for (const [id, value] of Object.entries(answers)) {
|
|
873
|
+
scripted[id] = toScriptedAnswer(value);
|
|
874
|
+
}
|
|
875
|
+
script.answers = scripted;
|
|
876
|
+
}
|
|
877
|
+
const defaultAnswer = parsed.defaultAnswer;
|
|
878
|
+
if (defaultAnswer !== void 0) {
|
|
879
|
+
const answer = isRecord3(defaultAnswer) ? toChoiceAnswer(defaultAnswer) : void 0;
|
|
880
|
+
if (answer === void 0) {
|
|
881
|
+
throw new ProviderConfigError(`defaultAnswer in ${path} is not a choice answer`);
|
|
882
|
+
}
|
|
883
|
+
script.defaultAnswer = answer;
|
|
884
|
+
}
|
|
885
|
+
const errorMessage = parsed.errorMessage;
|
|
886
|
+
if (errorMessage !== void 0) {
|
|
887
|
+
if (typeof errorMessage !== "string" || errorMessage.length === 0) {
|
|
888
|
+
throw new ProviderConfigError(`errorMessage in ${path} is not a non-empty string`);
|
|
889
|
+
}
|
|
890
|
+
script.error = new ProviderError(errorMessage, { retryable: false });
|
|
891
|
+
}
|
|
892
|
+
return new _FakeProvider(script);
|
|
893
|
+
}
|
|
894
|
+
};
|
|
895
|
+
function toPayload(scripted) {
|
|
896
|
+
if ("raw" in scripted) {
|
|
897
|
+
return scripted.raw;
|
|
898
|
+
}
|
|
899
|
+
return {
|
|
900
|
+
type: "choice",
|
|
901
|
+
choice: scripted.choice,
|
|
902
|
+
confidence: scripted.confidence,
|
|
903
|
+
probabilities: scripted.probabilities
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
function toScriptedAnswer(value) {
|
|
907
|
+
if (!isRecord3(value)) {
|
|
908
|
+
return { raw: value };
|
|
909
|
+
}
|
|
910
|
+
if ("raw" in value) {
|
|
911
|
+
return { raw: value.raw };
|
|
912
|
+
}
|
|
913
|
+
return toChoiceAnswer(value) ?? { raw: value };
|
|
914
|
+
}
|
|
915
|
+
function toChoiceAnswer(value) {
|
|
916
|
+
const selected = value.choice;
|
|
917
|
+
const confidence = value.confidence;
|
|
918
|
+
const rawProbabilities = value.probabilities;
|
|
919
|
+
if (typeof selected !== "string" || typeof confidence !== "number") {
|
|
920
|
+
return void 0;
|
|
921
|
+
}
|
|
922
|
+
if (!isRecord3(rawProbabilities)) {
|
|
923
|
+
return void 0;
|
|
924
|
+
}
|
|
925
|
+
const probabilities = {};
|
|
926
|
+
for (const [label, probability] of Object.entries(rawProbabilities)) {
|
|
927
|
+
if (typeof probability !== "number") {
|
|
928
|
+
return void 0;
|
|
929
|
+
}
|
|
930
|
+
probabilities[label] = probability;
|
|
931
|
+
}
|
|
932
|
+
return { choice: selected, confidence, probabilities };
|
|
933
|
+
}
|
|
934
|
+
function widenRequest(request) {
|
|
935
|
+
const questions = {};
|
|
936
|
+
for (const [id, spec] of Object.entries(request.questions)) {
|
|
937
|
+
questions[id] = { instructions: spec.instructions, options: { ...spec.options } };
|
|
938
|
+
}
|
|
939
|
+
return { state: request.state, questions };
|
|
940
|
+
}
|
|
941
|
+
function delay(ms) {
|
|
942
|
+
return new Promise((resolve3) => {
|
|
943
|
+
setTimeout(resolve3, ms);
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
function isRecord3(value) {
|
|
947
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// src/core/tokens.ts
|
|
951
|
+
var CHARS_PER_TOKEN = 4;
|
|
952
|
+
var DEFAULT_TOKEN_CEILING = 25e3;
|
|
953
|
+
var DEFAULT_PER_QUESTION_TOKENS = 40;
|
|
954
|
+
function estimateTokens(text) {
|
|
955
|
+
return Math.ceil(text.length / CHARS_PER_TOKEN);
|
|
956
|
+
}
|
|
957
|
+
function estimateJsonTokens(value) {
|
|
958
|
+
return estimateTokens(JSON.stringify(value));
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// src/core/windowing.ts
|
|
962
|
+
var DEFAULT_CONCURRENCY = 4;
|
|
963
|
+
function buildWindows(items, options) {
|
|
964
|
+
const ceiling = options.tokenCeiling ?? DEFAULT_TOKEN_CEILING;
|
|
965
|
+
const perQuestionTokens = options.perQuestionTokens ?? DEFAULT_PER_QUESTION_TOKENS;
|
|
966
|
+
const withContext = options.neighborContext ?? true;
|
|
967
|
+
const shared = options.sharedTokens;
|
|
968
|
+
const entries = items.map((item, index) => ({
|
|
969
|
+
index,
|
|
970
|
+
id: item.id,
|
|
971
|
+
tokens: estimateTokens(item.text)
|
|
972
|
+
}));
|
|
973
|
+
const neighbors = buildNeighborIndex(items, entries);
|
|
974
|
+
const packed = [];
|
|
975
|
+
let current = [];
|
|
976
|
+
let currentCost = shared;
|
|
977
|
+
for (const entry of entries) {
|
|
978
|
+
const askCost = entry.tokens + perQuestionTokens;
|
|
979
|
+
if (shared + askCost > ceiling) {
|
|
980
|
+
if (current.length > 0) {
|
|
981
|
+
packed.push(current);
|
|
982
|
+
current = [];
|
|
983
|
+
currentCost = shared;
|
|
984
|
+
}
|
|
985
|
+
packed.push([entry]);
|
|
986
|
+
continue;
|
|
987
|
+
}
|
|
988
|
+
if (current.length > 0 && currentCost + askCost > ceiling) {
|
|
989
|
+
packed.push(current);
|
|
990
|
+
current = [];
|
|
991
|
+
currentCost = shared;
|
|
992
|
+
}
|
|
993
|
+
current.push(entry);
|
|
994
|
+
currentCost += askCost;
|
|
995
|
+
}
|
|
996
|
+
if (current.length > 0) {
|
|
997
|
+
packed.push(current);
|
|
998
|
+
}
|
|
999
|
+
return packed.map((asks) => {
|
|
1000
|
+
const asked = new Set(asks.map((entry) => entry.index));
|
|
1001
|
+
const context = [];
|
|
1002
|
+
let cost = shared;
|
|
1003
|
+
for (const entry of asks) {
|
|
1004
|
+
cost += entry.tokens + perQuestionTokens;
|
|
1005
|
+
}
|
|
1006
|
+
if (withContext) {
|
|
1007
|
+
const taken = /* @__PURE__ */ new Set();
|
|
1008
|
+
for (const entry of asks) {
|
|
1009
|
+
for (const candidate of neighbors.get(entry.index) ?? []) {
|
|
1010
|
+
if (asked.has(candidate.index) || taken.has(candidate.index)) {
|
|
1011
|
+
continue;
|
|
1012
|
+
}
|
|
1013
|
+
if (cost + candidate.tokens > ceiling) {
|
|
1014
|
+
continue;
|
|
1015
|
+
}
|
|
1016
|
+
taken.add(candidate.index);
|
|
1017
|
+
context.push(candidate);
|
|
1018
|
+
cost += candidate.tokens;
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
return {
|
|
1023
|
+
askIds: asks.map((entry) => entry.id),
|
|
1024
|
+
contextIds: context.map((entry) => entry.id),
|
|
1025
|
+
estimatedTokens: cost
|
|
1026
|
+
};
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
async function runWindows(windows, fn, options = {}) {
|
|
1030
|
+
options.signal?.throwIfAborted();
|
|
1031
|
+
if (windows.length === 0) {
|
|
1032
|
+
return [];
|
|
1033
|
+
}
|
|
1034
|
+
const concurrency = Math.max(1, Math.trunc(options.concurrency ?? DEFAULT_CONCURRENCY));
|
|
1035
|
+
const results = new Array(windows.length);
|
|
1036
|
+
const controller = new AbortController();
|
|
1037
|
+
const external = options.signal;
|
|
1038
|
+
const forwardAbort = () => {
|
|
1039
|
+
controller.abort(external?.reason);
|
|
1040
|
+
};
|
|
1041
|
+
external?.addEventListener("abort", forwardAbort, { once: true });
|
|
1042
|
+
let cursor = 0;
|
|
1043
|
+
let failed = false;
|
|
1044
|
+
let failure;
|
|
1045
|
+
const worker = async () => {
|
|
1046
|
+
while (!failed) {
|
|
1047
|
+
const index = cursor;
|
|
1048
|
+
cursor += 1;
|
|
1049
|
+
const window = windows[index];
|
|
1050
|
+
if (window === void 0) {
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
1053
|
+
try {
|
|
1054
|
+
results[index] = await fn(window, index, controller.signal);
|
|
1055
|
+
} catch (error) {
|
|
1056
|
+
if (!failed) {
|
|
1057
|
+
failed = true;
|
|
1058
|
+
failure = error;
|
|
1059
|
+
controller.abort();
|
|
1060
|
+
}
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
};
|
|
1065
|
+
try {
|
|
1066
|
+
await Promise.all(
|
|
1067
|
+
Array.from({ length: Math.min(concurrency, windows.length) }, () => worker())
|
|
1068
|
+
);
|
|
1069
|
+
} finally {
|
|
1070
|
+
external?.removeEventListener("abort", forwardAbort);
|
|
1071
|
+
}
|
|
1072
|
+
if (failed) {
|
|
1073
|
+
throw failure;
|
|
1074
|
+
}
|
|
1075
|
+
return results;
|
|
1076
|
+
}
|
|
1077
|
+
function buildNeighborIndex(items, entries) {
|
|
1078
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1079
|
+
items.forEach((item, index) => {
|
|
1080
|
+
const entry = entries[index];
|
|
1081
|
+
if (entry === void 0) {
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
1084
|
+
const bucket = groups.get(item.group);
|
|
1085
|
+
if (bucket === void 0) {
|
|
1086
|
+
groups.set(item.group, [entry]);
|
|
1087
|
+
} else {
|
|
1088
|
+
bucket.push(entry);
|
|
1089
|
+
}
|
|
1090
|
+
});
|
|
1091
|
+
const neighbors = /* @__PURE__ */ new Map();
|
|
1092
|
+
for (const bucket of groups.values()) {
|
|
1093
|
+
const ordered = [...bucket].sort((left, right) => {
|
|
1094
|
+
const leftItem = items[left.index];
|
|
1095
|
+
const rightItem = items[right.index];
|
|
1096
|
+
if (leftItem === void 0 || rightItem === void 0) {
|
|
1097
|
+
return left.index - right.index;
|
|
1098
|
+
}
|
|
1099
|
+
return leftItem.ordinal - rightItem.ordinal || left.index - right.index;
|
|
1100
|
+
});
|
|
1101
|
+
ordered.forEach((entry, position) => {
|
|
1102
|
+
const candidates = [];
|
|
1103
|
+
const previous = ordered[position - 1];
|
|
1104
|
+
const next = ordered[position + 1];
|
|
1105
|
+
if (previous !== void 0) {
|
|
1106
|
+
candidates.push(previous);
|
|
1107
|
+
}
|
|
1108
|
+
if (next !== void 0) {
|
|
1109
|
+
candidates.push(next);
|
|
1110
|
+
}
|
|
1111
|
+
neighbors.set(entry.index, candidates);
|
|
1112
|
+
});
|
|
1113
|
+
}
|
|
1114
|
+
return neighbors;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
// src/errors.ts
|
|
1118
|
+
var JevStageError = class extends Error {
|
|
1119
|
+
exitCode = 1;
|
|
1120
|
+
};
|
|
1121
|
+
var UsageError = class extends JevStageError {
|
|
1122
|
+
code = "usage";
|
|
1123
|
+
exitCode = 2;
|
|
1124
|
+
constructor(message, options) {
|
|
1125
|
+
super(message, options);
|
|
1126
|
+
this.name = "UsageError";
|
|
1127
|
+
}
|
|
1128
|
+
};
|
|
1129
|
+
var NotARepositoryError = class extends JevStageError {
|
|
1130
|
+
code = "not-a-repository";
|
|
1131
|
+
constructor(message = "not a git repository", options) {
|
|
1132
|
+
super(message, options);
|
|
1133
|
+
this.name = "NotARepositoryError";
|
|
1134
|
+
}
|
|
1135
|
+
};
|
|
1136
|
+
var UnbornRepositoryError = class extends JevStageError {
|
|
1137
|
+
code = "unborn-repository";
|
|
1138
|
+
constructor(message = "the repository has no commits yet", options) {
|
|
1139
|
+
super(message, options);
|
|
1140
|
+
this.name = "UnbornRepositoryError";
|
|
1141
|
+
}
|
|
1142
|
+
};
|
|
1143
|
+
var UnsupportedGitVersionError = class extends JevStageError {
|
|
1144
|
+
code = "unsupported-git-version";
|
|
1145
|
+
found;
|
|
1146
|
+
minimum;
|
|
1147
|
+
constructor(found, minimum, options) {
|
|
1148
|
+
super(`git ${found} is too old; ${minimum} or newer is required`, options);
|
|
1149
|
+
this.name = "UnsupportedGitVersionError";
|
|
1150
|
+
this.found = found;
|
|
1151
|
+
this.minimum = minimum;
|
|
1152
|
+
}
|
|
1153
|
+
};
|
|
1154
|
+
var UnmergedEntriesError = class extends JevStageError {
|
|
1155
|
+
code = "unmerged-entries";
|
|
1156
|
+
paths;
|
|
1157
|
+
constructor(paths, options) {
|
|
1158
|
+
super(`resolve the unmerged paths first: ${paths.join(", ")}`, options);
|
|
1159
|
+
this.name = "UnmergedEntriesError";
|
|
1160
|
+
this.paths = [...paths];
|
|
1161
|
+
}
|
|
1162
|
+
};
|
|
1163
|
+
var UnsupportedEntryError = class extends JevStageError {
|
|
1164
|
+
code = "unsupported-entry";
|
|
1165
|
+
kind;
|
|
1166
|
+
paths;
|
|
1167
|
+
constructor(kind, paths, options) {
|
|
1168
|
+
super(`${kind} entries cannot be staged by hunk: ${paths.join(", ")}`, options);
|
|
1169
|
+
this.name = "UnsupportedEntryError";
|
|
1170
|
+
this.kind = kind;
|
|
1171
|
+
this.paths = [...paths];
|
|
1172
|
+
}
|
|
1173
|
+
};
|
|
1174
|
+
var DiffParseError = class extends JevStageError {
|
|
1175
|
+
code = "diff-parse";
|
|
1176
|
+
constructor(message, options) {
|
|
1177
|
+
super(message, options);
|
|
1178
|
+
this.name = "DiffParseError";
|
|
1179
|
+
}
|
|
1180
|
+
};
|
|
1181
|
+
var UnknownHunkError = class extends JevStageError {
|
|
1182
|
+
code = "unknown-hunk";
|
|
1183
|
+
ids;
|
|
1184
|
+
constructor(ids, options) {
|
|
1185
|
+
super(`no hunk matches the selected ids: ${ids.join(", ")}`, options);
|
|
1186
|
+
this.name = "UnknownHunkError";
|
|
1187
|
+
this.ids = [...ids];
|
|
1188
|
+
}
|
|
1189
|
+
};
|
|
1190
|
+
var PatchApplyError = class extends JevStageError {
|
|
1191
|
+
code = "patch-apply";
|
|
1192
|
+
stderr;
|
|
1193
|
+
constructor(stderr, options) {
|
|
1194
|
+
const detail = stderr.trim();
|
|
1195
|
+
super(detail.length > 0 ? `git apply failed: ${detail}` : "git apply failed", options);
|
|
1196
|
+
this.name = "PatchApplyError";
|
|
1197
|
+
this.stderr = detail;
|
|
1198
|
+
}
|
|
1199
|
+
};
|
|
1200
|
+
var STALE_SNAPSHOT_MESSAGES = {
|
|
1201
|
+
head: "HEAD moved while the plan was being reviewed",
|
|
1202
|
+
index: "the index changed while the plan was being reviewed",
|
|
1203
|
+
diff: "the working tree changed while the plan was being reviewed"
|
|
1204
|
+
};
|
|
1205
|
+
var StaleSnapshotError = class extends JevStageError {
|
|
1206
|
+
code = "stale-snapshot";
|
|
1207
|
+
exitCode = 3;
|
|
1208
|
+
which;
|
|
1209
|
+
constructor(which, options) {
|
|
1210
|
+
super(`${STALE_SNAPSHOT_MESSAGES[which]}; nothing was staged`, options);
|
|
1211
|
+
this.name = "StaleSnapshotError";
|
|
1212
|
+
this.which = which;
|
|
1213
|
+
}
|
|
1214
|
+
};
|
|
1215
|
+
var IndexLockedError = class extends JevStageError {
|
|
1216
|
+
code = "index-locked";
|
|
1217
|
+
exitCode = 3;
|
|
1218
|
+
lockPath;
|
|
1219
|
+
constructor(lockPath, options) {
|
|
1220
|
+
super(`another git process holds ${lockPath}`, options);
|
|
1221
|
+
this.name = "IndexLockedError";
|
|
1222
|
+
this.lockPath = lockPath;
|
|
1223
|
+
}
|
|
1224
|
+
};
|
|
1225
|
+
var MissingApiKeyError = class extends JevStageError {
|
|
1226
|
+
code = "missing-api-key";
|
|
1227
|
+
constructor(message = "TYPESAFE_API_KEY is not set", options) {
|
|
1228
|
+
super(message, options);
|
|
1229
|
+
this.name = "MissingApiKeyError";
|
|
1230
|
+
}
|
|
1231
|
+
};
|
|
1232
|
+
var GitCommandError = class extends JevStageError {
|
|
1233
|
+
code = "git-command";
|
|
1234
|
+
argv;
|
|
1235
|
+
gitExitCode;
|
|
1236
|
+
stderr;
|
|
1237
|
+
constructor(argv, exitCode, stderr, options) {
|
|
1238
|
+
const detail = stderr.trim();
|
|
1239
|
+
const command = `git ${argv.join(" ")}`;
|
|
1240
|
+
super(
|
|
1241
|
+
detail.length > 0 ? `${command} exited ${exitCode}: ${detail}` : `${command} exited ${exitCode}`,
|
|
1242
|
+
options
|
|
1243
|
+
);
|
|
1244
|
+
this.name = "GitCommandError";
|
|
1245
|
+
this.argv = [...argv];
|
|
1246
|
+
this.gitExitCode = exitCode;
|
|
1247
|
+
this.stderr = detail;
|
|
1248
|
+
}
|
|
1249
|
+
};
|
|
1250
|
+
|
|
1251
|
+
// src/git/applySelection.ts
|
|
1252
|
+
import { randomBytes } from "node:crypto";
|
|
1253
|
+
import {
|
|
1254
|
+
closeSync,
|
|
1255
|
+
constants,
|
|
1256
|
+
copyFileSync,
|
|
1257
|
+
fsyncSync,
|
|
1258
|
+
openSync,
|
|
1259
|
+
readdirSync,
|
|
1260
|
+
readFileSync as readFileSync3,
|
|
1261
|
+
renameSync,
|
|
1262
|
+
rmSync,
|
|
1263
|
+
writeSync
|
|
1264
|
+
} from "node:fs";
|
|
1265
|
+
import { basename, dirname, join } from "node:path";
|
|
1266
|
+
|
|
1267
|
+
// src/git/composePatch.ts
|
|
1268
|
+
function composePatch(files, selectedIds) {
|
|
1269
|
+
assertKnownIds(files, selectedIds);
|
|
1270
|
+
const parts = [];
|
|
1271
|
+
let total2 = 0;
|
|
1272
|
+
for (const file of files) {
|
|
1273
|
+
const selected = selectHunks(file, selectedIds);
|
|
1274
|
+
if (selected.length === 0) {
|
|
1275
|
+
continue;
|
|
1276
|
+
}
|
|
1277
|
+
parts.push(file.headerBytes);
|
|
1278
|
+
total2 += file.headerBytes.length;
|
|
1279
|
+
for (const hunk of selected) {
|
|
1280
|
+
parts.push(hunk.bytes);
|
|
1281
|
+
total2 += hunk.bytes.length;
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
return Buffer.concat(parts, total2);
|
|
1285
|
+
}
|
|
1286
|
+
function summarizeSelection(files, selectedIds) {
|
|
1287
|
+
assertKnownIds(files, selectedIds);
|
|
1288
|
+
const perFile = [];
|
|
1289
|
+
let hunks = 0;
|
|
1290
|
+
let added = 0;
|
|
1291
|
+
let removed = 0;
|
|
1292
|
+
for (const file of files) {
|
|
1293
|
+
const selected = selectHunks(file, selectedIds);
|
|
1294
|
+
if (selected.length === 0) {
|
|
1295
|
+
continue;
|
|
1296
|
+
}
|
|
1297
|
+
const fileAdded = total(selected, (hunk) => hunk.added);
|
|
1298
|
+
const fileRemoved = total(selected, (hunk) => hunk.removed);
|
|
1299
|
+
perFile.push({
|
|
1300
|
+
path: file.path,
|
|
1301
|
+
hunks: selected.length,
|
|
1302
|
+
added: fileAdded,
|
|
1303
|
+
removed: fileRemoved
|
|
1304
|
+
});
|
|
1305
|
+
hunks += selected.length;
|
|
1306
|
+
added += fileAdded;
|
|
1307
|
+
removed += fileRemoved;
|
|
1308
|
+
}
|
|
1309
|
+
return { files: perFile.length, hunks, added, removed, perFile };
|
|
1310
|
+
}
|
|
1311
|
+
function selectHunks(file, selectedIds) {
|
|
1312
|
+
return file.hunks.filter((hunk) => selectedIds.has(hunk.id));
|
|
1313
|
+
}
|
|
1314
|
+
function assertKnownIds(files, selectedIds) {
|
|
1315
|
+
const known = /* @__PURE__ */ new Set();
|
|
1316
|
+
for (const file of files) {
|
|
1317
|
+
for (const hunk of file.hunks) {
|
|
1318
|
+
known.add(hunk.id);
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
const unknown = [...selectedIds].filter((id) => !known.has(id)).sort();
|
|
1322
|
+
if (unknown.length > 0) {
|
|
1323
|
+
throw new UnknownHunkError(unknown);
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
function total(hunks, pick) {
|
|
1327
|
+
return hunks.reduce((sum, hunk) => sum + pick(hunk), 0);
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
// src/git/hash.ts
|
|
1331
|
+
import { createHash } from "node:crypto";
|
|
1332
|
+
var HUNK_ID_LENGTH = 16;
|
|
1333
|
+
var SEPARATOR = Buffer.from([0]);
|
|
1334
|
+
function hashBytes(bytes) {
|
|
1335
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
1336
|
+
}
|
|
1337
|
+
function hashHunkId(path, bytes) {
|
|
1338
|
+
return createHash("sha256").update(Buffer.from(path, "utf8")).update(SEPARATOR).update(bytes).digest("hex").slice(0, HUNK_ID_LENGTH);
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
// src/git/runGit.ts
|
|
1342
|
+
import { spawn } from "node:child_process";
|
|
1343
|
+
var DEFAULT_MAX_BUFFER = 512 * 1024 * 1024;
|
|
1344
|
+
var NO_EXIT_CODE = -1;
|
|
1345
|
+
function createGitRunner(gitBinary = "git") {
|
|
1346
|
+
return {
|
|
1347
|
+
run(args, options) {
|
|
1348
|
+
return spawnGit(gitBinary, [...args], options);
|
|
1349
|
+
}
|
|
1350
|
+
};
|
|
1351
|
+
}
|
|
1352
|
+
async function runGitOrThrow(git, args, options) {
|
|
1353
|
+
const result = await git.run(args, options);
|
|
1354
|
+
if (result.exitCode !== 0) {
|
|
1355
|
+
throw new GitCommandError(args, result.exitCode, result.stderr);
|
|
1356
|
+
}
|
|
1357
|
+
return result;
|
|
1358
|
+
}
|
|
1359
|
+
function spawnGit(gitBinary, args, options) {
|
|
1360
|
+
const maxBuffer = options?.maxBuffer ?? DEFAULT_MAX_BUFFER;
|
|
1361
|
+
const extraEnv = options?.env;
|
|
1362
|
+
const input = options?.stdin;
|
|
1363
|
+
return new Promise((resolve3, reject) => {
|
|
1364
|
+
const child = spawn(gitBinary, args, {
|
|
1365
|
+
cwd: options?.cwd ?? process.cwd(),
|
|
1366
|
+
env: extraEnv === void 0 ? process.env : { ...process.env, ...extraEnv },
|
|
1367
|
+
windowsHide: true
|
|
1368
|
+
});
|
|
1369
|
+
const stdoutChunks = [];
|
|
1370
|
+
const stderrChunks = [];
|
|
1371
|
+
let stdoutLength = 0;
|
|
1372
|
+
let settled = false;
|
|
1373
|
+
const fail = (error) => {
|
|
1374
|
+
if (settled) {
|
|
1375
|
+
return;
|
|
1376
|
+
}
|
|
1377
|
+
settled = true;
|
|
1378
|
+
child.kill("SIGKILL");
|
|
1379
|
+
reject(error);
|
|
1380
|
+
};
|
|
1381
|
+
child.on("error", (error) => {
|
|
1382
|
+
fail(new GitCommandError(args, NO_EXIT_CODE, error.message, { cause: error }));
|
|
1383
|
+
});
|
|
1384
|
+
child.stdout.on("data", (chunk) => {
|
|
1385
|
+
stdoutLength += chunk.length;
|
|
1386
|
+
if (stdoutLength > maxBuffer) {
|
|
1387
|
+
fail(new GitCommandError(args, NO_EXIT_CODE, `stdout exceeded ${maxBuffer} bytes`));
|
|
1388
|
+
return;
|
|
1389
|
+
}
|
|
1390
|
+
stdoutChunks.push(chunk);
|
|
1391
|
+
});
|
|
1392
|
+
child.stderr.on("data", (chunk) => {
|
|
1393
|
+
stderrChunks.push(chunk);
|
|
1394
|
+
});
|
|
1395
|
+
child.stdin.on("error", (error) => {
|
|
1396
|
+
if (error.code === "EPIPE" || error.code === "ERR_STREAM_DESTROYED") {
|
|
1397
|
+
return;
|
|
1398
|
+
}
|
|
1399
|
+
fail(new GitCommandError(args, NO_EXIT_CODE, error.message, { cause: error }));
|
|
1400
|
+
});
|
|
1401
|
+
if (input === void 0) {
|
|
1402
|
+
child.stdin.end();
|
|
1403
|
+
} else {
|
|
1404
|
+
child.stdin.end(input);
|
|
1405
|
+
}
|
|
1406
|
+
child.on("close", (code) => {
|
|
1407
|
+
if (settled) {
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
1410
|
+
settled = true;
|
|
1411
|
+
resolve3({
|
|
1412
|
+
stdout: Buffer.concat(stdoutChunks, stdoutLength),
|
|
1413
|
+
stderr: Buffer.concat(stderrChunks).toString("utf8"),
|
|
1414
|
+
exitCode: code ?? NO_EXIT_CODE
|
|
1415
|
+
});
|
|
1416
|
+
});
|
|
1417
|
+
});
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
// src/git/snapshot.ts
|
|
1421
|
+
import { readFileSync as readFileSync2 } from "node:fs";
|
|
1422
|
+
import { resolve } from "node:path";
|
|
1423
|
+
|
|
1424
|
+
// src/git/parseDiff.ts
|
|
1425
|
+
var LF = 10;
|
|
1426
|
+
var CR = 13;
|
|
1427
|
+
var SPACE = 32;
|
|
1428
|
+
var QUOTE = 34;
|
|
1429
|
+
var PLUS = 43;
|
|
1430
|
+
var MINUS = 45;
|
|
1431
|
+
var BACKSLASH = 92;
|
|
1432
|
+
var FILE_HEADER = "diff --git ";
|
|
1433
|
+
var HUNK_HEADER = "@@ ";
|
|
1434
|
+
var OLD_FILE = "--- ";
|
|
1435
|
+
var NEW_FILE = "+++ ";
|
|
1436
|
+
var NEW_FILE_MODE = "new file mode ";
|
|
1437
|
+
var DELETED_FILE_MODE = "deleted file mode ";
|
|
1438
|
+
var OLD_MODE = "old mode ";
|
|
1439
|
+
var NEW_MODE = "new mode ";
|
|
1440
|
+
var INDEX_LINE = "index ";
|
|
1441
|
+
var BINARY_PATCH = "GIT binary patch";
|
|
1442
|
+
var BINARY_FILES = "Binary files ";
|
|
1443
|
+
var BINARY_FILES_SUFFIX = " differ";
|
|
1444
|
+
var DEV_NULL = "/dev/null";
|
|
1445
|
+
var SYMLINK_MODE = "120000";
|
|
1446
|
+
var SUBMODULE_MODE = "160000";
|
|
1447
|
+
var SIDE_PREFIXES = ["a/", "b/"];
|
|
1448
|
+
var DIFF_GIT_PATHS_OVERHEAD = "a/ b/".length;
|
|
1449
|
+
var UNSUPPORTED_KIND_ORDER = ["binary", "symlink", "submodule"];
|
|
1450
|
+
var SIMPLE_ESCAPES = /* @__PURE__ */ new Map([
|
|
1451
|
+
[34, 34],
|
|
1452
|
+
[92, 92],
|
|
1453
|
+
[97, 7],
|
|
1454
|
+
[98, 8],
|
|
1455
|
+
[102, 12],
|
|
1456
|
+
[110, 10],
|
|
1457
|
+
[114, 13],
|
|
1458
|
+
[116, 9],
|
|
1459
|
+
[118, 11]
|
|
1460
|
+
]);
|
|
1461
|
+
function parseUnifiedDiffDetailed(diff) {
|
|
1462
|
+
if (diff.length === 0) {
|
|
1463
|
+
return { files: [], unsupported: [] };
|
|
1464
|
+
}
|
|
1465
|
+
const lines = splitLines(diff);
|
|
1466
|
+
const sectionStarts = findSectionStarts(diff, lines);
|
|
1467
|
+
if (sectionStarts[0] !== 0) {
|
|
1468
|
+
throw new DiffParseError("the diff does not start with a 'diff --git' header");
|
|
1469
|
+
}
|
|
1470
|
+
const files = [];
|
|
1471
|
+
const unsupported = [];
|
|
1472
|
+
for (let index = 0; index < sectionStarts.length; index += 1) {
|
|
1473
|
+
const startLine = sectionStarts[index];
|
|
1474
|
+
if (startLine === void 0) {
|
|
1475
|
+
continue;
|
|
1476
|
+
}
|
|
1477
|
+
const endLine = sectionStarts[index + 1] ?? lines.length;
|
|
1478
|
+
const file = parseSection(diff, lines, startLine, endLine);
|
|
1479
|
+
files.push(file.file);
|
|
1480
|
+
if (file.unsupported !== void 0) {
|
|
1481
|
+
unsupported.push({ path: file.file.path, kind: file.unsupported });
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
assertByteExact(diff, files);
|
|
1485
|
+
return { files, unsupported };
|
|
1486
|
+
}
|
|
1487
|
+
function buildUnsupportedEntryError(entries) {
|
|
1488
|
+
for (const kind of UNSUPPORTED_KIND_ORDER) {
|
|
1489
|
+
const paths = entries.filter((entry) => entry.kind === kind).map((entry) => entry.path);
|
|
1490
|
+
if (paths.length > 0) {
|
|
1491
|
+
return new UnsupportedEntryError(kind, paths);
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
return void 0;
|
|
1495
|
+
}
|
|
1496
|
+
function parseSection(diff, lines, startLine, endLine) {
|
|
1497
|
+
const headerLine = lines[startLine];
|
|
1498
|
+
const lastLine = lines[endLine - 1];
|
|
1499
|
+
if (headerLine === void 0 || lastLine === void 0) {
|
|
1500
|
+
throw new DiffParseError("the diff ends inside a file section");
|
|
1501
|
+
}
|
|
1502
|
+
let firstHunkLine = endLine;
|
|
1503
|
+
for (let index = startLine + 1; index < endLine; index += 1) {
|
|
1504
|
+
const line = lines[index];
|
|
1505
|
+
if (line !== void 0 && lineStartsWith(diff, line, HUNK_HEADER)) {
|
|
1506
|
+
firstHunkLine = index;
|
|
1507
|
+
break;
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
const meta = readFileMeta(diff, lines, startLine, firstHunkLine);
|
|
1511
|
+
const path = resolvePath(diff, headerLine, meta);
|
|
1512
|
+
const hunkStarts = findHunkStarts(diff, lines, firstHunkLine, endLine);
|
|
1513
|
+
const hunks = buildHunks(diff, lines, hunkStarts, endLine, path);
|
|
1514
|
+
const headerEnd = firstHunkLine < endLine ? lines[firstHunkLine]?.start ?? lastLine.end : lastLine.end;
|
|
1515
|
+
return {
|
|
1516
|
+
file: {
|
|
1517
|
+
path,
|
|
1518
|
+
kind: resolveKind(meta, hunks.length),
|
|
1519
|
+
headerBytes: diff.subarray(headerLine.start, headerEnd),
|
|
1520
|
+
hunks
|
|
1521
|
+
},
|
|
1522
|
+
unsupported: resolveUnsupportedKind(meta)
|
|
1523
|
+
};
|
|
1524
|
+
}
|
|
1525
|
+
function readFileMeta(diff, lines, startLine, headerEndLine) {
|
|
1526
|
+
const meta = {
|
|
1527
|
+
newPath: void 0,
|
|
1528
|
+
oldPath: void 0,
|
|
1529
|
+
isNewFile: false,
|
|
1530
|
+
isDeletedFile: false,
|
|
1531
|
+
hasOldMode: false,
|
|
1532
|
+
hasNewMode: false,
|
|
1533
|
+
binary: false,
|
|
1534
|
+
symlink: false,
|
|
1535
|
+
submodule: false
|
|
1536
|
+
};
|
|
1537
|
+
for (let index = startLine + 1; index < headerEndLine; index += 1) {
|
|
1538
|
+
const line = lines[index];
|
|
1539
|
+
if (line === void 0) {
|
|
1540
|
+
continue;
|
|
1541
|
+
}
|
|
1542
|
+
if (lineStartsWith(diff, line, NEW_FILE)) {
|
|
1543
|
+
meta.newPath = pathFromSideLine(diff, line);
|
|
1544
|
+
continue;
|
|
1545
|
+
}
|
|
1546
|
+
if (lineStartsWith(diff, line, OLD_FILE)) {
|
|
1547
|
+
meta.oldPath = pathFromSideLine(diff, line);
|
|
1548
|
+
continue;
|
|
1549
|
+
}
|
|
1550
|
+
const text = decodeLine(diff, line);
|
|
1551
|
+
if (text === BINARY_PATCH) {
|
|
1552
|
+
meta.binary = true;
|
|
1553
|
+
} else if (text.startsWith(BINARY_FILES) && text.endsWith(BINARY_FILES_SUFFIX)) {
|
|
1554
|
+
meta.binary = true;
|
|
1555
|
+
} else if (text.startsWith(NEW_FILE_MODE)) {
|
|
1556
|
+
meta.isNewFile = true;
|
|
1557
|
+
applyMode(meta, text.slice(NEW_FILE_MODE.length));
|
|
1558
|
+
} else if (text.startsWith(DELETED_FILE_MODE)) {
|
|
1559
|
+
meta.isDeletedFile = true;
|
|
1560
|
+
applyMode(meta, text.slice(DELETED_FILE_MODE.length));
|
|
1561
|
+
} else if (text.startsWith(OLD_MODE)) {
|
|
1562
|
+
meta.hasOldMode = true;
|
|
1563
|
+
applyMode(meta, text.slice(OLD_MODE.length));
|
|
1564
|
+
} else if (text.startsWith(NEW_MODE)) {
|
|
1565
|
+
meta.hasNewMode = true;
|
|
1566
|
+
applyMode(meta, text.slice(NEW_MODE.length));
|
|
1567
|
+
} else if (text.startsWith(INDEX_LINE)) {
|
|
1568
|
+
applyMode(meta, text.split(" ")[2] ?? "");
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
return meta;
|
|
1572
|
+
}
|
|
1573
|
+
function applyMode(meta, mode) {
|
|
1574
|
+
const value = mode.trim();
|
|
1575
|
+
if (value === SYMLINK_MODE) {
|
|
1576
|
+
meta.symlink = true;
|
|
1577
|
+
} else if (value === SUBMODULE_MODE) {
|
|
1578
|
+
meta.submodule = true;
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
function resolveKind(meta, hunkCount) {
|
|
1582
|
+
if (meta.isNewFile) {
|
|
1583
|
+
return "added";
|
|
1584
|
+
}
|
|
1585
|
+
if (meta.isDeletedFile) {
|
|
1586
|
+
return "deleted";
|
|
1587
|
+
}
|
|
1588
|
+
if (hunkCount === 0 && meta.hasOldMode && meta.hasNewMode && !meta.binary) {
|
|
1589
|
+
return "mode-only";
|
|
1590
|
+
}
|
|
1591
|
+
return "modified";
|
|
1592
|
+
}
|
|
1593
|
+
function resolveUnsupportedKind(meta) {
|
|
1594
|
+
if (meta.submodule) {
|
|
1595
|
+
return "submodule";
|
|
1596
|
+
}
|
|
1597
|
+
if (meta.symlink) {
|
|
1598
|
+
return "symlink";
|
|
1599
|
+
}
|
|
1600
|
+
if (meta.binary) {
|
|
1601
|
+
return "binary";
|
|
1602
|
+
}
|
|
1603
|
+
return void 0;
|
|
1604
|
+
}
|
|
1605
|
+
function resolvePath(diff, headerLine, meta) {
|
|
1606
|
+
if (meta.newPath !== void 0 && meta.newPath !== DEV_NULL) {
|
|
1607
|
+
return meta.newPath;
|
|
1608
|
+
}
|
|
1609
|
+
if (meta.oldPath !== void 0 && meta.oldPath !== DEV_NULL) {
|
|
1610
|
+
return meta.oldPath;
|
|
1611
|
+
}
|
|
1612
|
+
return pathFromFileHeaderLine(diff, headerLine);
|
|
1613
|
+
}
|
|
1614
|
+
function findSectionStarts(diff, lines) {
|
|
1615
|
+
const starts = [];
|
|
1616
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
1617
|
+
const line = lines[index];
|
|
1618
|
+
if (line !== void 0 && lineStartsWith(diff, line, FILE_HEADER)) {
|
|
1619
|
+
starts.push(index);
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
return starts;
|
|
1623
|
+
}
|
|
1624
|
+
function findHunkStarts(diff, lines, from, to) {
|
|
1625
|
+
const starts = [];
|
|
1626
|
+
for (let index = from; index < to; index += 1) {
|
|
1627
|
+
const line = lines[index];
|
|
1628
|
+
if (line !== void 0 && lineStartsWith(diff, line, HUNK_HEADER)) {
|
|
1629
|
+
starts.push(index);
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
return starts;
|
|
1633
|
+
}
|
|
1634
|
+
function buildHunks(diff, lines, hunkStarts, sectionEndLine, path) {
|
|
1635
|
+
const hunks = [];
|
|
1636
|
+
for (let ordinal = 0; ordinal < hunkStarts.length; ordinal += 1) {
|
|
1637
|
+
const startLine = hunkStarts[ordinal];
|
|
1638
|
+
if (startLine === void 0) {
|
|
1639
|
+
continue;
|
|
1640
|
+
}
|
|
1641
|
+
const endLine = hunkStarts[ordinal + 1] ?? sectionEndLine;
|
|
1642
|
+
const first = lines[startLine];
|
|
1643
|
+
const last = lines[endLine - 1];
|
|
1644
|
+
if (first === void 0 || last === void 0) {
|
|
1645
|
+
throw new DiffParseError("the diff ends inside a hunk");
|
|
1646
|
+
}
|
|
1647
|
+
const bytes = diff.subarray(first.start, last.end);
|
|
1648
|
+
let added = 0;
|
|
1649
|
+
let removed = 0;
|
|
1650
|
+
for (let index = startLine + 1; index < endLine; index += 1) {
|
|
1651
|
+
const line = lines[index];
|
|
1652
|
+
if (line === void 0) {
|
|
1653
|
+
continue;
|
|
1654
|
+
}
|
|
1655
|
+
const marker = diff[line.start];
|
|
1656
|
+
if (marker === PLUS) {
|
|
1657
|
+
added += 1;
|
|
1658
|
+
} else if (marker === MINUS) {
|
|
1659
|
+
removed += 1;
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
hunks.push({
|
|
1663
|
+
id: hashHunkId(path, bytes),
|
|
1664
|
+
path,
|
|
1665
|
+
ordinal,
|
|
1666
|
+
header: decodeLine(diff, first),
|
|
1667
|
+
bytes,
|
|
1668
|
+
text: bytes.toString("utf8"),
|
|
1669
|
+
added,
|
|
1670
|
+
removed
|
|
1671
|
+
});
|
|
1672
|
+
}
|
|
1673
|
+
return hunks;
|
|
1674
|
+
}
|
|
1675
|
+
function assertByteExact(diff, files) {
|
|
1676
|
+
const parts = [];
|
|
1677
|
+
let total2 = 0;
|
|
1678
|
+
for (const file of files) {
|
|
1679
|
+
parts.push(file.headerBytes);
|
|
1680
|
+
total2 += file.headerBytes.length;
|
|
1681
|
+
for (const hunk of file.hunks) {
|
|
1682
|
+
parts.push(hunk.bytes);
|
|
1683
|
+
total2 += hunk.bytes.length;
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
if (total2 !== diff.length) {
|
|
1687
|
+
throw new DiffParseError(`the parsed diff covers ${total2} of ${diff.length} bytes`);
|
|
1688
|
+
}
|
|
1689
|
+
if (!Buffer.concat(parts, total2).equals(diff)) {
|
|
1690
|
+
throw new DiffParseError("the parsed diff does not reassemble to the input bytes");
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
function splitLines(diff) {
|
|
1694
|
+
const lines = [];
|
|
1695
|
+
let start = 0;
|
|
1696
|
+
while (start < diff.length) {
|
|
1697
|
+
const newline = diff.indexOf(LF, start);
|
|
1698
|
+
if (newline === -1) {
|
|
1699
|
+
lines.push({ start, end: diff.length });
|
|
1700
|
+
break;
|
|
1701
|
+
}
|
|
1702
|
+
lines.push({ start, end: newline + 1 });
|
|
1703
|
+
start = newline + 1;
|
|
1704
|
+
}
|
|
1705
|
+
return lines;
|
|
1706
|
+
}
|
|
1707
|
+
function lineStartsWith(diff, line, prefix) {
|
|
1708
|
+
if (line.end - line.start < prefix.length) {
|
|
1709
|
+
return false;
|
|
1710
|
+
}
|
|
1711
|
+
return diff.toString("latin1", line.start, line.start + prefix.length) === prefix;
|
|
1712
|
+
}
|
|
1713
|
+
function contentEnd(diff, line) {
|
|
1714
|
+
let end = line.end;
|
|
1715
|
+
if (end > line.start && diff[end - 1] === LF) {
|
|
1716
|
+
end -= 1;
|
|
1717
|
+
}
|
|
1718
|
+
if (end > line.start && diff[end - 1] === CR) {
|
|
1719
|
+
end -= 1;
|
|
1720
|
+
}
|
|
1721
|
+
return end;
|
|
1722
|
+
}
|
|
1723
|
+
function decodeLine(diff, line) {
|
|
1724
|
+
return diff.toString("utf8", line.start, contentEnd(diff, line));
|
|
1725
|
+
}
|
|
1726
|
+
function pathFromSideLine(diff, line) {
|
|
1727
|
+
const raw = diff.subarray(line.start + OLD_FILE.length, contentEnd(diff, line));
|
|
1728
|
+
if (raw[0] === QUOTE) {
|
|
1729
|
+
return stripSidePrefix(unquoteCStyle(raw));
|
|
1730
|
+
}
|
|
1731
|
+
let value = raw.toString("utf8");
|
|
1732
|
+
if (value.includes(" ") && value.endsWith(" ")) {
|
|
1733
|
+
value = value.slice(0, -1);
|
|
1734
|
+
}
|
|
1735
|
+
return stripSidePrefix(value);
|
|
1736
|
+
}
|
|
1737
|
+
function pathFromFileHeaderLine(diff, line) {
|
|
1738
|
+
const raw = diff.subarray(line.start + FILE_HEADER.length, contentEnd(diff, line));
|
|
1739
|
+
if (raw[0] === QUOTE) {
|
|
1740
|
+
const secondStart = quotedTokenEnd(raw, 0) + 1;
|
|
1741
|
+
if (raw[secondStart - 1] !== SPACE || raw[secondStart] !== QUOTE) {
|
|
1742
|
+
throw new DiffParseError("malformed 'diff --git' header");
|
|
1743
|
+
}
|
|
1744
|
+
return stripSidePrefix(unquoteCStyle(raw.subarray(secondStart)));
|
|
1745
|
+
}
|
|
1746
|
+
const remaining = raw.length - DIFF_GIT_PATHS_OVERHEAD;
|
|
1747
|
+
if (remaining <= 0 || remaining % 2 !== 0) {
|
|
1748
|
+
throw new DiffParseError("malformed 'diff --git' header");
|
|
1749
|
+
}
|
|
1750
|
+
return raw.subarray(raw.length - remaining / 2).toString("utf8");
|
|
1751
|
+
}
|
|
1752
|
+
function stripSidePrefix(value) {
|
|
1753
|
+
if (value === DEV_NULL) {
|
|
1754
|
+
return DEV_NULL;
|
|
1755
|
+
}
|
|
1756
|
+
for (const prefix of SIDE_PREFIXES) {
|
|
1757
|
+
if (value.startsWith(prefix)) {
|
|
1758
|
+
return value.slice(prefix.length);
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
return value;
|
|
1762
|
+
}
|
|
1763
|
+
function quotedTokenEnd(raw, start) {
|
|
1764
|
+
let index = start + 1;
|
|
1765
|
+
while (index < raw.length) {
|
|
1766
|
+
const byte = raw[index];
|
|
1767
|
+
if (byte === BACKSLASH) {
|
|
1768
|
+
index += 2;
|
|
1769
|
+
continue;
|
|
1770
|
+
}
|
|
1771
|
+
if (byte === QUOTE) {
|
|
1772
|
+
return index + 1;
|
|
1773
|
+
}
|
|
1774
|
+
index += 1;
|
|
1775
|
+
}
|
|
1776
|
+
throw new DiffParseError("unterminated quoted path in the diff header");
|
|
1777
|
+
}
|
|
1778
|
+
function unquoteCStyle(raw) {
|
|
1779
|
+
const out = [];
|
|
1780
|
+
let index = 1;
|
|
1781
|
+
let closed = false;
|
|
1782
|
+
while (index < raw.length) {
|
|
1783
|
+
const byte = raw[index];
|
|
1784
|
+
if (byte === void 0) {
|
|
1785
|
+
break;
|
|
1786
|
+
}
|
|
1787
|
+
if (byte === QUOTE) {
|
|
1788
|
+
closed = true;
|
|
1789
|
+
break;
|
|
1790
|
+
}
|
|
1791
|
+
if (byte !== BACKSLASH) {
|
|
1792
|
+
out.push(byte);
|
|
1793
|
+
index += 1;
|
|
1794
|
+
continue;
|
|
1795
|
+
}
|
|
1796
|
+
const escaped = raw[index + 1];
|
|
1797
|
+
if (escaped === void 0) {
|
|
1798
|
+
throw new DiffParseError("truncated escape in a quoted diff path");
|
|
1799
|
+
}
|
|
1800
|
+
if (escaped >= 48 && escaped <= 55) {
|
|
1801
|
+
let value = 0;
|
|
1802
|
+
let digits = 0;
|
|
1803
|
+
while (digits < 3) {
|
|
1804
|
+
const digit = raw[index + 1 + digits];
|
|
1805
|
+
if (digit === void 0 || digit < 48 || digit > 55) {
|
|
1806
|
+
break;
|
|
1807
|
+
}
|
|
1808
|
+
value = value * 8 + (digit - 48);
|
|
1809
|
+
digits += 1;
|
|
1810
|
+
}
|
|
1811
|
+
if (value > 255) {
|
|
1812
|
+
throw new DiffParseError("octal escape out of range in a quoted diff path");
|
|
1813
|
+
}
|
|
1814
|
+
out.push(value);
|
|
1815
|
+
index += 1 + digits;
|
|
1816
|
+
continue;
|
|
1817
|
+
}
|
|
1818
|
+
const mapped = SIMPLE_ESCAPES.get(escaped);
|
|
1819
|
+
if (mapped === void 0) {
|
|
1820
|
+
throw new DiffParseError("unknown escape in a quoted diff path");
|
|
1821
|
+
}
|
|
1822
|
+
out.push(mapped);
|
|
1823
|
+
index += 2;
|
|
1824
|
+
}
|
|
1825
|
+
if (!closed) {
|
|
1826
|
+
throw new DiffParseError("unterminated quoted path in the diff header");
|
|
1827
|
+
}
|
|
1828
|
+
return Buffer.from(out).toString("utf8");
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
// src/git/snapshot.ts
|
|
1832
|
+
var DIFF_ARGS = Object.freeze([
|
|
1833
|
+
"-c",
|
|
1834
|
+
"core.quotePath=true",
|
|
1835
|
+
"-c",
|
|
1836
|
+
"diff.suppressBlankEmpty=false",
|
|
1837
|
+
"-c",
|
|
1838
|
+
"diff.noprefix=false",
|
|
1839
|
+
"-c",
|
|
1840
|
+
"diff.mnemonicPrefix=false",
|
|
1841
|
+
"diff",
|
|
1842
|
+
"--no-color",
|
|
1843
|
+
"--binary",
|
|
1844
|
+
"--full-index",
|
|
1845
|
+
"--no-ext-diff",
|
|
1846
|
+
"--no-textconv",
|
|
1847
|
+
"--no-renames",
|
|
1848
|
+
"--ignore-submodules=dirty",
|
|
1849
|
+
"--unified=6",
|
|
1850
|
+
"--src-prefix=a/",
|
|
1851
|
+
"--dst-prefix=b/",
|
|
1852
|
+
"--no-relative"
|
|
1853
|
+
]);
|
|
1854
|
+
var MIN_GIT_MAJOR = 2;
|
|
1855
|
+
var MIN_GIT_MINOR = 30;
|
|
1856
|
+
var MIN_GIT_VERSION = `${MIN_GIT_MAJOR}.${MIN_GIT_MINOR}`;
|
|
1857
|
+
var GIT_VERSION_LINE = /^git version (\d+)\.(\d+)/;
|
|
1858
|
+
async function captureSnapshot({ cwd, git }) {
|
|
1859
|
+
const gitEnv = resolveGitEnv();
|
|
1860
|
+
await assertSupportedGitVersion(git, cwd, gitEnv);
|
|
1861
|
+
const locations = await resolveLocations(git, cwd, gitEnv);
|
|
1862
|
+
const headOid = await resolveHeadOid(git, locations.workTree, gitEnv);
|
|
1863
|
+
await assertNoUnmergedEntries(git, locations.workTree, gitEnv);
|
|
1864
|
+
const diff = await runGitOrThrow(git, [...DIFF_ARGS], { cwd: locations.workTree, env: gitEnv });
|
|
1865
|
+
const diffBytes = diff.stdout;
|
|
1866
|
+
const parsed = parseUnifiedDiffDetailed(diffBytes);
|
|
1867
|
+
const unsupported = buildUnsupportedEntryError(parsed.unsupported);
|
|
1868
|
+
if (unsupported !== void 0) {
|
|
1869
|
+
throw unsupported;
|
|
1870
|
+
}
|
|
1871
|
+
return {
|
|
1872
|
+
workTree: locations.workTree,
|
|
1873
|
+
gitDir: locations.gitDir,
|
|
1874
|
+
indexPath: locations.indexPath,
|
|
1875
|
+
gitEnv,
|
|
1876
|
+
headOid,
|
|
1877
|
+
indexHash: hashBytes(readIndexBytes(locations.indexPath)),
|
|
1878
|
+
diffHash: hashBytes(diffBytes),
|
|
1879
|
+
diffBytes,
|
|
1880
|
+
files: parsed.files.filter((file) => file.hunks.length > 0),
|
|
1881
|
+
skipped: parsed.files.filter((file) => file.hunks.length === 0).map((file) => ({ path: file.path, reason: skippedReason(file.kind) }))
|
|
1882
|
+
};
|
|
1883
|
+
}
|
|
1884
|
+
function skippedReason(kind) {
|
|
1885
|
+
return kind === "mode-only" ? "mode-only" : "empty-file";
|
|
1886
|
+
}
|
|
1887
|
+
function resolveGitEnv() {
|
|
1888
|
+
const envIndex = process.env.GIT_INDEX_FILE;
|
|
1889
|
+
if (envIndex === void 0 || envIndex.length === 0) {
|
|
1890
|
+
return {};
|
|
1891
|
+
}
|
|
1892
|
+
return { GIT_INDEX_FILE: resolve(process.cwd(), envIndex) };
|
|
1893
|
+
}
|
|
1894
|
+
async function assertSupportedGitVersion(git, cwd, env) {
|
|
1895
|
+
const result = await runGitOrThrow(git, ["--version"], { cwd, env });
|
|
1896
|
+
const line = firstLine(result.stdout.toString("utf8"));
|
|
1897
|
+
const match = GIT_VERSION_LINE.exec(line);
|
|
1898
|
+
const major = Number(match?.[1]);
|
|
1899
|
+
const minor = Number(match?.[2]);
|
|
1900
|
+
if (!Number.isInteger(major) || !Number.isInteger(minor)) {
|
|
1901
|
+
throw new UnsupportedGitVersionError(line, MIN_GIT_VERSION);
|
|
1902
|
+
}
|
|
1903
|
+
if (major < MIN_GIT_MAJOR || major === MIN_GIT_MAJOR && minor < MIN_GIT_MINOR) {
|
|
1904
|
+
throw new UnsupportedGitVersionError(`${major}.${minor}`, MIN_GIT_VERSION);
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
async function resolveLocations(git, cwd, env) {
|
|
1908
|
+
const result = await git.run(
|
|
1909
|
+
["rev-parse", "--show-toplevel", "--git-dir", "--git-path", "index"],
|
|
1910
|
+
{
|
|
1911
|
+
cwd,
|
|
1912
|
+
env
|
|
1913
|
+
}
|
|
1914
|
+
);
|
|
1915
|
+
if (result.exitCode !== 0) {
|
|
1916
|
+
throw new NotARepositoryError();
|
|
1917
|
+
}
|
|
1918
|
+
const lines = splitOutputLines(result.stdout.toString("utf8"));
|
|
1919
|
+
const workTree = lines[0];
|
|
1920
|
+
const gitDir = lines[1];
|
|
1921
|
+
const indexFile = lines[2];
|
|
1922
|
+
if (workTree === void 0 || gitDir === void 0 || indexFile === void 0) {
|
|
1923
|
+
throw new NotARepositoryError();
|
|
1924
|
+
}
|
|
1925
|
+
return {
|
|
1926
|
+
workTree: resolve(cwd, workTree),
|
|
1927
|
+
gitDir: resolve(cwd, gitDir),
|
|
1928
|
+
indexPath: env.GIT_INDEX_FILE ?? resolve(cwd, indexFile)
|
|
1929
|
+
};
|
|
1930
|
+
}
|
|
1931
|
+
async function resolveHeadOid(git, cwd, env) {
|
|
1932
|
+
const result = await git.run(["rev-parse", "--verify", "HEAD"], { cwd, env });
|
|
1933
|
+
const oid = result.stdout.toString("utf8").trim();
|
|
1934
|
+
if (result.exitCode !== 0 || oid.length === 0) {
|
|
1935
|
+
throw new UnbornRepositoryError();
|
|
1936
|
+
}
|
|
1937
|
+
return oid;
|
|
1938
|
+
}
|
|
1939
|
+
async function assertNoUnmergedEntries(git, cwd, env) {
|
|
1940
|
+
const result = await runGitOrThrow(git, ["ls-files", "-u", "-z"], { cwd, env });
|
|
1941
|
+
if (result.stdout.length === 0) {
|
|
1942
|
+
return;
|
|
1943
|
+
}
|
|
1944
|
+
const paths = [];
|
|
1945
|
+
for (const record of result.stdout.toString("utf8").split("\0")) {
|
|
1946
|
+
if (record.length === 0) {
|
|
1947
|
+
continue;
|
|
1948
|
+
}
|
|
1949
|
+
const tab = record.indexOf(" ");
|
|
1950
|
+
const path = tab === -1 ? record : record.slice(tab + 1);
|
|
1951
|
+
if (!paths.includes(path)) {
|
|
1952
|
+
paths.push(path);
|
|
1953
|
+
}
|
|
1954
|
+
}
|
|
1955
|
+
if (paths.length > 0) {
|
|
1956
|
+
throw new UnmergedEntriesError(paths);
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
function readIndexBytes(indexPath) {
|
|
1960
|
+
try {
|
|
1961
|
+
return readFileSync2(indexPath);
|
|
1962
|
+
} catch (error) {
|
|
1963
|
+
if (isMissingFile(error)) {
|
|
1964
|
+
return Buffer.alloc(0);
|
|
1965
|
+
}
|
|
1966
|
+
throw error;
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
function isMissingFile(error) {
|
|
1970
|
+
if (!(error instanceof Error) || !("code" in error)) {
|
|
1971
|
+
return false;
|
|
1972
|
+
}
|
|
1973
|
+
return error.code === "ENOENT" || error.code === "EISDIR";
|
|
1974
|
+
}
|
|
1975
|
+
function firstLine(output) {
|
|
1976
|
+
return stripCarriageReturn(output.split("\n")[0] ?? "").trim();
|
|
1977
|
+
}
|
|
1978
|
+
function splitOutputLines(output) {
|
|
1979
|
+
return output.split("\n").map(stripCarriageReturn).filter((line) => line.length > 0);
|
|
1980
|
+
}
|
|
1981
|
+
function stripCarriageReturn(line) {
|
|
1982
|
+
return line.endsWith("\r") ? line.slice(0, -1) : line;
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
// src/git/applySelection.ts
|
|
1986
|
+
var LOCK_SUFFIX = ".lock";
|
|
1987
|
+
var LOCK_MODE = 420;
|
|
1988
|
+
var TEMP_MARKER = ".jev-stage-";
|
|
1989
|
+
var TEMP_SUFFIX_BYTES = 8;
|
|
1990
|
+
var TEMP_SUFFIX = /^(\d+)-[0-9a-f]+$/;
|
|
1991
|
+
var ownedPaths = /* @__PURE__ */ new Set();
|
|
1992
|
+
var exitHookInstalled = false;
|
|
1993
|
+
async function applyPatchToIndex({
|
|
1994
|
+
snapshot,
|
|
1995
|
+
patch,
|
|
1996
|
+
git
|
|
1997
|
+
}) {
|
|
1998
|
+
if (patch.length === 0) {
|
|
1999
|
+
return;
|
|
2000
|
+
}
|
|
2001
|
+
sweepAbandonedTempIndexes(snapshot.indexPath);
|
|
2002
|
+
const tempPath = await createTempIndex(snapshot, git);
|
|
2003
|
+
const apply = { git, snapshot, patch, tempPath };
|
|
2004
|
+
try {
|
|
2005
|
+
await runApply(apply, ["apply", "--cached", "--check"]);
|
|
2006
|
+
await runApply(apply, ["apply", "--cached"]);
|
|
2007
|
+
await verifyTempIndex(apply);
|
|
2008
|
+
} catch (error) {
|
|
2009
|
+
discard(tempPath);
|
|
2010
|
+
throw error;
|
|
2011
|
+
}
|
|
2012
|
+
const lockPath = `${snapshot.indexPath}${LOCK_SUFFIX}`;
|
|
2013
|
+
const lockFd = openLock(lockPath, tempPath);
|
|
2014
|
+
let lockClosed = false;
|
|
2015
|
+
try {
|
|
2016
|
+
await assertSnapshotIsCurrent(git, snapshot);
|
|
2017
|
+
writeAll(lockFd, readFileSync3(tempPath));
|
|
2018
|
+
fsyncSync(lockFd);
|
|
2019
|
+
closeSync(lockFd);
|
|
2020
|
+
lockClosed = true;
|
|
2021
|
+
renameSync(lockPath, snapshot.indexPath);
|
|
2022
|
+
untrack(lockPath);
|
|
2023
|
+
} catch (error) {
|
|
2024
|
+
if (!lockClosed) {
|
|
2025
|
+
closeQuietly(lockFd);
|
|
2026
|
+
}
|
|
2027
|
+
discard(lockPath);
|
|
2028
|
+
discard(tempPath);
|
|
2029
|
+
throw error;
|
|
2030
|
+
}
|
|
2031
|
+
discard(tempPath);
|
|
2032
|
+
}
|
|
2033
|
+
async function stageHunks(snapshot, selectedIds, git) {
|
|
2034
|
+
const patch = composePatch(snapshot.files, selectedIds);
|
|
2035
|
+
await applyPatchToIndex({ snapshot, patch, git });
|
|
2036
|
+
return patch;
|
|
2037
|
+
}
|
|
2038
|
+
function sweepAbandonedTempIndexes(indexPath) {
|
|
2039
|
+
const directory = dirname(indexPath);
|
|
2040
|
+
const prefix = `${basename(indexPath)}${TEMP_MARKER}`;
|
|
2041
|
+
let names;
|
|
2042
|
+
try {
|
|
2043
|
+
names = readdirSync(directory);
|
|
2044
|
+
} catch {
|
|
2045
|
+
return;
|
|
2046
|
+
}
|
|
2047
|
+
for (const name of names) {
|
|
2048
|
+
if (!name.startsWith(prefix)) {
|
|
2049
|
+
continue;
|
|
2050
|
+
}
|
|
2051
|
+
const match = TEMP_SUFFIX.exec(name.slice(prefix.length));
|
|
2052
|
+
if (match === null) {
|
|
2053
|
+
continue;
|
|
2054
|
+
}
|
|
2055
|
+
const pid = Number(match[1]);
|
|
2056
|
+
if (!Number.isSafeInteger(pid) || pid <= 0 || isProcessAlive(pid)) {
|
|
2057
|
+
continue;
|
|
2058
|
+
}
|
|
2059
|
+
removeQuietly(join(directory, name));
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
2062
|
+
function isProcessAlive(pid) {
|
|
2063
|
+
try {
|
|
2064
|
+
process.kill(pid, 0);
|
|
2065
|
+
return true;
|
|
2066
|
+
} catch (error) {
|
|
2067
|
+
return !hasErrnoCode(error, "ESRCH");
|
|
2068
|
+
}
|
|
2069
|
+
}
|
|
2070
|
+
async function createTempIndex(snapshot, git) {
|
|
2071
|
+
const suffix = randomBytes(TEMP_SUFFIX_BYTES).toString("hex");
|
|
2072
|
+
const tempPath = `${snapshot.indexPath}${TEMP_MARKER}${process.pid}-${suffix}`;
|
|
2073
|
+
track(tempPath);
|
|
2074
|
+
try {
|
|
2075
|
+
copyFileSync(snapshot.indexPath, tempPath, constants.COPYFILE_EXCL);
|
|
2076
|
+
} catch (error) {
|
|
2077
|
+
if (!hasErrnoCode(error, "ENOENT")) {
|
|
2078
|
+
discard(tempPath);
|
|
2079
|
+
throw error;
|
|
2080
|
+
}
|
|
2081
|
+
try {
|
|
2082
|
+
await runGitOrThrow(git, ["read-tree", "HEAD"], {
|
|
2083
|
+
cwd: snapshot.workTree,
|
|
2084
|
+
env: { ...snapshot.gitEnv, GIT_INDEX_FILE: tempPath }
|
|
2085
|
+
});
|
|
2086
|
+
} catch (readTreeError) {
|
|
2087
|
+
discard(tempPath);
|
|
2088
|
+
throw readTreeError;
|
|
2089
|
+
}
|
|
2090
|
+
}
|
|
2091
|
+
return tempPath;
|
|
2092
|
+
}
|
|
2093
|
+
function tempIndexEnv(apply) {
|
|
2094
|
+
return { ...apply.snapshot.gitEnv, GIT_INDEX_FILE: apply.tempPath };
|
|
2095
|
+
}
|
|
2096
|
+
async function runApply(apply, args) {
|
|
2097
|
+
const result = await apply.git.run(args, {
|
|
2098
|
+
cwd: apply.snapshot.workTree,
|
|
2099
|
+
env: tempIndexEnv(apply),
|
|
2100
|
+
stdin: apply.patch
|
|
2101
|
+
});
|
|
2102
|
+
if (result.exitCode !== 0) {
|
|
2103
|
+
throw new PatchApplyError(result.stderr);
|
|
2104
|
+
}
|
|
2105
|
+
}
|
|
2106
|
+
async function verifyTempIndex(apply) {
|
|
2107
|
+
await runApply(apply, ["apply", "--cached", "--check", "-R"]);
|
|
2108
|
+
const listed = await apply.git.run(["ls-files", "-s"], {
|
|
2109
|
+
cwd: apply.snapshot.workTree,
|
|
2110
|
+
env: tempIndexEnv(apply)
|
|
2111
|
+
});
|
|
2112
|
+
if (listed.exitCode !== 0) {
|
|
2113
|
+
throw new PatchApplyError(listed.stderr);
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
async function assertSnapshotIsCurrent(git, snapshot) {
|
|
2117
|
+
const head = await runGitOrThrow(git, ["rev-parse", "HEAD"], {
|
|
2118
|
+
cwd: snapshot.workTree,
|
|
2119
|
+
env: snapshot.gitEnv
|
|
2120
|
+
});
|
|
2121
|
+
if (head.stdout.toString("utf8").trim() !== snapshot.headOid) {
|
|
2122
|
+
throw new StaleSnapshotError("head");
|
|
2123
|
+
}
|
|
2124
|
+
if (hashBytes(readIndexBytes(snapshot.indexPath)) !== snapshot.indexHash) {
|
|
2125
|
+
throw new StaleSnapshotError("index");
|
|
2126
|
+
}
|
|
2127
|
+
const diff = await runGitOrThrow(git, [...DIFF_ARGS], {
|
|
2128
|
+
cwd: snapshot.workTree,
|
|
2129
|
+
env: { ...snapshot.gitEnv, GIT_INDEX_FILE: snapshot.indexPath }
|
|
2130
|
+
});
|
|
2131
|
+
if (hashBytes(diff.stdout) !== snapshot.diffHash) {
|
|
2132
|
+
throw new StaleSnapshotError("diff");
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
function openLock(lockPath, tempPath) {
|
|
2136
|
+
let fd;
|
|
2137
|
+
try {
|
|
2138
|
+
fd = openSync(lockPath, "wx", LOCK_MODE);
|
|
2139
|
+
} catch (error) {
|
|
2140
|
+
discard(tempPath);
|
|
2141
|
+
if (hasErrnoCode(error, "EEXIST")) {
|
|
2142
|
+
throw new IndexLockedError(lockPath, { cause: error });
|
|
2143
|
+
}
|
|
2144
|
+
throw error;
|
|
2145
|
+
}
|
|
2146
|
+
track(lockPath);
|
|
2147
|
+
return fd;
|
|
2148
|
+
}
|
|
2149
|
+
function writeAll(fd, bytes) {
|
|
2150
|
+
let offset = 0;
|
|
2151
|
+
while (offset < bytes.length) {
|
|
2152
|
+
offset += writeSync(fd, bytes, offset, bytes.length - offset);
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
function track(path) {
|
|
2156
|
+
ownedPaths.add(path);
|
|
2157
|
+
if (!exitHookInstalled) {
|
|
2158
|
+
exitHookInstalled = true;
|
|
2159
|
+
process.on("exit", cleanupOwnedPaths);
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
2162
|
+
function untrack(path) {
|
|
2163
|
+
ownedPaths.delete(path);
|
|
2164
|
+
}
|
|
2165
|
+
function discard(path) {
|
|
2166
|
+
untrack(path);
|
|
2167
|
+
removeQuietly(path);
|
|
2168
|
+
}
|
|
2169
|
+
function cleanupOwnedPaths() {
|
|
2170
|
+
for (const path of [...ownedPaths]) {
|
|
2171
|
+
ownedPaths.delete(path);
|
|
2172
|
+
removeQuietly(path);
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2175
|
+
function removeQuietly(path) {
|
|
2176
|
+
try {
|
|
2177
|
+
rmSync(path, { force: true });
|
|
2178
|
+
} catch {
|
|
2179
|
+
return;
|
|
2180
|
+
}
|
|
2181
|
+
}
|
|
2182
|
+
function closeQuietly(fd) {
|
|
2183
|
+
try {
|
|
2184
|
+
closeSync(fd);
|
|
2185
|
+
} catch {
|
|
2186
|
+
return;
|
|
2187
|
+
}
|
|
2188
|
+
}
|
|
2189
|
+
function hasErrnoCode(error, code) {
|
|
2190
|
+
return error instanceof Error && "code" in error && error.code === code;
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2193
|
+
// src/selection/policy.ts
|
|
2194
|
+
var DEFAULT_THRESHOLD = 0.6;
|
|
2195
|
+
var DECISION_LABELS = Object.freeze([
|
|
2196
|
+
"include",
|
|
2197
|
+
"exclude",
|
|
2198
|
+
"mixed"
|
|
2199
|
+
]);
|
|
2200
|
+
function decide(outcome, threshold) {
|
|
2201
|
+
if (outcome.kind === "missing") {
|
|
2202
|
+
return { decision: "mixed", source: "missing" };
|
|
2203
|
+
}
|
|
2204
|
+
if (outcome.kind === "invalid") {
|
|
2205
|
+
return { decision: "mixed", source: "invalid" };
|
|
2206
|
+
}
|
|
2207
|
+
const { choice: choice2, confidence, probabilities } = outcome.answer;
|
|
2208
|
+
if (choice2 === "mixed") {
|
|
2209
|
+
return { decision: "mixed", source: "model", confidence, probabilities };
|
|
2210
|
+
}
|
|
2211
|
+
if (confidence >= threshold) {
|
|
2212
|
+
return { decision: choice2, source: "model", confidence, probabilities };
|
|
2213
|
+
}
|
|
2214
|
+
return { decision: "mixed", source: "low-confidence", confidence, probabilities };
|
|
2215
|
+
}
|
|
2216
|
+
function validateThreshold(threshold) {
|
|
2217
|
+
if (!Number.isFinite(threshold) || threshold <= 0 || threshold > 1) {
|
|
2218
|
+
throw new UsageError(`threshold must be greater than 0 and at most 1, got ${threshold}`);
|
|
2219
|
+
}
|
|
2220
|
+
return threshold;
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2223
|
+
// src/selection/classify.ts
|
|
2224
|
+
var CLASSIFY_CONCURRENCY = 4;
|
|
2225
|
+
var BASE_INSTRUCTIONS = "Decide, for each hunk marked role=ask, whether its changed lines belong to the change described by intent. Context hunks are for reference only.";
|
|
2226
|
+
var EXCLUDE_INSTRUCTIONS = " Lines that match exclude never belong.";
|
|
2227
|
+
var HUNK_OPTIONS = Object.freeze({
|
|
2228
|
+
include: "every changed line in this hunk belongs to the described change",
|
|
2229
|
+
exclude: "no changed line in this hunk belongs to the described change",
|
|
2230
|
+
mixed: "some changed lines belong and some do not"
|
|
2231
|
+
});
|
|
2232
|
+
async function classifyHunks(options) {
|
|
2233
|
+
const { snapshot, intent, exclude, threshold, provider, tokenCeiling } = options;
|
|
2234
|
+
const instructions = buildInstructions(exclude);
|
|
2235
|
+
const ceiling = tokenCeiling ?? DEFAULT_TOKEN_CEILING;
|
|
2236
|
+
const sharedTokens = envelopeTokens(intent, exclude);
|
|
2237
|
+
const windows = buildWindows(buildItems(snapshot), {
|
|
2238
|
+
sharedTokens,
|
|
2239
|
+
...tokenCeiling === void 0 ? {} : { tokenCeiling }
|
|
2240
|
+
});
|
|
2241
|
+
const decisions = /* @__PURE__ */ new Map();
|
|
2242
|
+
const sendable = [];
|
|
2243
|
+
for (const window of windows) {
|
|
2244
|
+
if (window.estimatedTokens > ceiling) {
|
|
2245
|
+
for (const hunkId of window.askIds) {
|
|
2246
|
+
decisions.set(hunkId, { hunkId, decision: "mixed", source: "too-large" });
|
|
2247
|
+
}
|
|
2248
|
+
continue;
|
|
2249
|
+
}
|
|
2250
|
+
sendable.push(window);
|
|
2251
|
+
}
|
|
2252
|
+
const results = await runWindows(
|
|
2253
|
+
sendable,
|
|
2254
|
+
(window, _index, signal) => provider.classify(buildRequest(snapshot, intent, exclude, instructions, window), { signal }),
|
|
2255
|
+
{
|
|
2256
|
+
concurrency: CLASSIFY_CONCURRENCY,
|
|
2257
|
+
...options.signal === void 0 ? {} : { signal: options.signal }
|
|
2258
|
+
}
|
|
2259
|
+
);
|
|
2260
|
+
const usage = { requests: sendable.length, inputTokens: 0, outputTokens: 0 };
|
|
2261
|
+
results.forEach((result, index) => {
|
|
2262
|
+
usage.inputTokens += result.usage.inputTokens;
|
|
2263
|
+
usage.outputTokens += result.usage.outputTokens;
|
|
2264
|
+
const window = sendable[index];
|
|
2265
|
+
if (window === void 0) {
|
|
2266
|
+
return;
|
|
2267
|
+
}
|
|
2268
|
+
for (const hunkId of window.askIds) {
|
|
2269
|
+
const outcome = result.outcomes[hunkId] ?? { kind: "missing" };
|
|
2270
|
+
decisions.set(hunkId, { hunkId, ...decide(outcome, threshold) });
|
|
2271
|
+
}
|
|
2272
|
+
});
|
|
2273
|
+
return { decisions, usage };
|
|
2274
|
+
}
|
|
2275
|
+
function envelopeTokens(intent, exclude) {
|
|
2276
|
+
return estimateJsonTokens(buildState(intent, exclude, buildInstructions(exclude), []));
|
|
2277
|
+
}
|
|
2278
|
+
function buildInstructions(exclude) {
|
|
2279
|
+
return exclude === void 0 ? BASE_INSTRUCTIONS : `${BASE_INSTRUCTIONS}${EXCLUDE_INSTRUCTIONS}`;
|
|
2280
|
+
}
|
|
2281
|
+
function buildItems(snapshot) {
|
|
2282
|
+
const items = [];
|
|
2283
|
+
for (const file of snapshot.files) {
|
|
2284
|
+
for (const hunk of file.hunks) {
|
|
2285
|
+
items.push({ id: hunk.id, text: hunk.text, group: file.path, ordinal: hunk.ordinal });
|
|
2286
|
+
}
|
|
2287
|
+
}
|
|
2288
|
+
return items;
|
|
2289
|
+
}
|
|
2290
|
+
function buildRequest(snapshot, intent, exclude, instructions, window) {
|
|
2291
|
+
const roles = /* @__PURE__ */ new Map();
|
|
2292
|
+
for (const hunkId of window.contextIds) {
|
|
2293
|
+
roles.set(hunkId, "context");
|
|
2294
|
+
}
|
|
2295
|
+
for (const hunkId of window.askIds) {
|
|
2296
|
+
roles.set(hunkId, "ask");
|
|
2297
|
+
}
|
|
2298
|
+
const files = [];
|
|
2299
|
+
for (const file of snapshot.files) {
|
|
2300
|
+
const hunks = [];
|
|
2301
|
+
for (const hunk of file.hunks) {
|
|
2302
|
+
const role = roles.get(hunk.id);
|
|
2303
|
+
if (role === void 0) {
|
|
2304
|
+
continue;
|
|
2305
|
+
}
|
|
2306
|
+
hunks.push({ id: hunk.id, header: hunk.header, patch: hunk.text, role });
|
|
2307
|
+
}
|
|
2308
|
+
if (hunks.length > 0) {
|
|
2309
|
+
files.push({ path: file.path, hunks });
|
|
2310
|
+
}
|
|
2311
|
+
}
|
|
2312
|
+
const questions = {};
|
|
2313
|
+
for (const hunkId of window.askIds) {
|
|
2314
|
+
questions[hunkId] = {
|
|
2315
|
+
instructions: `Does hunk ${hunkId} belong to the described change?`,
|
|
2316
|
+
options: { ...HUNK_OPTIONS }
|
|
2317
|
+
};
|
|
2318
|
+
}
|
|
2319
|
+
return { state: buildState(intent, exclude, instructions, files), questions };
|
|
2320
|
+
}
|
|
2321
|
+
function buildState(intent, exclude, instructions, files) {
|
|
2322
|
+
return {
|
|
2323
|
+
intent,
|
|
2324
|
+
...exclude === void 0 ? {} : { exclude },
|
|
2325
|
+
instructions,
|
|
2326
|
+
files
|
|
2327
|
+
};
|
|
2328
|
+
}
|
|
2329
|
+
|
|
2330
|
+
// src/library.ts
|
|
2331
|
+
async function planSelection(options) {
|
|
2332
|
+
const threshold = validateThreshold(options.threshold ?? DEFAULT_THRESHOLD);
|
|
2333
|
+
const git = options.git ?? createGitRunner();
|
|
2334
|
+
const snapshot = await captureSnapshot({ cwd: options.cwd, git });
|
|
2335
|
+
const { intent, exclude, provider } = options;
|
|
2336
|
+
const excludeField = exclude === void 0 ? {} : { exclude };
|
|
2337
|
+
if (provider === void 0) {
|
|
2338
|
+
return {
|
|
2339
|
+
intent,
|
|
2340
|
+
...excludeField,
|
|
2341
|
+
threshold,
|
|
2342
|
+
snapshot,
|
|
2343
|
+
decisions: withoutProvider(snapshot),
|
|
2344
|
+
usage: { requests: 0, inputTokens: 0, outputTokens: 0 }
|
|
2345
|
+
};
|
|
2346
|
+
}
|
|
2347
|
+
const { decisions, usage } = await classifyHunks({
|
|
2348
|
+
snapshot,
|
|
2349
|
+
intent,
|
|
2350
|
+
...excludeField,
|
|
2351
|
+
threshold,
|
|
2352
|
+
provider
|
|
2353
|
+
});
|
|
2354
|
+
return { intent, ...excludeField, threshold, snapshot, decisions, usage };
|
|
2355
|
+
}
|
|
2356
|
+
async function applySelection(plan, options) {
|
|
2357
|
+
const hunkIds = collectHunkIds(plan.snapshot);
|
|
2358
|
+
const known = new Set(hunkIds);
|
|
2359
|
+
const unknown = [...new Set(options.includeIds)].filter((id) => !known.has(id)).sort();
|
|
2360
|
+
if (unknown.length > 0) {
|
|
2361
|
+
throw new UnknownHunkError(unknown);
|
|
2362
|
+
}
|
|
2363
|
+
const included = new Set(options.includeIds);
|
|
2364
|
+
const stagedHunkIds = hunkIds.filter((id) => included.has(id));
|
|
2365
|
+
const skippedMixedIds = hunkIds.filter(
|
|
2366
|
+
(id) => plan.decisions.get(id)?.decision === "mixed" && !included.has(id)
|
|
2367
|
+
);
|
|
2368
|
+
const git = options.git ?? createGitRunner();
|
|
2369
|
+
const patchBytes = await stageHunks(plan.snapshot, included, git);
|
|
2370
|
+
return { stagedHunkIds, skippedMixedIds, patchBytes };
|
|
2371
|
+
}
|
|
2372
|
+
function collectHunkIds(snapshot) {
|
|
2373
|
+
const ids = [];
|
|
2374
|
+
for (const file of snapshot.files) {
|
|
2375
|
+
for (const hunk of file.hunks) {
|
|
2376
|
+
ids.push(hunk.id);
|
|
2377
|
+
}
|
|
2378
|
+
}
|
|
2379
|
+
return ids;
|
|
2380
|
+
}
|
|
2381
|
+
function withoutProvider(snapshot) {
|
|
2382
|
+
const decisions = /* @__PURE__ */ new Map();
|
|
2383
|
+
for (const hunkId of collectHunkIds(snapshot)) {
|
|
2384
|
+
decisions.set(hunkId, { hunkId, decision: "mixed", source: "no-provider" });
|
|
2385
|
+
}
|
|
2386
|
+
return decisions;
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2389
|
+
// src/version.ts
|
|
2390
|
+
var VERSION2 = "0.1.0";
|
|
2391
|
+
|
|
2392
|
+
// src/cli/args.ts
|
|
2393
|
+
import { parseArgs } from "node:util";
|
|
2394
|
+
var PARSE_CONFIG = {
|
|
2395
|
+
allowPositionals: true,
|
|
2396
|
+
strict: true,
|
|
2397
|
+
options: {
|
|
2398
|
+
exclude: { type: "string" },
|
|
2399
|
+
"dry-run": { type: "boolean" },
|
|
2400
|
+
yes: { type: "boolean" },
|
|
2401
|
+
json: { type: "boolean" },
|
|
2402
|
+
threshold: { type: "string" },
|
|
2403
|
+
"no-color": { type: "boolean" },
|
|
2404
|
+
help: { type: "boolean" },
|
|
2405
|
+
version: { type: "boolean" }
|
|
2406
|
+
}
|
|
2407
|
+
};
|
|
2408
|
+
function usageText() {
|
|
2409
|
+
return [
|
|
2410
|
+
'usage: git jev-stage "<sentence>" [options]',
|
|
2411
|
+
"",
|
|
2412
|
+
"Stages the unstaged hunks that match the sentence.",
|
|
2413
|
+
"",
|
|
2414
|
+
"options:",
|
|
2415
|
+
' --exclude "<sentence>" never stage hunks that match this sentence',
|
|
2416
|
+
" --threshold <n> confidence needed to stage or skip a hunk, 0-1 (default 0.6)",
|
|
2417
|
+
" --dry-run print the plan and the composed patch, stage nothing",
|
|
2418
|
+
" --yes skip prompts and leave mixed hunks unstaged",
|
|
2419
|
+
" --json print one JSON document; stages only with --yes",
|
|
2420
|
+
" --no-color turn off color",
|
|
2421
|
+
" --help print this message",
|
|
2422
|
+
" --version print the version"
|
|
2423
|
+
].join("\n");
|
|
2424
|
+
}
|
|
2425
|
+
function parseCliArgs(argv) {
|
|
2426
|
+
const { values, positionals } = runParseArgs(argv);
|
|
2427
|
+
const help = values.help ?? false;
|
|
2428
|
+
const version = values.version ?? false;
|
|
2429
|
+
const common = {
|
|
2430
|
+
dryRun: values["dry-run"] ?? false,
|
|
2431
|
+
yes: values.yes ?? false,
|
|
2432
|
+
json: values.json ?? false,
|
|
2433
|
+
threshold: parseThreshold(values.threshold),
|
|
2434
|
+
color: !(values["no-color"] ?? false),
|
|
2435
|
+
help,
|
|
2436
|
+
version
|
|
2437
|
+
};
|
|
2438
|
+
if (help || version) {
|
|
2439
|
+
return { intent: "", ...common };
|
|
2440
|
+
}
|
|
2441
|
+
const exclude = values.exclude;
|
|
2442
|
+
return {
|
|
2443
|
+
intent: parseIntent(positionals),
|
|
2444
|
+
...exclude === void 0 ? {} : { exclude: parseExclude(exclude) },
|
|
2445
|
+
...common
|
|
2446
|
+
};
|
|
2447
|
+
}
|
|
2448
|
+
function runParseArgs(argv) {
|
|
2449
|
+
try {
|
|
2450
|
+
return parseArgs({ ...PARSE_CONFIG, args: [...argv] });
|
|
2451
|
+
} catch (error) {
|
|
2452
|
+
throw new UsageError(error instanceof Error ? error.message : String(error), { cause: error });
|
|
2453
|
+
}
|
|
2454
|
+
}
|
|
2455
|
+
function parseIntent(positionals) {
|
|
2456
|
+
const first = positionals[0];
|
|
2457
|
+
if (first === void 0) {
|
|
2458
|
+
throw new UsageError('missing sentence; git jev-stage "<sentence>"');
|
|
2459
|
+
}
|
|
2460
|
+
if (positionals.length > 1) {
|
|
2461
|
+
throw new UsageError(
|
|
2462
|
+
`expected one sentence, got ${positionals.length}; quote the whole sentence`
|
|
2463
|
+
);
|
|
2464
|
+
}
|
|
2465
|
+
if (first.trim().length === 0) {
|
|
2466
|
+
throw new UsageError("the sentence is empty");
|
|
2467
|
+
}
|
|
2468
|
+
return first;
|
|
2469
|
+
}
|
|
2470
|
+
function parseExclude(value) {
|
|
2471
|
+
if (value.trim().length === 0) {
|
|
2472
|
+
throw new UsageError("--exclude is empty");
|
|
2473
|
+
}
|
|
2474
|
+
return value;
|
|
2475
|
+
}
|
|
2476
|
+
function parseThreshold(value) {
|
|
2477
|
+
if (value === void 0) {
|
|
2478
|
+
return DEFAULT_THRESHOLD;
|
|
2479
|
+
}
|
|
2480
|
+
const threshold = Number(value);
|
|
2481
|
+
if (!Number.isFinite(threshold) || threshold <= 0 || threshold > 1) {
|
|
2482
|
+
throw new UsageError(`--threshold must be greater than 0 and at most 1, got ${value}`);
|
|
2483
|
+
}
|
|
2484
|
+
return threshold;
|
|
2485
|
+
}
|
|
2486
|
+
|
|
2487
|
+
// src/cli/json.ts
|
|
2488
|
+
var JSON_INDENT = 2;
|
|
2489
|
+
function buildJsonDocument({
|
|
2490
|
+
plan,
|
|
2491
|
+
applied,
|
|
2492
|
+
stagedHunkIds,
|
|
2493
|
+
mixedHunkIds
|
|
2494
|
+
}) {
|
|
2495
|
+
const { snapshot } = plan;
|
|
2496
|
+
return {
|
|
2497
|
+
version: VERSION2,
|
|
2498
|
+
intent: plan.intent,
|
|
2499
|
+
...plan.exclude === void 0 ? {} : { exclude: plan.exclude },
|
|
2500
|
+
threshold: plan.threshold,
|
|
2501
|
+
head: snapshot.headOid,
|
|
2502
|
+
indexHash: snapshot.indexHash,
|
|
2503
|
+
diffHash: snapshot.diffHash,
|
|
2504
|
+
files: snapshot.files.map((file) => ({
|
|
2505
|
+
path: file.path,
|
|
2506
|
+
kind: file.kind,
|
|
2507
|
+
hunks: file.hunks.map((hunk) => {
|
|
2508
|
+
const decision = plan.decisions.get(hunk.id);
|
|
2509
|
+
return {
|
|
2510
|
+
id: hunk.id,
|
|
2511
|
+
header: hunk.header,
|
|
2512
|
+
text: hunk.text,
|
|
2513
|
+
added: hunk.added,
|
|
2514
|
+
removed: hunk.removed,
|
|
2515
|
+
decision: decision?.decision ?? "mixed",
|
|
2516
|
+
source: decision?.source ?? "missing",
|
|
2517
|
+
...decision?.confidence === void 0 ? {} : { confidence: decision.confidence },
|
|
2518
|
+
...decision?.probabilities === void 0 ? {} : { probabilities: decision.probabilities }
|
|
2519
|
+
};
|
|
2520
|
+
})
|
|
2521
|
+
})),
|
|
2522
|
+
skipped: snapshot.skipped.map((entry) => ({ path: entry.path, reason: entry.reason })),
|
|
2523
|
+
usage: plan.usage,
|
|
2524
|
+
applied,
|
|
2525
|
+
stagedHunkIds: [...stagedHunkIds],
|
|
2526
|
+
mixedHunkIds: [...mixedHunkIds]
|
|
2527
|
+
};
|
|
2528
|
+
}
|
|
2529
|
+
function renderJsonDocument(options) {
|
|
2530
|
+
return `${JSON.stringify(buildJsonDocument(options), null, JSON_INDENT)}
|
|
2531
|
+
`;
|
|
2532
|
+
}
|
|
2533
|
+
|
|
2534
|
+
// src/cli/loadEnv.ts
|
|
2535
|
+
import { existsSync, readFileSync as readFileSync4 } from "node:fs";
|
|
2536
|
+
import { dirname as dirname2, join as join2, resolve as resolve2 } from "node:path";
|
|
2537
|
+
var ENV_LINE = /^([A-Za-z_][A-Za-z0-9_]*)[ \t]*=(.*)$/;
|
|
2538
|
+
function loadDotEnv(dir, env = process.env) {
|
|
2539
|
+
const contents = readDotEnv(join2(dir, ".env"));
|
|
2540
|
+
if (contents === void 0) {
|
|
2541
|
+
return 0;
|
|
2542
|
+
}
|
|
2543
|
+
let loaded = 0;
|
|
2544
|
+
for (const rawLine of contents.split(/\r?\n/)) {
|
|
2545
|
+
const line = rawLine.trim();
|
|
2546
|
+
if (line.length === 0 || line.startsWith("#")) {
|
|
2547
|
+
continue;
|
|
2548
|
+
}
|
|
2549
|
+
const match = ENV_LINE.exec(line);
|
|
2550
|
+
if (match === null) {
|
|
2551
|
+
continue;
|
|
2552
|
+
}
|
|
2553
|
+
const key = match[1];
|
|
2554
|
+
const rawValue = match[2];
|
|
2555
|
+
if (key === void 0 || rawValue === void 0 || env[key] !== void 0) {
|
|
2556
|
+
continue;
|
|
2557
|
+
}
|
|
2558
|
+
env[key] = unquote(rawValue.trim());
|
|
2559
|
+
loaded += 1;
|
|
2560
|
+
}
|
|
2561
|
+
return loaded;
|
|
2562
|
+
}
|
|
2563
|
+
function readDotEnv(path) {
|
|
2564
|
+
try {
|
|
2565
|
+
return readFileSync4(path, "utf8");
|
|
2566
|
+
} catch (error) {
|
|
2567
|
+
if (isNodeError(error) && (error.code === "ENOENT" || error.code === "EISDIR")) {
|
|
2568
|
+
return void 0;
|
|
2569
|
+
}
|
|
2570
|
+
throw error;
|
|
2571
|
+
}
|
|
2572
|
+
}
|
|
2573
|
+
function unquote(value) {
|
|
2574
|
+
const first = value[0];
|
|
2575
|
+
if (value.length >= 2 && (first === '"' || first === "'") && value.endsWith(first)) {
|
|
2576
|
+
return value.slice(1, -1);
|
|
2577
|
+
}
|
|
2578
|
+
return value;
|
|
2579
|
+
}
|
|
2580
|
+
function isNodeError(error) {
|
|
2581
|
+
return error instanceof Error && "code" in error;
|
|
2582
|
+
}
|
|
2583
|
+
function loadDotEnvFromTree(cwd, env = process.env) {
|
|
2584
|
+
let dir = resolve2(cwd);
|
|
2585
|
+
for (; ; ) {
|
|
2586
|
+
if (existsSync(join2(dir, ".env"))) {
|
|
2587
|
+
return loadDotEnv(dir, env);
|
|
2588
|
+
}
|
|
2589
|
+
if (existsSync(join2(dir, ".git"))) {
|
|
2590
|
+
return 0;
|
|
2591
|
+
}
|
|
2592
|
+
const parent = dirname2(dir);
|
|
2593
|
+
if (parent === dir) {
|
|
2594
|
+
return 0;
|
|
2595
|
+
}
|
|
2596
|
+
dir = parent;
|
|
2597
|
+
}
|
|
2598
|
+
}
|
|
2599
|
+
|
|
2600
|
+
// src/cli/prompt.ts
|
|
2601
|
+
var NO_TTY_MESSAGE = "no TTY; use --yes, --dry-run or --json";
|
|
2602
|
+
var AFFIRMATIVE = /* @__PURE__ */ new Set(["y", "yes"]);
|
|
2603
|
+
async function confirm(question, io) {
|
|
2604
|
+
if (!io.isStdinTty) {
|
|
2605
|
+
throw new UsageError(NO_TTY_MESSAGE);
|
|
2606
|
+
}
|
|
2607
|
+
io.stdout(`${question} [y/N] `);
|
|
2608
|
+
const answer = await io.readLine();
|
|
2609
|
+
return answer !== void 0 && AFFIRMATIVE.has(answer.trim().toLowerCase());
|
|
2610
|
+
}
|
|
2611
|
+
async function askHunk(hunk, io) {
|
|
2612
|
+
if (!io.isStdinTty) {
|
|
2613
|
+
throw new UsageError(NO_TTY_MESSAGE);
|
|
2614
|
+
}
|
|
2615
|
+
const body = hunk.text.endsWith("\n") ? hunk.text : `${hunk.text}
|
|
2616
|
+
`;
|
|
2617
|
+
io.stdout(`
|
|
2618
|
+
${hunk.path}
|
|
2619
|
+
${body}`);
|
|
2620
|
+
return confirm("stage this hunk?", io);
|
|
2621
|
+
}
|
|
2622
|
+
|
|
2623
|
+
// src/cli/render.ts
|
|
2624
|
+
var RESET = "\x1B[0m";
|
|
2625
|
+
var GREEN = "\x1B[32m";
|
|
2626
|
+
var RED = "\x1B[31m";
|
|
2627
|
+
var YELLOW = "\x1B[33m";
|
|
2628
|
+
var DIM = "\x1B[2m";
|
|
2629
|
+
var ID_LENGTH = 8;
|
|
2630
|
+
var PROBABILITY_DIGITS = 2;
|
|
2631
|
+
var DECISION_MARKS = Object.freeze({
|
|
2632
|
+
include: "+",
|
|
2633
|
+
exclude: "-",
|
|
2634
|
+
mixed: "?"
|
|
2635
|
+
});
|
|
2636
|
+
var FILE_MARKS = Object.freeze({
|
|
2637
|
+
modified: "M",
|
|
2638
|
+
added: "A",
|
|
2639
|
+
deleted: "D",
|
|
2640
|
+
"mode-only": "M"
|
|
2641
|
+
});
|
|
2642
|
+
var PROBABILITY_ORDER = Object.freeze(["include", "exclude", "mixed"]);
|
|
2643
|
+
function shouldColor({ requested, isTty, noColor }) {
|
|
2644
|
+
return requested && isTty && (noColor === void 0 || noColor.length === 0);
|
|
2645
|
+
}
|
|
2646
|
+
function renderPlan({ plan, includeIds, color }) {
|
|
2647
|
+
const lines = [];
|
|
2648
|
+
for (const file of plan.snapshot.files) {
|
|
2649
|
+
lines.push(`${FILE_MARKS[file.kind]} ${file.path}`);
|
|
2650
|
+
for (const hunk of file.hunks) {
|
|
2651
|
+
lines.push(renderHunk(hunk.id, hunk.header, plan.decisions.get(hunk.id), color));
|
|
2652
|
+
}
|
|
2653
|
+
}
|
|
2654
|
+
for (const skipped of plan.snapshot.skipped) {
|
|
2655
|
+
lines.push(`skipped: ${skipped.path} (${skipped.reason})`);
|
|
2656
|
+
}
|
|
2657
|
+
const summary = summarizeSelection(plan.snapshot.files, includeIds);
|
|
2658
|
+
lines.push(
|
|
2659
|
+
`will stage: ${count(summary.hunks, "hunk")}, ${count(summary.files, "file")} (+${summary.added} -${summary.removed})`
|
|
2660
|
+
);
|
|
2661
|
+
return `${lines.join("\n")}
|
|
2662
|
+
`;
|
|
2663
|
+
}
|
|
2664
|
+
function renderPatch(patch, write) {
|
|
2665
|
+
if (patch.length === 0) {
|
|
2666
|
+
return;
|
|
2667
|
+
}
|
|
2668
|
+
write(patch);
|
|
2669
|
+
}
|
|
2670
|
+
function renderHunk(hunkId, header, decision, color) {
|
|
2671
|
+
const resolved = decision?.decision ?? "mixed";
|
|
2672
|
+
const mark = colorize(DECISION_MARKS[resolved], resolved, color);
|
|
2673
|
+
const parts = [` ${mark} ${hunkId.slice(0, ID_LENGTH)} ${header}`];
|
|
2674
|
+
const probabilities = decision?.probabilities;
|
|
2675
|
+
if (probabilities !== void 0) {
|
|
2676
|
+
parts.push(renderProbabilities(probabilities));
|
|
2677
|
+
}
|
|
2678
|
+
if (decision !== void 0 && decision.source !== "model") {
|
|
2679
|
+
parts.push(decision.source);
|
|
2680
|
+
}
|
|
2681
|
+
return parts.join(" ");
|
|
2682
|
+
}
|
|
2683
|
+
function renderProbabilities(probabilities) {
|
|
2684
|
+
return PROBABILITY_ORDER.map(
|
|
2685
|
+
(label) => `${label} ${(probabilities[label] ?? 0).toFixed(PROBABILITY_DIGITS)}`
|
|
2686
|
+
).join(" ");
|
|
2687
|
+
}
|
|
2688
|
+
function colorize(mark, decision, color) {
|
|
2689
|
+
if (!color) {
|
|
2690
|
+
return mark;
|
|
2691
|
+
}
|
|
2692
|
+
if (decision === "include") {
|
|
2693
|
+
return `${GREEN}${mark}${RESET}`;
|
|
2694
|
+
}
|
|
2695
|
+
if (decision === "exclude") {
|
|
2696
|
+
return `${DIM}${RED}${mark}${RESET}`;
|
|
2697
|
+
}
|
|
2698
|
+
return `${YELLOW}${mark}${RESET}`;
|
|
2699
|
+
}
|
|
2700
|
+
function count(n, noun) {
|
|
2701
|
+
return `${n} ${noun}${n === 1 ? "" : "s"}`;
|
|
2702
|
+
}
|
|
2703
|
+
|
|
2704
|
+
// src/cli/main.ts
|
|
2705
|
+
var SIGINT_EXIT_CODE = 130;
|
|
2706
|
+
var SIGTERM_EXIT_CODE = 143;
|
|
2707
|
+
var FAKE_PROVIDER_ENV = "GIT_JEV_STAGE_FAKE_PROVIDER";
|
|
2708
|
+
async function run(argv, io) {
|
|
2709
|
+
try {
|
|
2710
|
+
loadDotEnvFromTree(io.cwd, io.env);
|
|
2711
|
+
const options = parseCliArgs(argv);
|
|
2712
|
+
if (options.help) {
|
|
2713
|
+
io.stdout(`${usageText()}
|
|
2714
|
+
`);
|
|
2715
|
+
return 0;
|
|
2716
|
+
}
|
|
2717
|
+
if (options.version) {
|
|
2718
|
+
io.stdout(`${VERSION2}
|
|
2719
|
+
`);
|
|
2720
|
+
return 0;
|
|
2721
|
+
}
|
|
2722
|
+
await stage(options, io);
|
|
2723
|
+
return 0;
|
|
2724
|
+
} catch (error) {
|
|
2725
|
+
const { message, exitCode } = toExit(error);
|
|
2726
|
+
io.stderr(`${message}
|
|
2727
|
+
`);
|
|
2728
|
+
return exitCode;
|
|
2729
|
+
}
|
|
2730
|
+
}
|
|
2731
|
+
async function main(argv) {
|
|
2732
|
+
const reader = createLineReader();
|
|
2733
|
+
const io = {
|
|
2734
|
+
stdout: (text) => {
|
|
2735
|
+
process.stdout.write(text);
|
|
2736
|
+
},
|
|
2737
|
+
stderr: (text) => {
|
|
2738
|
+
process.stderr.write(text);
|
|
2739
|
+
},
|
|
2740
|
+
stdoutBytes: (bytes) => {
|
|
2741
|
+
process.stdout.write(bytes);
|
|
2742
|
+
},
|
|
2743
|
+
isStdoutTty: process.stdout.isTTY === true,
|
|
2744
|
+
isStdinTty: process.stdin.isTTY === true,
|
|
2745
|
+
readLine: () => reader.readLine(),
|
|
2746
|
+
cwd: process.cwd(),
|
|
2747
|
+
env: process.env
|
|
2748
|
+
};
|
|
2749
|
+
const onInterrupt = createSignalHandler(SIGINT_EXIT_CODE);
|
|
2750
|
+
const onTerminate = createSignalHandler(SIGTERM_EXIT_CODE);
|
|
2751
|
+
process.on("SIGINT", onInterrupt);
|
|
2752
|
+
process.on("SIGTERM", onTerminate);
|
|
2753
|
+
try {
|
|
2754
|
+
return await run(argv, io);
|
|
2755
|
+
} finally {
|
|
2756
|
+
process.off("SIGINT", onInterrupt);
|
|
2757
|
+
process.off("SIGTERM", onTerminate);
|
|
2758
|
+
reader.close();
|
|
2759
|
+
}
|
|
2760
|
+
}
|
|
2761
|
+
async function stage(options, io) {
|
|
2762
|
+
const interactive = !options.json && !options.yes && !options.dryRun;
|
|
2763
|
+
if (interactive && !io.isStdinTty) {
|
|
2764
|
+
throw new UsageError(NO_TTY_MESSAGE);
|
|
2765
|
+
}
|
|
2766
|
+
const provider = resolveProvider(io.env);
|
|
2767
|
+
if (provider === void 0 && (options.yes || options.json)) {
|
|
2768
|
+
throw new MissingApiKeyError();
|
|
2769
|
+
}
|
|
2770
|
+
const report = options.json ? io.stderr : io.stdout;
|
|
2771
|
+
const plan = await planSelection({
|
|
2772
|
+
cwd: io.cwd,
|
|
2773
|
+
intent: options.intent,
|
|
2774
|
+
...options.exclude === void 0 ? {} : { exclude: options.exclude },
|
|
2775
|
+
threshold: options.threshold,
|
|
2776
|
+
...provider === void 0 ? {} : { provider }
|
|
2777
|
+
});
|
|
2778
|
+
if (plan.snapshot.files.length === 0) {
|
|
2779
|
+
if (options.json) {
|
|
2780
|
+
io.stdout(renderJsonDocument({ plan, applied: false, stagedHunkIds: [], mixedHunkIds: [] }));
|
|
2781
|
+
return;
|
|
2782
|
+
}
|
|
2783
|
+
report("nothing to stage\n");
|
|
2784
|
+
return;
|
|
2785
|
+
}
|
|
2786
|
+
const decided = provider === void 0 && interactive ? await decideByHand(plan, io) : plan;
|
|
2787
|
+
const includeIds = new Set(idsWithDecision(decided, "include"));
|
|
2788
|
+
report(
|
|
2789
|
+
renderPlan({
|
|
2790
|
+
plan: decided,
|
|
2791
|
+
includeIds,
|
|
2792
|
+
color: shouldColor({
|
|
2793
|
+
requested: options.color,
|
|
2794
|
+
isTty: io.isStdoutTty,
|
|
2795
|
+
noColor: io.env.NO_COLOR
|
|
2796
|
+
})
|
|
2797
|
+
})
|
|
2798
|
+
);
|
|
2799
|
+
if (options.dryRun) {
|
|
2800
|
+
if (options.json) {
|
|
2801
|
+
io.stdout(renderJsonDocument(plannedDocument(decided, includeIds)));
|
|
2802
|
+
return;
|
|
2803
|
+
}
|
|
2804
|
+
renderPatch(composePatch(decided.snapshot.files, includeIds), io.stdoutBytes);
|
|
2805
|
+
return;
|
|
2806
|
+
}
|
|
2807
|
+
if (interactive) {
|
|
2808
|
+
for (const hunk of hunksWithDecision(decided, "mixed")) {
|
|
2809
|
+
if (await askHunk(hunk, io)) {
|
|
2810
|
+
includeIds.add(hunk.id);
|
|
2811
|
+
}
|
|
2812
|
+
}
|
|
2813
|
+
const pending = summarizeSelection(decided.snapshot.files, includeIds);
|
|
2814
|
+
const confirmed = await confirm(
|
|
2815
|
+
`stage ${count(pending.hunks, "hunk")} in ${count(pending.files, "file")}?`,
|
|
2816
|
+
io
|
|
2817
|
+
);
|
|
2818
|
+
if (!confirmed) {
|
|
2819
|
+
report("nothing staged\n");
|
|
2820
|
+
return;
|
|
2821
|
+
}
|
|
2822
|
+
}
|
|
2823
|
+
if (options.json && !options.yes) {
|
|
2824
|
+
io.stdout(renderJsonDocument(plannedDocument(decided, includeIds)));
|
|
2825
|
+
return;
|
|
2826
|
+
}
|
|
2827
|
+
const result = await applySelection(decided, { includeIds: [...includeIds] });
|
|
2828
|
+
if (options.json) {
|
|
2829
|
+
io.stdout(
|
|
2830
|
+
renderJsonDocument({
|
|
2831
|
+
plan: decided,
|
|
2832
|
+
applied: true,
|
|
2833
|
+
stagedHunkIds: result.stagedHunkIds,
|
|
2834
|
+
mixedHunkIds: result.skippedMixedIds
|
|
2835
|
+
})
|
|
2836
|
+
);
|
|
2837
|
+
}
|
|
2838
|
+
const staged = summarizeSelection(decided.snapshot.files, new Set(result.stagedHunkIds));
|
|
2839
|
+
report(`staged ${count(staged.hunks, "hunk")} in ${count(staged.files, "file")}
|
|
2840
|
+
`);
|
|
2841
|
+
if (result.skippedMixedIds.length > 0) {
|
|
2842
|
+
report(`left unstaged: ${count(result.skippedMixedIds.length, "mixed hunk")}
|
|
2843
|
+
`);
|
|
2844
|
+
}
|
|
2845
|
+
}
|
|
2846
|
+
function plannedDocument(plan, includeIds) {
|
|
2847
|
+
return {
|
|
2848
|
+
plan,
|
|
2849
|
+
applied: false,
|
|
2850
|
+
stagedHunkIds: [],
|
|
2851
|
+
mixedHunkIds: idsWithDecision(plan, "mixed").filter((id) => !includeIds.has(id))
|
|
2852
|
+
};
|
|
2853
|
+
}
|
|
2854
|
+
async function decideByHand(plan, io) {
|
|
2855
|
+
io.stdout("no TYPESAFE_API_KEY, deciding by hand\n");
|
|
2856
|
+
const decisions = /* @__PURE__ */ new Map();
|
|
2857
|
+
for (const file of plan.snapshot.files) {
|
|
2858
|
+
for (const hunk of file.hunks) {
|
|
2859
|
+
const wanted = await askHunk(hunk, io);
|
|
2860
|
+
decisions.set(hunk.id, {
|
|
2861
|
+
hunkId: hunk.id,
|
|
2862
|
+
decision: wanted ? "include" : "exclude",
|
|
2863
|
+
source: "manual"
|
|
2864
|
+
});
|
|
2865
|
+
}
|
|
2866
|
+
}
|
|
2867
|
+
return { ...plan, decisions };
|
|
2868
|
+
}
|
|
2869
|
+
function resolveProvider(env) {
|
|
2870
|
+
const scriptPath = env[FAKE_PROVIDER_ENV];
|
|
2871
|
+
if (scriptPath !== void 0 && scriptPath.length > 0) {
|
|
2872
|
+
return FakeProvider.fromJsonFile(scriptPath);
|
|
2873
|
+
}
|
|
2874
|
+
return createProviderFromEnv(env);
|
|
2875
|
+
}
|
|
2876
|
+
function idsWithDecision(plan, decision) {
|
|
2877
|
+
return hunksWithDecision(plan, decision).map((hunk) => hunk.id);
|
|
2878
|
+
}
|
|
2879
|
+
function hunksWithDecision(plan, decision) {
|
|
2880
|
+
const hunks = [];
|
|
2881
|
+
for (const file of plan.snapshot.files) {
|
|
2882
|
+
for (const hunk of file.hunks) {
|
|
2883
|
+
if (plan.decisions.get(hunk.id)?.decision === decision) {
|
|
2884
|
+
hunks.push(hunk);
|
|
2885
|
+
}
|
|
2886
|
+
}
|
|
2887
|
+
}
|
|
2888
|
+
return hunks;
|
|
2889
|
+
}
|
|
2890
|
+
function toExit(error) {
|
|
2891
|
+
if (error instanceof JevStageError) {
|
|
2892
|
+
return { message: `git-jev-stage: ${error.code}: ${error.message}`, exitCode: error.exitCode };
|
|
2893
|
+
}
|
|
2894
|
+
if (error instanceof ProviderError || error instanceof ProviderConfigError) {
|
|
2895
|
+
return { message: `git-jev-stage: provider: ${error.message}`, exitCode: 1 };
|
|
2896
|
+
}
|
|
2897
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
2898
|
+
return { message: `git-jev-stage: internal: ${detail}`, exitCode: 1 };
|
|
2899
|
+
}
|
|
2900
|
+
function createLineReader() {
|
|
2901
|
+
let readerInterface;
|
|
2902
|
+
let lines;
|
|
2903
|
+
return {
|
|
2904
|
+
async readLine() {
|
|
2905
|
+
if (lines === void 0) {
|
|
2906
|
+
readerInterface = createInterface({
|
|
2907
|
+
input: process.stdin,
|
|
2908
|
+
crlfDelay: Number.POSITIVE_INFINITY
|
|
2909
|
+
});
|
|
2910
|
+
lines = readerInterface[Symbol.asyncIterator]();
|
|
2911
|
+
}
|
|
2912
|
+
const next = await lines.next();
|
|
2913
|
+
return next.done === true ? void 0 : next.value;
|
|
2914
|
+
},
|
|
2915
|
+
close() {
|
|
2916
|
+
readerInterface?.close();
|
|
2917
|
+
readerInterface = void 0;
|
|
2918
|
+
lines = void 0;
|
|
2919
|
+
}
|
|
2920
|
+
};
|
|
2921
|
+
}
|
|
2922
|
+
function createSignalHandler(exitCode) {
|
|
2923
|
+
return () => {
|
|
2924
|
+
cleanupOwnedPaths();
|
|
2925
|
+
process.exit(exitCode);
|
|
2926
|
+
};
|
|
2927
|
+
}
|
|
2928
|
+
function isEntryPoint() {
|
|
2929
|
+
const entry = process.argv[1];
|
|
2930
|
+
if (entry === void 0) {
|
|
2931
|
+
return false;
|
|
2932
|
+
}
|
|
2933
|
+
try {
|
|
2934
|
+
return pathToFileURL(realpathSync(entry)).href === import.meta.url;
|
|
2935
|
+
} catch {
|
|
2936
|
+
return false;
|
|
2937
|
+
}
|
|
2938
|
+
}
|
|
2939
|
+
if (isEntryPoint()) {
|
|
2940
|
+
process.exitCode = await main(process.argv.slice(2));
|
|
2941
|
+
}
|
|
2942
|
+
export {
|
|
2943
|
+
main,
|
|
2944
|
+
run
|
|
2945
|
+
};
|