@oh-my-pi/pi-coding-agent 16.2.9 → 16.2.11

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.
@@ -4,12 +4,12 @@ import {
4
4
  createWorkerHandle,
5
5
  createWorkerSubprocess,
6
6
  logWorkerMessage,
7
+ type RefCountedWorkerHandle,
7
8
  resolveWorkerSpawnCmd,
8
9
  SMOKE_TEST_TIMEOUT_MS,
9
10
  type SpawnedSubprocess,
10
11
  smokeTestWorker,
11
12
  spawnWorkerOrUnavailable,
12
- type WorkerHandle,
13
13
  } from "../subprocess/worker-client";
14
14
  import { tinyWorkerEnv } from "../tiny/title-client";
15
15
  import { safeSend } from "../utils/ipc";
@@ -18,7 +18,7 @@ import type { SttModelKey } from "./models";
18
18
 
19
19
  type PendingRequest =
20
20
  | { kind: "transcribe"; modelKey: SttModelKey; resolve: (text: string) => void; reject: (error: Error) => void }
21
- | { kind: "download"; modelKey: SttModelKey; resolve: (ok: boolean) => void };
21
+ | { kind: "download"; modelKey: SttModelKey; resolve: (result: SttDownloadResult) => void };
22
22
 
23
23
  export interface SttTranscribeOptions {
24
24
  language?: string;
@@ -30,6 +30,11 @@ export interface SttDownloadOptions {
30
30
  onProgress?: (event: SttProgressEvent) => void;
31
31
  }
32
32
 
33
+ export interface SttDownloadResult {
34
+ ok: boolean;
35
+ error?: string;
36
+ }
37
+
33
38
  /** Live streaming session handle returned by {@link SttClient.startStream}. */
34
39
  export interface SttStreamHandle {
35
40
  /** Feed 16 kHz mono float samples as the recorder produces them. */
@@ -79,30 +84,55 @@ export function createSttSubprocess(): SpawnedSubprocess<SttWorkerOutbound> {
79
84
 
80
85
  function wrapSubprocess(
81
86
  spawned: SpawnedSubprocess<SttWorkerOutbound>,
82
- ): WorkerHandle<SttWorkerInbound, SttWorkerOutbound> {
87
+ ): RefCountedWorkerHandle<SttWorkerInbound, SttWorkerOutbound> {
83
88
  const { proc } = spawned;
84
- return createWorkerHandle<SttWorkerInbound, SttWorkerOutbound>(spawned, message => safeSend(proc, message, "stt"));
89
+ return {
90
+ ...createWorkerHandle<SttWorkerInbound, SttWorkerOutbound>(spawned, message => safeSend(proc, message, "stt")),
91
+ ref() {
92
+ try {
93
+ proc.ref();
94
+ } catch {
95
+ // Already gone.
96
+ }
97
+ },
98
+ unref() {
99
+ try {
100
+ proc.unref();
101
+ } catch {
102
+ // Already gone.
103
+ }
104
+ },
105
+ };
106
+ }
107
+
108
+ function spawnInlineUnavailableWorker(error: unknown): RefCountedWorkerHandle<SttWorkerInbound, SttWorkerOutbound> {
109
+ return {
110
+ ...createUnavailableWorker<SttWorkerInbound, SttWorkerOutbound>(error),
111
+ ref() {},
112
+ unref() {},
113
+ };
85
114
  }
86
115
 
87
- function spawnSttWorker(): WorkerHandle<SttWorkerInbound, SttWorkerOutbound> {
116
+ function spawnSttWorker(): RefCountedWorkerHandle<SttWorkerInbound, SttWorkerOutbound> {
88
117
  return spawnWorkerOrUnavailable(
89
118
  () => wrapSubprocess(createSttSubprocess()),
90
- createUnavailableWorker<SttWorkerInbound, SttWorkerOutbound>,
119
+ spawnInlineUnavailableWorker,
91
120
  "stt worker spawn failed; speech-to-text disabled",
92
121
  );
93
122
  }
94
123
 
95
124
  export class SttClient {
96
- #worker: WorkerHandle<SttWorkerInbound, SttWorkerOutbound> | null = null;
125
+ #worker: RefCountedWorkerHandle<SttWorkerInbound, SttWorkerOutbound> | null = null;
97
126
  #unsubscribeMessage: (() => void) | null = null;
98
127
  #unsubscribeError: (() => void) | null = null;
99
128
  #pending = new Map<string, PendingRequest>();
100
129
  #streams = new Map<string, StreamState>();
101
130
  #progressListeners = new Set<(event: SttProgressEvent) => void>();
102
131
  #nextRequestId = 0;
103
- #spawnWorker: () => WorkerHandle<SttWorkerInbound, SttWorkerOutbound>;
132
+ #refed = false;
133
+ #spawnWorker: () => RefCountedWorkerHandle<SttWorkerInbound, SttWorkerOutbound>;
104
134
 
105
- constructor(spawnWorker: () => WorkerHandle<SttWorkerInbound, SttWorkerOutbound> = spawnSttWorker) {
135
+ constructor(spawnWorker: () => RefCountedWorkerHandle<SttWorkerInbound, SttWorkerOutbound> = spawnSttWorker) {
106
136
  this.#spawnWorker = spawnWorker;
107
137
  }
108
138
 
@@ -121,11 +151,11 @@ export class SttClient {
121
151
  const worker = this.#ensureWorker();
122
152
  const id = String(++this.#nextRequestId);
123
153
  const { promise, resolve, reject } = Promise.withResolvers<string>();
124
- this.#pending.set(id, { kind: "transcribe", modelKey, resolve, reject });
154
+ this.#addPending(id, { kind: "transcribe", modelKey, resolve, reject });
125
155
  const abort = (): void => {
126
156
  const pending = this.#pending.get(id);
127
157
  if (pending?.kind !== "transcribe") return;
128
- this.#pending.delete(id);
158
+ this.#deletePending(id);
129
159
  pending.reject(new DOMException("The operation was aborted.", "AbortError"));
130
160
  };
131
161
  options.signal?.addEventListener("abort", abort, { once: true });
@@ -134,7 +164,7 @@ export class SttClient {
134
164
  return await promise;
135
165
  } finally {
136
166
  options.signal?.removeEventListener("abort", abort);
137
- this.#pending.delete(id);
167
+ this.#deletePending(id);
138
168
  }
139
169
  }
140
170
 
@@ -163,6 +193,7 @@ export class SttClient {
163
193
  settled = true;
164
194
  this.#streams.delete(id);
165
195
  signal?.removeEventListener("abort", onAbort);
196
+ this.#syncWorkerRef();
166
197
  apply();
167
198
  };
168
199
  this.#streams.set(id, {
@@ -173,6 +204,7 @@ export class SttClient {
173
204
  reject,
174
205
  finish,
175
206
  });
207
+ this.#syncWorkerRef();
176
208
  worker.send({ type: "stream_start", id, modelKey, language: options.language });
177
209
  const handle: SttStreamHandle = {
178
210
  pushAudio: audio => {
@@ -193,19 +225,19 @@ export class SttClient {
193
225
  return handle;
194
226
  }
195
227
 
196
- async downloadModel(modelKey: SttModelKey, options: SttDownloadOptions = {}): Promise<boolean> {
197
- if (options.signal?.aborted) return false;
228
+ async downloadModel(modelKey: SttModelKey, options: SttDownloadOptions = {}): Promise<SttDownloadResult> {
229
+ if (options.signal?.aborted) return { ok: false };
198
230
  const unsubscribe = options.onProgress ? this.onProgress(options.onProgress) : undefined;
199
231
  try {
200
232
  const worker = this.#ensureWorker();
201
233
  const id = String(++this.#nextRequestId);
202
- const { promise, resolve } = Promise.withResolvers<boolean>();
203
- this.#pending.set(id, { kind: "download", modelKey, resolve });
234
+ const { promise, resolve } = Promise.withResolvers<SttDownloadResult>();
235
+ this.#addPending(id, { kind: "download", modelKey, resolve });
204
236
  const abort = (): void => {
205
237
  const pending = this.#pending.get(id);
206
238
  if (pending?.kind !== "download") return;
207
- this.#pending.delete(id);
208
- pending.resolve(false);
239
+ this.#deletePending(id);
240
+ pending.resolve({ ok: false });
209
241
  };
210
242
  options.signal?.addEventListener("abort", abort, { once: true });
211
243
  try {
@@ -213,14 +245,15 @@ export class SttClient {
213
245
  return await promise;
214
246
  } finally {
215
247
  options.signal?.removeEventListener("abort", abort);
216
- this.#pending.delete(id);
248
+ this.#deletePending(id);
217
249
  }
218
250
  } catch (error) {
251
+ const message = error instanceof Error ? error.message : String(error);
219
252
  logger.debug("stt: local model download failed", {
220
253
  modelKey,
221
- error: error instanceof Error ? error.message : String(error),
254
+ error: message,
222
255
  });
223
- return false;
256
+ return { ok: false, error: message };
224
257
  } finally {
225
258
  unsubscribe?.();
226
259
  }
@@ -236,9 +269,10 @@ export class SttClient {
236
269
  for (const pending of this.#pending.values()) {
237
270
  this.#emitProgress({ modelKey: pending.modelKey, status: "error" });
238
271
  if (pending.kind === "transcribe") pending.reject(new Error("stt worker terminated"));
239
- else pending.resolve(false);
272
+ else pending.resolve({ ok: false });
240
273
  }
241
274
  this.#pending.clear();
275
+ this.#refed = false;
242
276
  this.#failStreams(new Error("stt worker terminated"));
243
277
  try {
244
278
  await worker?.terminate();
@@ -247,7 +281,7 @@ export class SttClient {
247
281
  }
248
282
  }
249
283
 
250
- #ensureWorker(): WorkerHandle<SttWorkerInbound, SttWorkerOutbound> {
284
+ #ensureWorker(): RefCountedWorkerHandle<SttWorkerInbound, SttWorkerOutbound> {
251
285
  if (this.#worker) return this.#worker;
252
286
  const worker = this.#spawnWorker();
253
287
  this.#worker = worker;
@@ -256,6 +290,32 @@ export class SttClient {
256
290
  return worker;
257
291
  }
258
292
 
293
+ /** Register a pending request and keep the worker referenced while work is in flight. */
294
+ #addPending(id: string, request: PendingRequest): void {
295
+ this.#pending.set(id, request);
296
+ this.#syncWorkerRef();
297
+ }
298
+
299
+ /** Drop a pending request and unref the worker once no request or stream is active. */
300
+ #deletePending(id: string): void {
301
+ if (this.#pending.delete(id)) this.#syncWorkerRef();
302
+ }
303
+
304
+ /**
305
+ * STT workers start unreferenced so an idle warm model never blocks exit.
306
+ * Setup/download commands must keep the worker alive while awaiting IPC, or
307
+ * Bun can drain the event loop immediately after `Preparing Speech-to-Text`.
308
+ */
309
+ #syncWorkerRef(): void {
310
+ const worker = this.#worker;
311
+ if (!worker) return;
312
+ const shouldRef = this.#pending.size > 0 || this.#streams.size > 0;
313
+ if (shouldRef === this.#refed) return;
314
+ this.#refed = shouldRef;
315
+ if (shouldRef) worker.ref();
316
+ else worker.unref();
317
+ }
318
+
259
319
  #handleMessage(message: SttWorkerOutbound): void {
260
320
  if (message.type === "log") {
261
321
  logWorkerMessage(message);
@@ -287,19 +347,19 @@ export class SttClient {
287
347
  }
288
348
  return;
289
349
  }
290
- this.#pending.delete(message.id);
350
+ this.#deletePending(message.id);
291
351
  if (message.type === "transcription") {
292
352
  if (pending.kind === "transcribe") pending.resolve(message.text);
293
353
  return;
294
354
  }
295
355
  if (message.type === "downloaded") {
296
- if (pending.kind === "download") pending.resolve(true);
356
+ if (pending.kind === "download") pending.resolve({ ok: true });
297
357
  return;
298
358
  }
299
359
  // message.type === "error"
300
360
  this.#emitProgress({ modelKey: pending.modelKey, status: "error" });
301
361
  if (pending.kind === "transcribe") pending.reject(new Error(message.error));
302
- else pending.resolve(false);
362
+ else pending.resolve({ ok: false, error: message.error });
303
363
  }
304
364
 
305
365
  #emitProgress(event: SttProgressEvent): void {
@@ -318,7 +378,7 @@ export class SttClient {
318
378
  for (const pending of this.#pending.values()) {
319
379
  this.#emitProgress({ modelKey: pending.modelKey, status: "error" });
320
380
  if (pending.kind === "transcribe") pending.reject(error);
321
- else pending.resolve(false);
381
+ else pending.resolve({ ok: false, error: error.message });
322
382
  }
323
383
  this.#pending.clear();
324
384
  this.#failStreams(error);
@@ -90,7 +90,7 @@ export async function downloadSttModel(
90
90
  ): Promise<void> {
91
91
  const spec = resolveSttModelSpec(key);
92
92
  const files = new Map<string, { loaded: number; total: number }>();
93
- const ok = await sttClient.downloadModel(spec.key, {
93
+ const result = await sttClient.downloadModel(spec.key, {
94
94
  signal: options?.signal,
95
95
  onProgress: event => {
96
96
  if ((event.status === "progress" || event.status === "progress_total") && event.file) {
@@ -117,7 +117,13 @@ export async function downloadSttModel(
117
117
  });
118
118
  },
119
119
  });
120
- if (!ok) throw new Error(`Failed to download speech model (${spec.repo}). Check your network connection.`);
120
+ if (!result.ok) {
121
+ const detail = result.error ? `: ${result.error}` : ". Check your network connection.";
122
+ throw new Error(`Failed to download speech model (${spec.repo})${detail}`);
123
+ }
124
+ if (!(await isSttModelCached(spec.key))) {
125
+ throw new Error(`Speech model download finished without required files (${spec.repo}).`);
126
+ }
121
127
  }
122
128
 
123
129
  // ── Public API ─────────────────────────────────────────────────────
@@ -2053,9 +2053,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
2053
2053
  ? formatModelSelectorValue(formatModelStringWithRouting(model), resolvedThinkingLevel)
2054
2054
  : formatModelStringWithRouting(model);
2055
2055
  }
2056
- const effectiveThinkingLevel = explicitThinkingLevel
2057
- ? resolvedThinkingLevel
2058
- : (thinkingLevel ?? resolvedThinkingLevel);
2056
+ const effectiveThinkingLevel = thinkingLevel ?? resolvedThinkingLevel;
2059
2057
  resolvedAt = performance.now();
2060
2058
 
2061
2059
  const effectiveCwd = worktree ?? cwd;
package/src/task/index.ts CHANGED
@@ -803,10 +803,19 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
803
803
  async ({ jobId: ownJobId, signal: runSignal, reportProgress, markRunning }) => {
804
804
  const startedAt = Date.now();
805
805
  const semaphore = this.#getSpawnSemaphore();
806
- await semaphore.acquire();
806
+ let semaphoreHeld = false;
807
+ try {
808
+ await semaphore.acquire(runSignal);
809
+ semaphoreHeld = true;
810
+ } catch {
811
+ // Fall through so an acquire-time abort goes through the same
812
+ // path as the post-acquire race below: progress + onSettled
813
+ // have to fire even when the spawn never reached the executor,
814
+ // otherwise the batch aggregate state stays "running" forever.
815
+ }
807
816
  const acquiredAt = Date.now();
808
- if (runSignal.aborted) {
809
- semaphore.release();
817
+ if (!semaphoreHeld || runSignal.aborted) {
818
+ if (semaphoreHeld) semaphore.release();
810
819
  progress.status = "aborted";
811
820
  onSettled?.(true);
812
821
  throw new Error("Aborted before execution");
@@ -909,7 +918,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
909
918
  const semaphore = this.#getSpawnSemaphore();
910
919
  if (spawnItems.length === 1) {
911
920
  const invokedAt = Date.now();
912
- await semaphore.acquire();
921
+ await semaphore.acquire(signal);
913
922
  const acquiredAt = Date.now();
914
923
  try {
915
924
  return await this.#executeSync(
@@ -948,7 +957,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
948
957
  spawnItems.length,
949
958
  async (item, index, workerSignal) => {
950
959
  const invokedAt = Date.now();
951
- await semaphore.acquire();
960
+ await semaphore.acquire(workerSignal);
952
961
  const acquiredAt = Date.now();
953
962
  try {
954
963
  const itemOnUpdate: AgentToolUpdateCallback<TaskToolDetails> | undefined = onUpdate
@@ -44,6 +44,33 @@ const astGrepSchema = type({
44
44
  "skip?": type("number").describe("matches to skip"),
45
45
  });
46
46
 
47
+ function compareAstFindMatch(left: AstFindMatch, right: AstFindMatch): number {
48
+ const pathCmp = left.path.localeCompare(right.path);
49
+ if (pathCmp !== 0) return pathCmp;
50
+ if (left.startLine !== right.startLine) return left.startLine - right.startLine;
51
+ if (left.startColumn !== right.startColumn) return left.startColumn - right.startColumn;
52
+ if (left.endLine !== right.endLine) return left.endLine - right.endLine;
53
+ if (left.endColumn !== right.endColumn) return left.endColumn - right.endColumn;
54
+ if (left.byteStart !== right.byteStart) return left.byteStart - right.byteStart;
55
+ return left.byteEnd - right.byteEnd;
56
+ }
57
+
58
+ function retainAstFindMatch(matches: AstFindMatch[], capacity: number, candidate: AstFindMatch): void {
59
+ if (matches.length < capacity) {
60
+ matches.push(candidate);
61
+ return;
62
+ }
63
+ let worstIndex = 0;
64
+ for (let index = 1; index < matches.length; index++) {
65
+ if (compareAstFindMatch(matches[index]!, matches[worstIndex]!) > 0) {
66
+ worstIndex = index;
67
+ }
68
+ }
69
+ if (compareAstFindMatch(candidate, matches[worstIndex]!) < 0) {
70
+ matches[worstIndex] = candidate;
71
+ }
72
+ }
73
+
47
74
  async function runMultiTargetAstGrep(
48
75
  targets: Array<{ basePath: string; glob?: string }>,
49
76
  options: { patterns: string[]; commonBasePath: string; skip: number; limit: number; signal?: AbortSignal },
@@ -55,9 +82,11 @@ async function runMultiTargetAstGrep(
55
82
  limitReached: boolean;
56
83
  parseErrors?: string[];
57
84
  }> {
58
- const aggregatedMatches: AstFindMatch[] = [];
85
+ const retainedMatches: AstFindMatch[] = [];
86
+ const retainedCapacity = options.skip + options.limit + 1;
59
87
  const parseErrors: string[] = [];
60
88
  let totalMatches = 0;
89
+ let filesWithMatches = 0;
61
90
  let filesSearched = 0;
62
91
  let limitReached = false;
63
92
  for (const target of targets) {
@@ -71,26 +100,19 @@ async function runMultiTargetAstGrep(
71
100
  signal: options.signal,
72
101
  });
73
102
  totalMatches += targetResult.totalMatches;
103
+ filesWithMatches += targetResult.filesWithMatches;
74
104
  filesSearched += targetResult.filesSearched;
75
105
  limitReached = limitReached || targetResult.limitReached;
76
106
  if (targetResult.parseErrors) parseErrors.push(...targetResult.parseErrors);
77
107
  for (const match of targetResult.matches) {
78
108
  const absolute = path.resolve(target.basePath, match.path);
79
109
  const rebased = path.relative(options.commonBasePath, absolute).replace(/\\/g, "/");
80
- aggregatedMatches.push({ ...match, path: rebased });
110
+ retainAstFindMatch(retainedMatches, retainedCapacity, { ...match, path: rebased });
81
111
  }
82
112
  }
83
- aggregatedMatches.sort((left, right) => {
84
- const pathCmp = left.path.localeCompare(right.path);
85
- if (pathCmp !== 0) return pathCmp;
86
- if (left.startLine !== right.startLine) return left.startLine - right.startLine;
87
- if (left.startColumn !== right.startColumn) return left.startColumn - right.startColumn;
88
- if (left.byteStart !== right.byteStart) return left.byteStart - right.byteStart;
89
- return left.byteEnd - right.byteEnd;
90
- });
91
- const visible = aggregatedMatches.slice(options.skip);
113
+ retainedMatches.sort(compareAstFindMatch);
114
+ const visible = retainedMatches.slice(options.skip);
92
115
  const paged = visible.slice(0, options.limit);
93
- const filesWithMatches = new Set(aggregatedMatches.map(match => match.path)).size;
94
116
  return {
95
117
  matches: paged,
96
118
  totalMatches,
package/src/tools/grep.ts CHANGED
@@ -122,8 +122,10 @@ export const SINGLE_FILE_MATCHES = 200;
122
122
  * pagination headroom so the caller can see total file count. */
123
123
  const INTERNAL_TOTAL_CAP = 2000;
124
124
  /** Mirrors `MAX_FILE_BYTES` in `crates/pi-natives/src/grep.rs`. Native grep
125
- * silently returns no matches for files larger than this; surface a warning
126
- * when the caller explicitly targeted such a file so they know to chunk it. */
125
+ * searches only the first `MAX_FILE_BYTES` of a larger file (a leading mmap
126
+ * window) and drops the rest; matches beyond the window are not returned. We
127
+ * surface a partial-coverage note when the caller explicitly targeted such a
128
+ * file so they know matches past the window are not shown. */
127
129
  const NATIVE_GREP_MAX_FILE_BYTES = 4 * 1024 * 1024;
128
130
  /** Wall-clock budget for a single native grep invocation. Without it, an
129
131
  * aborted or runaway search (huge tree, network mount) keeps burning CPU on
@@ -1260,8 +1262,9 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
1260
1262
  const { record: recordFile, list: fileList } = createFileRecorder();
1261
1263
  const fileMatchCounts = new Map<string, number>();
1262
1264
  // Detect explicit file targets that exceed the native grep size cap.
1263
- // Native silently returns no matches above the cap; without this note the
1264
- // caller sees "no matches" for a literal pattern that visibly exists.
1265
+ // Native searches only their first NATIVE_GREP_MAX_FILE_BYTES; without
1266
+ // this note the caller might miss that matches beyond the window
1267
+ // (or "no matches") reflect partial coverage, not the whole file.
1265
1268
  const oversizedNote = await (async (): Promise<string | undefined> => {
1266
1269
  const explicitFileTargets: string[] = [];
1267
1270
  if (exactFilePaths) {
@@ -1285,13 +1288,13 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
1285
1288
  );
1286
1289
  if (oversized.length === 0) return undefined;
1287
1290
  const limitMb = Math.floor(NATIVE_GREP_MAX_FILE_BYTES / (1024 * 1024));
1288
- return `Skipped oversized files (>${limitMb}MB grep limit; split the file or narrow with \`read\`): ${oversized.join(", ")}`;
1291
+ return `Searched only the first ${limitMb}MB of large files (matches past the ${limitMb}MB window are not shown; use \`read\` for the rest): ${oversized.join(", ")}`;
1289
1292
  })();
1290
- // Directory/multi-target scopes: native grep counts oversized skips but
1291
- // cannot name them; explicit-file scopes are covered (with names) above.
1293
+ // Directory/multi-target scopes: native counts files it could not map
1294
+ // even a prefix of (rare mmap failures), but cannot name them.
1292
1295
  const oversizedScanNote =
1293
1296
  !oversizedNote && skippedOversizedCount > 0
1294
- ? `Skipped ${skippedOversizedCount} oversized file(s) (>${Math.floor(NATIVE_GREP_MAX_FILE_BYTES / (1024 * 1024))}MB grep limit); target them directly with \`read\``
1297
+ ? `Skipped ${skippedOversizedCount} unreadable large file(s); target them directly with \`read\``
1295
1298
  : undefined;
1296
1299
  const archiveNote =
1297
1300
  archiveUnreadable.length > 0
package/src/utils/git.ts CHANGED
@@ -175,6 +175,14 @@ const SHORT_LIVED_GIT_CONFIG: readonly (readonly [key: string, value: string])[]
175
175
  ["core.untrackedCache", "false"],
176
176
  ];
177
177
  const REMOTE_ALREADY_EXISTS = /remote .* already exists/i;
178
+ const AMBIENT_GIT_ENV = {
179
+ GIT_DIR: undefined,
180
+ GIT_COMMON_DIR: undefined,
181
+ GIT_WORK_TREE: undefined,
182
+ GIT_INDEX_FILE: undefined,
183
+ GIT_OBJECT_DIRECTORY: undefined,
184
+ GIT_ALTERNATE_OBJECT_DIRECTORIES: undefined,
185
+ } satisfies Record<string, undefined>;
178
186
 
179
187
  interface CommandOptions {
180
188
  readonly env?: Record<string, string | undefined>;
@@ -190,6 +198,15 @@ function normalizeStdin(input: CommandOptions["stdin"]): "ignore" | Uint8Array {
190
198
  return new Uint8Array(input);
191
199
  }
192
200
 
201
+ function buildGitEnv(overrides?: Record<string, string | undefined>): Record<string, string | undefined> {
202
+ return {
203
+ ...process.env,
204
+ GIT_OPTIONAL_LOCKS: "0",
205
+ ...AMBIENT_GIT_ENV,
206
+ ...overrides,
207
+ };
208
+ }
209
+
193
210
  function ensureAvailable(): void {
194
211
  if (!$which("git")) {
195
212
  throw new Error("git is not installed.");
@@ -211,7 +228,7 @@ async function git(cwd: string, args: readonly string[], options: CommandOptions
211
228
  const commandArgs = withShortLivedGitConfig(options.readOnly ? withNoOptionalLocks(args) : [...args]);
212
229
  const child = Bun.spawn(["git", ...commandArgs], {
213
230
  cwd,
214
- env: options.env ? { ...process.env, GIT_OPTIONAL_LOCKS: "0", ...options.env } : undefined,
231
+ env: buildGitEnv(options.env),
215
232
  signal: options.signal,
216
233
  stdin: normalizeStdin(options.stdin),
217
234
  stdout: "pipe",
@@ -707,6 +724,7 @@ function resolveHeadStateReftableSync(repository: GitRepository): GitHeadState |
707
724
  const symArgs = withShortLivedGitConfig(withNoOptionalLocks(["symbolic-ref", "HEAD"]));
708
725
  const symResult = Bun.spawnSync(["git", ...symArgs], {
709
726
  cwd: repository.repoRoot,
727
+ env: buildGitEnv(),
710
728
  stdout: "pipe",
711
729
  stderr: "pipe",
712
730
  windowsHide: true,
@@ -715,6 +733,7 @@ function resolveHeadStateReftableSync(repository: GitRepository): GitHeadState |
715
733
  const revArgs = withShortLivedGitConfig(withNoOptionalLocks(["rev-parse", "--verify", "HEAD"]));
716
734
  const revResult = Bun.spawnSync(["git", ...revArgs], {
717
735
  cwd: repository.repoRoot,
736
+ env: buildGitEnv(),
718
737
  stdout: "pipe",
719
738
  stderr: "pipe",
720
739
  windowsHide: true,
@@ -748,6 +767,7 @@ function readRefSync(repository: GitRepository, targetRef: string): string | nul
748
767
  const symArgs = withShortLivedGitConfig(withNoOptionalLocks(["symbolic-ref", targetRef]));
749
768
  const symResult = Bun.spawnSync(["git", ...symArgs], {
750
769
  cwd: repository.repoRoot,
770
+ env: buildGitEnv(),
751
771
  stdout: "pipe",
752
772
  stderr: "pipe",
753
773
  windowsHide: true,
@@ -759,6 +779,7 @@ function readRefSync(repository: GitRepository, targetRef: string): string | nul
759
779
  const revArgs = withShortLivedGitConfig(withNoOptionalLocks(["rev-parse", "--verify", targetRef]));
760
780
  const revResult = Bun.spawnSync(["git", ...revArgs], {
761
781
  cwd: repository.repoRoot,
782
+ env: buildGitEnv(),
762
783
  stdout: "pipe",
763
784
  stderr: "pipe",
764
785
  windowsHide: true,