mirror-mind 0.33.0 → 0.33.2

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.
@@ -1,20 +1,36 @@
1
1
  // Release-note reading for the runtime surface (CV22.DS7.TS3, plateau 2).
2
2
  // Port of the release-note half of `src/memory/cli/runtime.py`.
3
3
  //
4
- // Two sources, one document shape. The working tree answers "what does this
5
- // checkout contain"; a git ref answers "what does the channel contain that I
6
- // have not merged", which is the question `release-notes pending` exists for
7
- // and the reason this path shells out to `ls-tree`/`show` instead of reading
8
- // files. Ordering is by semantic version everywhere: `v0.10.0` follows
9
- // `v0.9.0`, which a lexical sort gets backwards.
4
+ // Three sources, one document shape. A tree answers "what does this tree
5
+ // contain": `docs/releases` under the root the caller names, read as files
6
+ // with no git call -- the caller passes the running tree, which for a package
7
+ // may sit inside a git work tree it does not own, such as Homebrew's prefix
8
+ // (CR133). The other two answer `release-notes pending`, "what does my channel
9
+ // hold that I do not", for the install that is running and never for the cwd
10
+ // (CR134): a clone's git ref, read in the clone's own repository -- the only
11
+ // path here that shells out to git -- and a package's channel version,
12
+ // resolved by the registry and read from that version's tarball in memory.
13
+ // Ordering is by semantic version everywhere: `v0.10.0` follows `v0.9.0`,
14
+ // which a lexical sort gets backwards.
10
15
  //
11
- // `fetch` is the one command here that writes anything — remote-tracking refs,
12
- // never the worktree — and only when `release-notes pending` is allowed to
13
- // refresh. Everything else reads.
16
+ // When no source answers, the bundle says so -- `unknown`, with a reason --
17
+ // rather than `none`, which means a source answered and held nothing newer.
18
+ //
19
+ // `fetch` writes remote-tracking refs in the clone, never its worktree, and
20
+ // `npm pack` one tarball into a private directory removed after the read.
21
+ // Everything else reads.
14
22
 
15
- import { existsSync, readdirSync, readFileSync } from "node:fs";
23
+ import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
24
+ import { tmpdir } from "node:os";
16
25
  import { basename, join, resolve } from "node:path";
26
+ import {
27
+ channelDirection,
28
+ type NpmRunner,
29
+ type PackageInstall,
30
+ packTarball,
31
+ } from "#runtime/strategies/package.ts";
17
32
  import { compareSemver } from "#util/semver.ts";
33
+ import { MAX_INFLATED_BYTES, readTarGzEntries } from "#util/tarEntries.ts";
18
34
  import { GIT_NETWORK_TIMEOUT_MS, runGit } from "./git.ts";
19
35
 
20
36
  export interface ReleaseNote {
@@ -29,6 +45,8 @@ export interface ReleaseNotesBundle {
29
45
  notes: ReleaseNote[];
30
46
  current_version: string | null;
31
47
  source: string | null;
48
+ /** Why no source answered; null when one did, with notes or with none (CR134). */
49
+ unknown_reason: string | null;
32
50
  }
33
51
 
34
52
  const NOTE_NAME = /^v\d+\.\d+\.\d+\.md$/;
@@ -99,15 +117,13 @@ export function releaseNoteFromText(path: string, text: string): ReleaseNote {
99
117
  };
100
118
  }
101
119
 
102
- function repositoryFor(start: string): string {
103
- const startPath = resolve(start);
104
- const top = runGit(["rev-parse", "--show-toplevel"], startPath);
105
- return top.code === 0 && top.stdout ? top.stdout : startPath;
106
- }
107
-
108
- /** Notes in the working tree, ordered by semantic version. */
109
- export function readReleaseNotes(start: string): ReleaseNote[] {
110
- const dir = join(repositoryFor(start), "docs", "releases");
120
+ /**
121
+ * Notes under `<root>/docs/releases`, ordered by semantic version. The root is
122
+ * read as given: asking git for its repository would climb out of a package
123
+ * installed under a prefix that is itself a work tree (CR133).
124
+ */
125
+ export function readReleaseNotes(root: string): ReleaseNote[] {
126
+ const dir = join(resolve(root), "docs", "releases");
111
127
  if (!existsSync(dir)) return [];
112
128
  return readdirSync(dir)
113
129
  .filter((name) => NOTE_NAME.test(name))
@@ -118,58 +134,104 @@ export function readReleaseNotes(start: string): ReleaseNote[] {
118
134
  });
119
135
  }
