@schemic/core 0.1.0-alpha.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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +212 -0
  3. package/lib/authoring.d.ts +89 -0
  4. package/lib/authoring.js +187 -0
  5. package/lib/authoring.js.map +1 -0
  6. package/lib/chunk-C4D6JWSE.js +54 -0
  7. package/lib/chunk-C4D6JWSE.js.map +1 -0
  8. package/lib/chunk-T23RNU7G.js +304 -0
  9. package/lib/chunk-T23RNU7G.js.map +1 -0
  10. package/lib/config-TIiKDd9t.d.ts +97 -0
  11. package/lib/config.d.ts +1 -0
  12. package/lib/config.js +8 -0
  13. package/lib/config.js.map +1 -0
  14. package/lib/driver-Dh5hLKHm.d.ts +736 -0
  15. package/lib/driver.d.ts +150 -0
  16. package/lib/driver.js +47 -0
  17. package/lib/driver.js.map +1 -0
  18. package/lib/index.d.ts +84 -0
  19. package/lib/index.js +794 -0
  20. package/lib/index.js.map +1 -0
  21. package/lib/testing.d.ts +29 -0
  22. package/lib/testing.js +111 -0
  23. package/lib/testing.js.map +1 -0
  24. package/package.json +93 -0
  25. package/src/authoring.ts +304 -0
  26. package/src/cli-kit/config.ts +179 -0
  27. package/src/cli-kit/diff.ts +230 -0
  28. package/src/cli-kit/filter.ts +159 -0
  29. package/src/cli-kit/merge.ts +380 -0
  30. package/src/cli-kit/meta.ts +123 -0
  31. package/src/cli-kit/pager.ts +42 -0
  32. package/src/cli-kit/schema.ts +186 -0
  33. package/src/cli-kit/style.ts +24 -0
  34. package/src/config.ts +51 -0
  35. package/src/connection.ts +78 -0
  36. package/src/driver/driver.ts +300 -0
  37. package/src/driver/index.ts +31 -0
  38. package/src/driver/portable-ir.ts +51 -0
  39. package/src/driver/portable.ts +124 -0
  40. package/src/driver/sdk.ts +66 -0
  41. package/src/index.ts +145 -0
  42. package/src/kind/index.ts +28 -0
  43. package/src/kind/plan.ts +390 -0
  44. package/src/kind/registry.ts +225 -0
  45. package/src/testing.ts +181 -0