120
136
 
121
- export function readReleaseNote(version: string, start: string): ReleaseNote | null {
122
- const notes = readReleaseNotes(start);
137
+ export function readReleaseNote(version: string, root: string): ReleaseNote | null {
138
+ const notes = readReleaseNotes(root);
123
139
  if (version === "latest")
124
140
  return notes.length > 0 ? (notes[notes.length - 1] as ReleaseNote) : null;
125
141
  const wanted = version.startsWith("v") ? version : `v${version}`;
126
142
  return notes.find((note) => note.version === wanted) ?? null;
127
143
  }
128
144
 
129
- function refNoteNames(ref: string, repository: string): string[] {
130
- const listed = runGit(["ls-tree", "-r", "--name-only", ref, "docs/releases"], repository);
131
- if (listed.code !== 0 || !listed.stdout) return [];
132
- return listed.stdout
145
+ /**
146
+ * A ref is user-supplied (`--ref`), and git takes an argument that begins with
147
+ * `-` as an option, `--upload-pack=<command>` among them (CR134). Such a ref is
148
+ * refused before git runs, and every verb below also puts `--end-of-options`
149
+ * before its operands, which covers the half of a ref after its first slash.
150
+ */
151
+ function refusedRef(ref: string): string | null {
152
+ if (ref === "") return "no ref was named";
153
+ if (ref.startsWith("-")) return `ref ${JSON.stringify(ref)} begins with '-' and is refused`;
154
+ return null;
155
+ }
156
+
157
+ function firstLine(text: string): string {
158
+ return (
159
+ text
160
+ .split("\n")
161
+ .find((line) => line.trim() !== "")
162
+ ?.trim() ?? ""
163
+ );
164
+ }
165
+
166
+ type RefNames = { ok: true; names: string[] } | { ok: false; detail: string };
167
+
168
+ /** The note paths at `ref`. A ref that does not resolve is said, not read as empty. */
169
+ function refNoteNames(ref: string, repository: string): RefNames {
170
+ const refused = refusedRef(ref);
171
+ if (refused !== null) return { ok: false, detail: refused };
172
+ const listed = runGit(
173
+ ["ls-tree", "-r", "--name-only", "--end-of-options", ref, "docs/releases"],
174
+ repository,
175
+ );
176
+ if (listed.code !== 0) return { ok: false, detail: `ref ${ref} does not resolve` };
177
+ const names = listed.stdout
133
178
  .split("\n")
134
179
  .map((line) => line.trim())
135
180
  .filter((line) => REF_NOTE_PATH.test(line));
181
+ return { ok: true, names };
182
+ }
183
+
184
+ function showAtRef(ref: string, path: string, repository: string): string | null {
185
+ const shown = runGit(["show", "--end-of-options", `${ref}:${path}`], repository);
186
+ return shown.code === 0 && shown.stdout ? shown.stdout : null;
136
187
  }
137
188
 
138
- /** Notes as the ref has them — what a channel contains, merged or not. */
139
- export function readReleaseNotesFromRef(ref: string, start: string): ReleaseNote[] {
140
- const repository = repositoryFor(start);
141
- const names = refNoteNames(ref, repository).sort((a, b) =>
189
+ type RefNotes = { ok: true; notes: ReleaseNote[] } | { ok: false; detail: string };
190
+
191
+ function readRefNotes(ref: string, repository: string): RefNotes {
192
+ const listed = refNoteNames(ref, repository);
193
+ if (!listed.ok) return listed;
194
+ const names = listed.names.sort((a, b) =>
142
195
  compareSemver(basename(a).replace(/\.md$/, ""), basename(b).replace(/\.md$/, "")),
143
196
  );
144
197
  const notes: ReleaseNote[] = [];
145
198
  for (const selected of names) {
146
- const shown = runGit(["show", `${ref}:${selected}`], repository);
147
- if (shown.code !== 0 || !shown.stdout) continue;
148
- notes.push(releaseNoteFromText(selected, shown.stdout));
199
+ const text = showAtRef(ref, selected, repository);
200
+ if (text !== null) notes.push(releaseNoteFromText(selected, text));
149
201
  }
150
- return notes;
202
+ return { ok: true, notes };
203
+ }
204
+
205
+ /**
206
+ * Notes as the ref has them — what a channel contains, merged or not — in the
207
+ * repository given, never one discovered from a cwd (CR134). A ref that does
208
+ * not resolve yields none here; `buildPendingReleaseNotes` says why.
209
+ */
210
+ export function readReleaseNotesFromRef(ref: string, repository: string): ReleaseNote[] {
211
+ const read = readRefNotes(ref, resolve(repository));
212
+ return read.ok ? read.notes : [];
151
213
  }
152
214
 
153
215
  export function readReleaseNoteFromRef(
154
216
  ref: string,
155
217
  version: string,
156
- start: string,
218
+ repository: string,
157
219
  ): ReleaseNote | null {
158
- const repository = repositoryFor(start);
220
+ const root = resolve(repository);
159
221
  let selected: string;
160
222
  if (version === "latest") {
161
- const names = refNoteNames(ref, repository);
162
- if (names.length === 0) return null;
163
- selected = names.sort((a, b) =>
223
+ const listed = refNoteNames(ref, root);
224
+ if (!listed.ok || listed.names.length === 0) return null;
225
+ selected = listed.names.sort((a, b) =>
164
226
  compareSemver(basename(b).replace(/\.md$/, ""), basename(a).replace(/\.md$/, "")),
165
227
  )[0] as string;
166
228
  } else {
229
+ if (refusedRef(ref) !== null) return null;
167
230
  const wanted = version.startsWith("v") ? version : `v${version}`;
168
231
  selected = `docs/releases/${wanted}.md`;
169
232
  }
170
- const shown = runGit(["show", `${ref}:${selected}`], repository);
171
- if (shown.code !== 0 || !shown.stdout) return null;
172
- return releaseNoteFromText(selected, shown.stdout);
233
+ const text = showAtRef(ref, selected, root);
234
+ return text === null ? null : releaseNoteFromText(selected, text);
173
235
  }
174
236
 
175
237
  /** Port of `_normalize_version`: drop a leading `v`, keep the rest. */
@@ -178,30 +240,141 @@ export function normalizeVersion(version: string): string {
178
240
  return trimmed.startsWith("v") ? trimmed.slice(1) : trimmed;
179
241
  }
180
242
 
181
- /** Refresh the channel ref before reading it. The only write in this module. */
182
- export function fetchReleaseNotesRef(ref: string, repository: string): void {
243
+ export type RefFetch =
244
+ | { kind: "skipped" }
245
+ | { kind: "fetched" }
246
+ | { kind: "failed"; detail: string };
247
+
248
+ /**
249
+ * Refresh the channel ref before reading it: `git fetch <remote> <branch>` for
250
+ * a ref shaped `<remote>/<branch>`, in the clone's own repository. A ref with
251
+ * no remote part, such as `HEAD`, has nothing to fetch, and a refused ref never
252
+ * reaches git.
253
+ */
254
+ export function fetchReleaseNotesRef(ref: string, repository: string): RefFetch {
255
+ if (refusedRef(ref) !== null) return { kind: "skipped" };
183
256
  const slash = ref.indexOf("/");
184
- if (slash <= 0 || slash === ref.length - 1) return;
257
+ if (slash <= 0 || slash === ref.length - 1) return { kind: "skipped" };
185
258
  const remote = ref.slice(0, slash);
186
259
  const branch = ref.slice(slash + 1);
187
- runGit(["fetch", remote, branch], repository, GIT_NETWORK_TIMEOUT_MS);
260
+ const fetched = runGit(
261
+ ["fetch", "--end-of-options", remote, branch],
262
+ repository,
263
+ GIT_NETWORK_TIMEOUT_MS,
264
+ );
265
+ if (fetched.code === 0) return { kind: "fetched" };
266
+ return {
267
+ kind: "failed",
268
+ detail: firstLine(fetched.stderr) || `git fetch exited ${fetched.code}`,
269
+ };
270
+ }
271
+
272
+ /** A bundle no source answered: `Pending releases: unknown`, and why. */
273
+ export function unknownReleaseNotes(
274
+ currentVersion: string,
275
+ source: string | null,
276
+ reason: string,
277
+ ): ReleaseNotesBundle {
278
+ return {
279
+ notes: [],
280
+ current_version: `v${normalizeVersion(currentVersion)}`,
281
+ source,
282
+ unknown_reason: reason,
283
+ };
188
284
  }
189
285
 
190
286
  export interface PendingOptions {
191
287
  currentVersion: string;
192
288
  ref: string;
193
- start: string;
289
+ /** The install's own checkout, used as given: never discovered from a cwd (CR134). */
290
+ repository: string;
194
291
  fetch: boolean;
195
292
  }
196
293
 
294
+ /**
295
+ * A clone's pending notes: the releases at `ref` newer than the installed
296
+ * version. A fetch that fails is `unknown`, not a quiet read of a stale ref;
297
+ * `--no-fetch` is how to ask for the ref as last fetched (CR134, decision 2).
298
+ */
197
299
  export function buildPendingReleaseNotes(options: PendingOptions): ReleaseNotesBundle {
198
- const repository = repositoryFor(options.start);
300
+ const repository = resolve(options.repository);
199
301
  const current = normalizeVersion(options.currentVersion);
200
- if (options.fetch) fetchReleaseNotesRef(options.ref, repository);
201
- const notes = readReleaseNotesFromRef(options.ref, repository).filter((note) =>
202
- isNewer(note.version, current),
203
- );
204
- return { notes, current_version: `v${current}`, source: options.ref };
302
+ const unknown = (reason: string) => unknownReleaseNotes(current, options.ref, reason);
303
+ const refused = refusedRef(options.ref);
304
+ if (refused !== null) return unknown(refused);
305
+ if (options.fetch) {
306
+ const fetched = fetchReleaseNotesRef(options.ref, repository);
307
+ if (fetched.kind === "failed") return unknown(`fetch failed: ${fetched.detail}`);
308
+ }
309
+ const read = readRefNotes(options.ref, repository);
310
+ if (!read.ok) return unknown(read.detail);
311
+ return {
312
+ notes: read.notes.filter((note) => isNewer(note.version, current)),
313
+ current_version: `v${current}`,
314
+ source: options.ref,
315
+ unknown_reason: null,
316
+ };
317
+ }
318
+
319
+ export interface RegistryPendingOptions {
320
+ currentVersion: string;
321
+ install: PackageInstall;
322
+ channel: string;
323
+ npm: NpmRunner;
324
+ }
325
+
326
+ const TARBALL_NOTE = /^package\/docs\/releases\/v\d+\.\d+\.\d+\.md$/;
327
+
328
+ /**
329
+ * A package's pending notes (CR134). The registry resolves the channel's
330
+ * dist-tag to a version, as `runtime update` does at plan. When that version is
331
+ * ahead, its exact tarball is fetched into a private directory and the notes
332
+ * are read in memory, within the reader's bound; when it is current or behind,
333
+ * nothing is downloaded. Every npm call runs in the install's root (CR135).
334
+ */
335
+ export function buildPendingReleaseNotesFromRegistry(
336
+ options: RegistryPendingOptions,
337
+ ): ReleaseNotesBundle {
338
+ const { install, channel, npm } = options;
339
+ const current = normalizeVersion(options.currentVersion);
340
+ const tag = `npm ${install.name}@${channel}`;
341
+ const direction = channelDirection({ ...install, version: current }, channel, npm);
342
+ if (direction.kind === "unresolved") return unknownReleaseNotes(current, tag, direction.detail);
343
+ const target = direction.version;
344
+ const source = `${tag} (${target})`;
345
+ if (direction.kind !== "ahead") {
346
+ return { notes: [], current_version: `v${current}`, source, unknown_reason: null };
347
+ }
348
+
349
+ const destination = mkdtempSync(join(tmpdir(), "mirror-release-notes-"));
350
+ try {
351
+ const packed = packTarball(install.name, target, install.root, destination, npm);
352
+ if (!packed.ok) return unknownReleaseNotes(current, source, packed.detail);
353
+ // The compressed file is read whole before it is inflated, so it is bounded too.
354
+ if (statSync(packed.path).size > MAX_INFLATED_BYTES) {
355
+ return unknownReleaseNotes(
356
+ current,
357
+ source,
358
+ `the tarball is larger than ${MAX_INFLATED_BYTES} bytes`,
359
+ );
360
+ }
361
+ const read = readTarGzEntries(readFileSync(packed.path), (name) => TARBALL_NOTE.test(name));
362
+ if (!read.ok) return unknownReleaseNotes(current, source, read.detail);
363
+ const notes = [...read.entries]
364
+ .sort(([a], [b]) =>
365
+ compareSemver(basename(a).replace(/\.md$/, ""), basename(b).replace(/\.md$/, "")),
366
+ )
367
+ .map(([name, bytes]) =>
368
+ releaseNoteFromText(
369
+ `${install.name}@${target}:${name.slice("package/".length)}`,
370
+ bytes.toString("utf8"),
371
+ ),
372
+ )
373
+ .filter((note) => isNewer(note.version, current));
374
+ return { notes, current_version: `v${current}`, source, unknown_reason: null };
375
+ } finally {
376
+ rmSync(destination, { recursive: true, force: true });
377
+ }
205
378
  }
206
379
 
207
380
  export function renderReleaseNote(note: ReleaseNote | null): string {
@@ -226,6 +399,11 @@ export function renderReleaseNotesBundle(bundle: ReleaseNotesBundle): string {
226
399
  const lines = ["Mirror runtime release notes", ""];
227
400
  if (bundle.current_version) lines.push(`Current version: ${bundle.current_version}`);
228
401
  if (bundle.source) lines.push(`Source: ${bundle.source}`);
402
+ if (bundle.unknown_reason !== null) {
403
+ lines.push("Pending releases: unknown");
404
+ lines.push(`Reason: ${bundle.unknown_reason}`);
405
+ return `${lines.join("\n")}\n`;
406
+ }
229
407
  if (bundle.notes.length === 0) {
230
408
  lines.push("Pending releases: none");
231
409
  return `${lines.join("\n")}\n`;
@@ -26,16 +26,21 @@ import { inspectMigrationFiles } from "#extensions/migrations.ts";
26
26
  import { type DbPathEnv, dbNameForEnv, resolveMirrorHome } from "#frontDoor/dbPath.ts";
27
27
  import { DEFAULT_EMBEDDING_MODEL, DEFAULT_EXTRACTION_MODEL } from "#providers/config.ts";
28
28
  import { sortByCodePoint } from "#util/pythonText.ts";
29
- import { channelFor } from "./channel.ts";
29
+ import { channelForReport } from "./channel.ts";
30
30
  import type { MarkerValue } from "./git.ts";
31
31
  import {
32
+ cloneRoleFor,
32
33
  type GitStatus,
33
- inspectCloneRole,
34
34
  inspectGitFor,
35
- inspectUpdateChannel,
35
+ renderCloneRoleLines,
36
36
  renderRepositoryLines,
37
37
  } from "./git.ts";
38
- import { detectInstallKind, type InstallKind, renderInstallLines } from "./installKind.ts";
38
+ import {
39
+ detectInstallKind,
40
+ type InstallKind,
41
+ inspectionRootFor,
42
+ renderInstallLines,
43
+ } from "./installKind.ts";
39
44
 
40
45
  export interface CoreMigrationHealth {
41
46
  ready: boolean;
@@ -339,6 +344,11 @@ export function detectNodeVersion(): string | null {
339
344
  }
340
345
 
341
346
  export interface BuildStatusOptions {
347
+ /**
348
+ * An explicit repository to inspect. Absent, the install's own: a clone's
349
+ * checkout, and for anything else none (CR138). It used to default to the
350
+ * cwd, which for a skill is the person's project.
351
+ */
342
352
  start?: string;
343
353
  mirrorHome?: string | null;
344
354
  channel?: string | null;
@@ -354,21 +364,6 @@ export interface BuildStatusOptions {
354
364
  install?: InstallKind;
355
365
  }
356
366
 
357
- /**
358
- * The channel the report shows. A clone is inspected at `start`, as the
359
- * oracle did and `version --start PATH` still means; a package has no tree at
360
- * `start` and keeps its channel in the user's config file (US3 D9).
361
- */
362
- function channelForStatus(
363
- install: InstallKind,
364
- startPath: string,
365
- override: string | null,
366
- env: NodeJS.ProcessEnv,
367
- ): MarkerValue {
368
- if (install.kind === "package") return channelFor(install, env, undefined, override);
369
- return inspectUpdateChannel(startPath, override);
370
- }
371
-
372
367
  /**
373
368
  * Python's `resolve_mirror_home` raises `ValueError`, and
374
369
  * `build_runtime_status` prints `str(exc)` as `Mirror home note:`. The front
@@ -394,16 +389,17 @@ function mirrorHomeForStatus(env: DbPathEnv): { home: string | null; error: stri
394
389
 
395
390
  /** Port of `build_runtime_status`. */
396
391
  export function buildRuntimeStatus(options: BuildStatusOptions = {}): RuntimeStatusReport {
397
- const startPath = resolve(options.start ?? process.cwd());
398
392
  const env = options.env ?? process.env;
399
393
  const install =
400
394
  options.install ??
401
395
  detectInstallKind({ frontDoorPath: new URL("../frontDoor/cli.ts", import.meta.url).pathname });
402
- // The git facts are a clone's. A package has no repository, and grading the
403
- // cwd as one read "not a git repository" as a git error, said `attention
404
- // needed`, and so refused every package update at the gate (CV22.DS10.US3
405
- // plateau 4, found by the package lane of the update smoke).
406
- const git = inspectGitFor(install, startPath);
396
+ // The repository rows are the install's own, or the one `start` names; never
397
+ // the cwd's (CR138). The git facts are a clone's: a package has no
398
+ // repository, and grading the cwd as one read "not a git repository" as a git
399
+ // error, said `attention needed`, and so refused every package update at the
400
+ // gate (CV22.DS10.US3 plateau 4, found by the package lane of the update smoke).
401
+ const root = inspectionRootFor(install, options.start);
402
+ const git = inspectGitFor(install, root);
407
403
 
408
404
  let mirrorHome: string | null;
409
405
  let mirrorHomeError: string | null;
@@ -430,9 +426,9 @@ export function buildRuntimeStatus(options: BuildStatusOptions = {}): RuntimeSta
430
426
  core_migrations: inspectCoreMigrations(dbPath, dbExists),
431
427
  extensions: listInstalledExtensions(mirrorHome),
432
428
  extension_health: inspectExtensionHealth(mirrorHome, dbPath, dbExists),
433
- clone_role: inspectCloneRole(startPath),
429
+ clone_role: cloneRoleFor(install, root),
434
430
  memory_env: memoryEnv,
435
- update_channel: channelForStatus(install, startPath, options.channel ?? null, env),
431
+ update_channel: channelForReport(install, root, env, undefined, options.channel ?? null),
436
432
  node_version: options.nodeVersion === undefined ? detectNodeVersion() : options.nodeVersion,
437
433
  };
438
434
  }
@@ -533,8 +529,7 @@ export function renderRuntimeStatus(
533
529
  lines.push("Installed extensions: 0");
534
530
  }
535
531
  lines.push(...renderExtensionHealth(report.extension_health));
536
- lines.push(`Clone role: ${report.clone_role.value}`);
537
- if (report.clone_role.note) lines.push(`Clone role note: ${report.clone_role.note}`);
532
+ lines.push(...renderCloneRoleLines(report.install, report.clone_role));
538
533
  lines.push(`Update channel: ${report.update_channel.value}`);
539
534
  if (report.update_channel.note) lines.push(`Update channel note: ${report.update_channel.note}`);
540
535
  lines.push(`Node: ${report.node_version || "not found (TS front door requires Node >= 24)"}`);
@@ -9,15 +9,18 @@
9
9
  // decisions can be driven in tests without a remote, and so this module can be
10
10
  // read as "what we ask git for" rather than "how we spawn it".
11
11
 
12
- import { runGit } from "#runtime/git.ts";
12
+ import { GIT_NETWORK_TIMEOUT_MS, runGit } from "#runtime/git.ts";
13
13
  import type { ApplyStrategy } from "#runtime/updatePipeline.ts";
14
14
 
15
+ /** Without a timeout, a command gets `runGit`'s local budget. */
15
16
  export type GitRunner = (
16
17
  args: readonly string[],
17
18
  cwd: string,
19
+ timeoutMs?: number,
18
20
  ) => { code: number; stdout: string; stderr: string };
19
21
 
20
- export const defaultGitRunner: GitRunner = (args, cwd) => runGit([...args], cwd);
22
+ export const defaultGitRunner: GitRunner = (args, cwd, timeoutMs) =>
23
+ runGit([...args], cwd, timeoutMs);
21
24
 
22
25
  /** `origin/main` -> `["origin", "main"]`; anything else is refused. */
23
26
  export function splitUpstream(upstream: string): [string, string] | null {
@@ -44,7 +47,11 @@ export function fetchUpstream(
44
47
  const split = splitUpstream(upstream);
45
48
  if (split === null) return { ok: false, detail: "unexpected upstream name" };
46
49
  const [remote, branch] = split;
47
- const result = git(["fetch", remote, branch], repository);
50
+ // The one command here that crosses the network, so it gets the network
51
+ // budget (CR132). On the 2-second local budget a fetch still in flight was
52
+ // killed and reported failed. The oracle's `_git_fetch` had this since
53
+ // 521177f8, and the port lost it. Both update lanes run this fetch.
54
+ const result = git(["fetch", remote, branch], repository, GIT_NETWORK_TIMEOUT_MS);
48
55
  return result.code === 0
49
56
  ? { ok: true, detail: `${remote} ${branch}` }
50
57
  : { ok: false, detail: result.stderr.trim() || "fetch failed" };