@@ -0,0 +1,380 @@
1
+ // Surgically merge freshly-pulled `s.*` definitions into existing schema files, instead of
2
+ // overwriting them. Uses magicast (recast under the hood), so untouched code — user comments,
3
+ // extra imports, unrelated consts, hand-formatted fields — survives. The live DB wins on any
4
+ // object/field it defines; the only thing at risk is LOCAL-ONLY content (a field or whole const
5
+ // that exists in your files but not in the DB), which the caller resolves via keep/drop.
6
+
7
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
8
+ import { dirname } from "node:path";
9
+ import { generateCode, parseModule } from "magicast";
10
+ import { colorEnabled, style } from "./style";
11
+
12
+ /** One rendered object (table / function / access): its const statement + the imports it needs. */
13
+ export interface RenderedUnit {
14
+ kind: "table" | "function" | "access";
15
+ /** DB object name (drives the file path). */
16
+ name: string;
17
+ /** The exported const identifier (`User`, `math_add`, …). */
18
+ exportName: string;
19
+ /** The `export const … = define…(…);` statement source (no import lines). */
20
+ code: string;
21
+ /** The `import …` lines this unit needs. */
22
+ imports: string[];
23
+ }
24
+
25
+ /** Local-only content a merge would drop when mirroring the DB. */
26
+ export interface LocalOnly {
27
+ /** Per existing const: field keys present locally but absent from the DB object. */
28
+ fields: { exportName: string; fields: string[] }[];
29
+ /** Whole consts present locally whose object the DB no longer has. */
30
+ objects: string[];
31
+ }
32
+
33
+ export interface MergeResult {
34
+ content: string;
35
+ localOnly: LocalOnly;
36
+ }
37
+
38
+ /** Options governing what happens to local-only content. */
39
+ export interface MergeOptions {
40
+ /** Keep local-only fields (graft them back into the merged object). */
41
+ keepLocalFields: boolean;
42
+ /** Keep local-only consts (objects the DB no longer has). */
43
+ keepLocalObjects: boolean;
44
+ }
45
+
46
+ /** What pulling would do to one schema file. */
47
+ export interface PullFilePlan {
48
+ /** Path relative to the project root (for display). */
49
+ rel: string;
50
+ /** Absolute path on disk. */
51
+ abs: string;
52
+ /**
53
+ * `create` (new file), `update` (merged edits), `unchanged` (already matches the DB), or `delete`
54
+ * (a file that is purely local-only entities the DB doesn't have — removed when mirroring).
55
+ */
56
+ action: "create" | "update" | "unchanged" | "delete";
57
+ /** Current file contents (`""` for a new file). */
58
+ before: string;
59
+ /** Contents after the pull (the merged result). */
60
+ after: string;
61
+ /** Local-only content this file would drop when mirroring the DB. */
62
+ localOnly: LocalOnly;
63
+ }
64
+
65
+ /** A driver's introspection rendered into a per-file write plan (see a driver's `planPull`). */
66
+ export interface PullPlan {
67
+ files: PullFilePlan[];
68
+ }
69
+
70
+ /** Apply a plan: write created/updated files, delete local-only files. Returns the paths touched. */
71
+ export function applyPull(plan: PullPlan): string[] {
72
+ const touched: string[] = [];
73
+ for (const f of plan.files) {
74
+ if (f.action === "unchanged") continue;
75
+ if (f.action === "delete") {
76
+ rmSync(f.abs, { force: true });
77
+ } else {
78
+ mkdirSync(dirname(f.abs), { recursive: true });
79
+ writeFileSync(f.abs, f.after);
80
+ }
81
+ touched.push(f.rel);
82
+ }
83
+ return touched;
84
+ }
85
+
86
+ // --- AST helpers ------------------------------------------------------------------------------
87
+ // A permissive view over the recast/babel nodes we touch — every property we read, all optional,
88
+ // so navigation needs no per-access casts.
89
+ interface AstNode {
90
+ type: string;
91
+ name?: string;
92
+ callee?: AstNode;
93
+ object?: AstNode;
94
+ body?: AstNode;
95
+ arguments?: AstNode[];
96
+ properties?: AstNode[];
97
+ declaration?: AstNode;
98
+ declarations?: Array<{ id?: { name?: string }; init?: AstNode }>;
99
+ key?: { name?: string; value?: string };
100
+ comments?: Array<{ leading?: boolean }>;
101
+ }
102
+
103
+ interface MagicastModule {
104
+ $ast: { body: AstNode[] };
105
+ imports: {
106
+ $add: (s: { from: string; imported: string; local: string }) => void;
107
+ };
108
+ }
109
+
110
+ const asMod = (mod: unknown): MagicastModule =>
111
+ mod as unknown as MagicastModule;
112
+
113
+ /** The exported const's name, or null if this statement isn't an `export const X = …`. */
114
+ function exportConstName(node: AstNode): string | null {
115
+ if (node.type !== "ExportNamedDeclaration") return null;
116
+ if (node.declaration?.type !== "VariableDeclaration") return null;
117
+ return node.declaration.declarations?.[0]?.id?.name ?? null;
118
+ }
119
+
120
+ /** The init expression of an `export const X = <init>`. */
121
+ function exportConstInit(node: AstNode): AstNode | null {
122
+ return node.declaration?.declarations?.[0]?.init ?? null;
123
+ }
124
+
125
+ /** Walk a `define…(…).a().b()` chain down to the `define…(…)` CallExpression. */
126
+ function defineCall(init: AstNode | null): AstNode | null {
127
+ let n: AstNode | null = init;
128
+ while (n) {
129
+ if (n.type === "CallExpression") {
130
+ if (
131
+ n.callee?.type === "Identifier" &&
132
+ /^define/.test(n.callee.name ?? "")
133
+ )
134
+ return n;
135
+ n =
136
+ n.callee?.type === "MemberExpression"
137
+ ? (n.callee.object ?? null)
138
+ : (n.callee ?? null);
139
+ } else if (n.type === "MemberExpression") {
140
+ n = n.object ?? null;
141
+ } else break;
142
+ }
143
+ return null;
144
+ }
145
+
146
+ /** The fields ObjectExpression of a `defineTable`/`defineRelation` call (handles the `self` arrow form). */
147
+ function fieldsObject(init: AstNode | null): AstNode | null {
148
+ const call = defineCall(init);
149
+ if (!call) return null;
150
+ let obj = call.arguments?.[1] ?? null;
151
+ if (obj?.type === "ArrowFunctionExpression") obj = obj.body ?? null;
152
+ return obj?.type === "ObjectExpression" ? obj : null;
153
+ }
154
+
155
+ /** Property key as a string (`name` / `"weird-key"`). */
156
+ function propKey(p: AstNode): string | undefined {
157
+ return p.key?.name ?? p.key?.value;
158
+ }
159
+
160
+ /** Whether a node carries a leading comment (so we don't clobber/duplicate it). */
161
+ function hasLeadingComment(node: AstNode): boolean {
162
+ return Boolean(node.comments?.some((c) => c.leading));
163
+ }
164
+
165
+ /** Parse `import { a, b } from "x"` lines into `{from, names}` (our generated imports use no aliases). */
166
+ function parseImportLine(
167
+ line: string,
168
+ ): { from: string; names: string[] } | null {
169
+ const m = /import\s*\{([^}]*)\}\s*from\s*["']([^"']+)["']/.exec(line);
170
+ if (!m) return null;
171
+ return {
172
+ from: m[2],
173
+ names: m[1]
174
+ .split(",")
175
+ .map((s) => s.trim())
176
+ .filter(Boolean),
177
+ };
178
+ }
179
+
180
+ /**
181
+ * Merge `units` into `existingSrc`. Each unit replaces the matching `export const` in place (DB wins),
182
+ * preserving everything else in the file. New units are appended; needed imports are unioned in.
183
+ * Local-only fields/objects are reported, and kept or dropped per `opts`.
184
+ */
185
+ export function mergeUnits(
186
+ existingSrc: string,
187
+ units: RenderedUnit[],
188
+ opts: MergeOptions,
189
+ ): MergeResult {
190
+ const mod = parseModule(existingSrc);
191
+ const body = asMod(mod).$ast.body;
192
+
193
+ // Index existing exported consts by name.
194
+ const existing = new Map<string, AstNode>();
195
+ for (const node of body) {
196
+ const name = exportConstName(node);
197
+ if (name) existing.set(name, node);
198
+ }
199
+
200
+ const localOnly: LocalOnly = { fields: [], objects: [] };
201
+ const desiredNames = new Set(units.map((u) => u.exportName));
202
+
203
+ for (const unit of units) {
204
+ // Parse the freshly-rendered unit to lift its statement node.
205
+ const unitBody = asMod(parseModule(unit.code)).$ast.body;
206
+ const desiredNode = unitBody.find(
207
+ (n) => exportConstName(n) === unit.exportName,
208
+ );
209
+ if (!desiredNode) continue; // shouldn't happen — the renderer always emits the const
210
+
211
+ const prior = existing.get(unit.exportName);
212
+ if (prior) {
213
+ // Table/relation: reconcile fields. Functions/access are atomic (whole-const replace).
214
+ if (unit.kind === "table") {
215
+ const priorObj = fieldsObject(exportConstInit(prior));
216
+ const desiredObj = fieldsObject(exportConstInit(desiredNode));
217
+ if (priorObj?.properties && desiredObj?.properties) {
218
+ const desiredKeys = new Set(desiredObj.properties.map(propKey));
219
+ const localFields = priorObj.properties.filter(
220
+ (p) => !desiredKeys.has(propKey(p)),
221
+ );
222
+ if (localFields.length) {
223
+ localOnly.fields.push({
224
+ exportName: unit.exportName,
225
+ fields: localFields.map((p) => propKey(p) ?? "?"),
226
+ });
227
+ // Graft the local-only field nodes (with their comments) onto the merged object.
228
+ if (opts.keepLocalFields)
229
+ for (const p of localFields) desiredObj.properties.push(p);
230
+ }
231
+ }
232
+ }
233
+ // Preserve a user's leading comment above the const (the renderer emits none for tables;
234
+ // for access the renderer's own NOTE already lives on desiredNode, so don't double it).
235
+ if (!hasLeadingComment(desiredNode))
236
+ desiredNode.comments = prior.comments;
237
+ body[body.indexOf(prior)] = desiredNode;
238
+ } else {
239
+ body.push(desiredNode);
240
+ }
241
+ addImports(mod, unit.imports);
242
+ }
243
+
244
+ // Existing consts the DB no longer has → local-only objects.
245
+ for (const [name, node] of existing) {
246
+ if (desiredNames.has(name)) continue;
247
+ localOnly.objects.push(name);
248
+ if (!opts.keepLocalObjects) body.splice(body.indexOf(node), 1);
249
+ }
250
+
251
+ return { content: ensureTrailingNewline(generateCode(mod).code), localOnly };
252
+ }
253
+
254
+ /** Union the named imports from `lines` into the module (magicast dedupes against existing). */
255
+ function addImports(mod: unknown, lines: string[]): void {
256
+ const imports = asMod(mod).imports;
257
+ for (const line of lines) {
258
+ const parsed = parseImportLine(line);
259
+ if (!parsed) continue;
260
+ for (const name of parsed.names)
261
+ imports.$add({ from: parsed.from, imported: name, local: name });
262
+ }
263
+ }
264
+
265
+ function ensureTrailingNewline(s: string): string {
266
+ return s.endsWith("\n") ? s : `${s}\n`;
267
+ }
268
+
269
+ // --- Line diff (colored preview + git-style patch) -------------------------------------------
270
+
271
+ type LineOp = { tag: " " | "-" | "+"; line: string };
272
+
273
+ const splitLines = (s: string): string[] =>
274
+ s === "" ? [] : s.replace(/\n$/, "").split("\n");
275
+
276
+ /** LCS line-level ops between two texts (a trailing newline is ignored). */
277
+ function lineOps(before: string, after: string): LineOp[] {
278
+ const a = splitLines(before);
279
+ const b = splitLines(after);
280
+ const m = a.length;
281
+ const n = b.length;
282
+ const dp: number[][] = Array.from({ length: m + 1 }, () =>
283
+ new Array(n + 1).fill(0),
284
+ );
285
+ for (let i = m - 1; i >= 0; i--)
286
+ for (let j = n - 1; j >= 0; j--)
287
+ dp[i][j] =
288
+ a[i] === b[j]
289
+ ? dp[i + 1][j + 1] + 1
290
+ : Math.max(dp[i + 1][j], dp[i][j + 1]);
291
+ const ops: LineOp[] = [];
292
+ let i = 0;
293
+ let j = 0;
294
+ while (i < m && j < n) {
295
+ if (a[i] === b[j]) {
296
+ ops.push({ tag: " ", line: a[i] });
297
+ i++;
298
+ j++;
299
+ } else if (dp[i + 1][j] >= dp[i][j + 1]) {
300
+ ops.push({ tag: "-", line: a[i++] });
301
+ } else {
302
+ ops.push({ tag: "+", line: b[j++] });
303
+ }
304
+ }
305
+ while (i < m) ops.push({ tag: "-", line: a[i++] });
306
+ while (j < n) ops.push({ tag: "+", line: b[j++] });
307
+ return ops;
308
+ }
309
+
310
+ /**
311
+ * A compact colored line diff for previews. A new file renders as all-green additions; an edit
312
+ * renders removed (red `-`) / added (green `+`) lines with a little surrounding context (long
313
+ * unchanged runs collapse to `…`).
314
+ */
315
+ export function lineDiff(before: string, after: string): string {
316
+ const ops = lineOps(before, after);
317
+ // Keep changed lines plus up to 2 lines of context around each; collapse long unchanged runs.
318
+ const CONTEXT = 2;
319
+ const keep = new Array(ops.length).fill(false);
320
+ ops.forEach((op, idx) => {
321
+ if (op.tag === " ") return;
322
+ for (
323
+ let k = Math.max(0, idx - CONTEXT);
324
+ k <= Math.min(ops.length - 1, idx + CONTEXT);
325
+ k++
326
+ )
327
+ keep[k] = true;
328
+ });
329
+ const out: string[] = [];
330
+ let gap = false;
331
+ ops.forEach((op, idx) => {
332
+ if (!keep[idx]) {
333
+ if (!gap) out.push(style.dim(" …"));
334
+ gap = true;
335
+ return;
336
+ }
337
+ gap = false;
338
+ if (op.tag === " ") out.push(style.dim(` ${op.line}`));
339
+ else if (op.tag === "-") out.push(style.red(`- ${op.line}`));
340
+ else out.push(style.green(`+ ${op.line}`));
341
+ });
342
+ return out.join("\n");
343
+ }
344
+
345
+ /**
346
+ * A git-style unified diff between two texts, headed by `label` as the file path — for piping to a
347
+ * diff viewer (delta / git's pager). Returns "" when there's no change.
348
+ */
349
+ export function unifiedDiff(
350
+ before: string,
351
+ after: string,
352
+ label: string,
353
+ ): string {
354
+ const ops = lineOps(before, after);
355
+ if (!ops.some((o) => o.tag !== " ")) return "";
356
+ const oldLen = ops.filter((o) => o.tag !== "+").length;
357
+ const newLen = ops.filter((o) => o.tag !== "-").length;
358
+ const body = ops.map((o) =>
359
+ o.tag === " " ? ` ${o.line}` : `${o.tag}${o.line}`,
360
+ );
361
+ return `${[
362
+ `diff --git a/${label} b/${label}`,
363
+ `--- a/${label}`,
364
+ `+++ b/${label}`,
365
+ `@@ -1,${oldLen} +1,${newLen} @@`,
366
+ ...body,
367
+ ].join("\n")}\n`;
368
+ }
369
+
370
+ /** Colored verb for a pull action (`new` / `update` / `delete` / `unchanged`). */
371
+ export function actionLabel(
372
+ action: "create" | "update" | "unchanged" | "delete",
373
+ ): string {
374
+ if (action === "create") return colorEnabled() ? style.green("new") : "new";
375
+ if (action === "update")
376
+ return colorEnabled() ? style.yellow("update") : "update";
377
+ if (action === "delete")
378
+ return colorEnabled() ? style.red("delete") : "delete";
379
+ return style.dim("unchanged");
380
+ }
@@ -0,0 +1,123 @@
1
+ import { createHash } from "node:crypto";
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ readdirSync,
6
+ readFileSync,
7
+ writeFileSync,
8
+ } from "node:fs";
9
+ import { join } from "node:path";
10
+ import type { KindSnapshot } from "../kind";
11
+
12
+ // The legacy STATEMENT snapshot types (Snapshot/SnapshotStatement/EMPTY_SNAPSHOT) now live in
13
+ // cli/structure.ts (the SurrealDB module that produces them) — this file is the NEUTRAL stored-
14
+ // snapshot + migration-file engine.
15
+
16
+ /**
17
+ * The STORED snapshot (`_snapshot.json`): the canonical schema is portable objects grouped by kind
18
+ * (a {@link KindSnapshot}); DDL is derived generically via the kind registry (`buildKindDiff`/
19
+ * `emitKinds`). Diffed against the next `generate`. `files` maps each object's name to its
20
+ * project-root-relative source file (display-only; attached to diff items by the CLI). Pre-launch:
21
+ * the format is free to change, so there is no on-disk version migration — an unrecognized snapshot
22
+ * is treated as empty (regenerate via `schemic gen --baseline`).
23
+ */
24
+ export interface StoredSnapshot {
25
+ version: 3;
26
+ /** The driver that authored this snapshot ("surrealdb", "postgres", …). */
27
+ driver: string;
28
+ /** Portable objects grouped by kind. */
29
+ schema: KindSnapshot;
30
+ files?: Record<string, string>;
31
+ }
32
+
33
+ /** A migration file on disk. The filename is the source of truth — there's no journal. */
34
+ export interface Migration {
35
+ /** Filename without the `.surql` extension, e.g. `20260607153045_add_users`. */
36
+ tag: string;
37
+ /** Filename, e.g. `20260607153045_add_users.surql`. */
38
+ file: string;
39
+ }
40
+
41
+ const SNAPSHOT_FILE = "_snapshot.json";
42
+ /** Fallback migration extension when a caller has no driver to hand (the driver provides the real one). */
43
+ const DEFAULT_MIGRATION_EXT = ".surql";
44
+
45
+ /** A fresh empty STORED snapshot. Fresh each call so callers can't alias shared empty state. */
46
+ function emptyStored(): StoredSnapshot {
47
+ return {
48
+ version: 3,
49
+ driver: "surrealdb",
50
+ schema: { kinds: {} },
51
+ files: {},
52
+ };
53
+ }
54
+
55
+ /** The empty STORED snapshot — used as `prev` for a `--baseline` generate (diff against nothing). */
56
+ export const EMPTY_STORED: StoredSnapshot = emptyStored();
57
+
58
+ /**
59
+ * Read the stored snapshot. Pre-launch: any snapshot that isn't the current `version: 3` shape (a
60
+ * pre-portable v1/v2, or absent) is treated as EMPTY — regenerate with `schemic gen --baseline`.
61
+ */
62
+ export function readSnapshot(metaDir: string): StoredSnapshot {
63
+ const path = join(metaDir, SNAPSHOT_FILE);
64
+ if (!existsSync(path)) return emptyStored();
65
+ const raw = JSON.parse(readFileSync(path, "utf8")) as Partial<StoredSnapshot>;
66
+ if (raw.version === 3 && raw.driver && raw.schema)
67
+ return { files: {}, ...(raw as StoredSnapshot) };
68
+ return emptyStored();
69
+ }
70
+
71
+ export function writeSnapshot(metaDir: string, snapshot: StoredSnapshot): void {
72
+ mkdirSync(metaDir, { recursive: true });
73
+ writeFileSync(
74
+ join(metaDir, SNAPSHOT_FILE),
75
+ `${JSON.stringify(snapshot, null, 2)}\n`,
76
+ );
77
+ }
78
+
79
+ /**
80
+ * All migration files in `migrationsDir`, in apply order. Filenames are timestamp-prefixed, so
81
+ * a plain ascending sort is chronological (and legacy `0001_` names sort before timestamped
82
+ * ones). The `meta/` directory and any file not ending in `ext` (the driver's migration extension)
83
+ * are ignored.
84
+ */
85
+ export function listMigrations(
86
+ migrationsDir: string,
87
+ ext: string = DEFAULT_MIGRATION_EXT,
88
+ ): Migration[] {
89
+ if (!existsSync(migrationsDir)) return [];
90
+ return readdirSync(migrationsDir)
91
+ .filter((f) => f.endsWith(ext))
92
+ .sort()
93
+ .map((file) => ({ tag: file.slice(0, -ext.length), file }));
94
+ }
95
+
96
+ /** A sortable UTC timestamp prefix for a new migration, e.g. `20260607153045`. */
97
+ export function timestamp(date: Date): string {
98
+ const p = (n: number) => String(n).padStart(2, "0");
99
+ return (
100
+ `${date.getUTCFullYear()}` +
101
+ p(date.getUTCMonth() + 1) +
102
+ p(date.getUTCDate()) +
103
+ p(date.getUTCHours()) +
104
+ p(date.getUTCMinutes()) +
105
+ p(date.getUTCSeconds())
106
+ );
107
+ }
108
+
109
+ /** sha256 of a migration file's contents (drift detection / apply-time bookkeeping). */
110
+ export function checksum(content: string): string {
111
+ return createHash("sha256").update(content).digest("hex").slice(0, 16);
112
+ }
113
+
114
+ /** Turn a free-form migration name into a filename-safe slug. */
115
+ export function slug(name: string): string {
116
+ return (
117
+ name
118
+ .trim()
119
+ .toLowerCase()
120
+ .replace(/[^a-z0-9]+/g, "_")
121
+ .replace(/^_+|_+$/g, "") || "migration"
122
+ );
123
+ }
@@ -0,0 +1,42 @@
1
+ import { execFileSync, spawn } from "node:child_process";
2
+
3
+ /** Read a single git config value (global/system included), or undefined. */
4
+ function gitConfig(key: string): string | undefined {
5
+ try {
6
+ const v = execFileSync("git", ["config", "--get", key], {
7
+ encoding: "utf8",
8
+ stdio: ["ignore", "pipe", "ignore"],
9
+ }).trim();
10
+ return v || undefined;
11
+ } catch {
12
+ return undefined;
13
+ }
14
+ }
15
+
16
+ /**
17
+ * The diff pager, resolved the way git does: `pager.diff` → `core.pager` → `$GIT_PAGER` →
18
+ * `$PAGER`. So a user with `core.pager = delta` gets delta for free, with their own config.
19
+ */
20
+ export function resolvePager(): string | undefined {
21
+ return (
22
+ gitConfig("pager.diff") ||
23
+ gitConfig("core.pager") ||
24
+ process.env.GIT_PAGER ||
25
+ process.env.PAGER ||
26
+ undefined
27
+ );
28
+ }
29
+
30
+ /** Pipe `text` through `pager` (a shell command, possibly with args); resolve when it exits. */
31
+ export function pipeThroughPager(pager: string, text: string): Promise<void> {
32
+ return new Promise<void>((resolve, reject) => {
33
+ const child = spawn("sh", ["-c", pager], {
34
+ stdio: ["pipe", "inherit", "inherit"],
35
+ });
36
+ child.once("error", reject);
37
+ child.once("close", () => resolve());
38
+ // The pager may quit before reading all input (e.g. `less` on a short diff) — ignore EPIPE.
39
+ child.stdin.on("error", () => {});
40
+ child.stdin.end(text);
41
+ });
42
+ }