@volter/twin-github 0.1.0 → 0.1.1

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.
@@ -0,0 +1,511 @@
1
+ // GitHub twin git plane — REAL bare git repositories under the twin instance's state dir,
2
+ // driven by the system `git` binary. This is the supabase doctrine applied to GitHub: twin
3
+ // the CONTROL plane (the REST/GraphQL surface), run the REAL engine for the data plane (the
4
+ // git object store). git is its own reference implementation — the object formats, pkt-line
5
+ // framing and smart-HTTP semantics this plane serves are specified by git's own docs
6
+ // (gitformat-pack(5), gitprotocol-common(5), gitprotocol-http(5); recorded in
7
+ // spec-sources.json) and produced/consumed here by real `git` subprocesses, never re-implemented.
8
+ //
9
+ // Layout: one bare repo per twin repository at `<root>/.volter/world/github/git/<owner>/<name>.git`
10
+ // (worldPaths('github').dir — the SAME state dir the kernel event log lives under, so a twin
11
+ // instance's git plane travels with its control-plane state).
12
+ //
13
+ // Reconciliation contract (the heart of the smart-HTTP upgrade — see github-git-http.ts):
14
+ // • Git Data REST writes (blobs/trees/commits/tags) write REAL git objects here and return
15
+ // the REAL git sha — so a REST-authored commit chain is byte-for-byte fetchable by any
16
+ // git client.
17
+ // • REST ref writes materialize into the bare repo whenever the target object exists
18
+ // (updateGitRefInPlane); a ref minted against a sha the plane has never seen stays
19
+ // control-plane-only (the twin's long-standing lenient simulation surface — real GitHub
20
+ // would 422 "Object does not exist"; the leniency predates this plane and is pinned by
21
+ // existing capabilities, so it is kept and documented rather than silently changed).
22
+ // • Pushed refs (git-receive-pack) are folded back into the kernel event log by
23
+ // reconcileGitPlaneRefs so the REST plane (refs, branches, PR head resolution) sees them.
24
+ //
25
+ // Determinism/offline: every function here shells out to the LOCAL `git` binary over local
26
+ // files — no network, deterministic given the same inputs (commit/tag shas are pinned by the
27
+ // caller-supplied occurredAt). Spawning local git is NOT a live-probe and is outside rate
28
+ // budgets. If `git` itself cannot be spawned we fail LOUDLY with a clear message — never a
29
+ // silent fallback (the gate's absent-capability rule).
30
+ import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
31
+ import { tmpdir } from 'node:os';
32
+ import { join } from 'node:path';
33
+ import { worldPaths } from '@volter/twin';
34
+
35
+ // Path-safe owner/name segments (GitHub's own rules are close enough; the point is that a
36
+ // repo name can never traverse out of the plane dir).
37
+ const SEGMENT_RE = /^[A-Za-z0-9_.-]+$/;
38
+
39
+ /** The git plane root for a twin instance: `<state>/github/git`. */
40
+ export function githubGitPlaneDir(root?: string): string {
41
+ return join(worldPaths('github', root).dir, 'git');
42
+ }
43
+
44
+ /** The bare repo dir for `owner/name` (validated — a hostile name cannot escape the plane dir). */
45
+ export function bareRepoDir(repo: string, root?: string): string {
46
+ const [owner, name, extra] = repo.split('/');
47
+ if (!owner || !name || extra !== undefined || !SEGMENT_RE.test(owner) || !SEGMENT_RE.test(name) || owner === '..' || name === '..' || owner === '.' || name === '.') {
48
+ throw new Error(`github twin git plane: invalid repository name ${JSON.stringify(repo)}`);
49
+ }
50
+ return join(githubGitPlaneDir(root), owner, `${name}.git`);
51
+ }
52
+
53
+ export function hasBareRepo(repo: string, root?: string): boolean {
54
+ return existsSync(join(bareRepoDir(repo, root), 'HEAD'));
55
+ }
56
+
57
+ // Config hygiene: a spawned git must never read the operator's user/system config (hooks
58
+ // paths, transfer knobs, protocol overrides would make the twin's behavior machine-dependent).
59
+ function gitEnv(): Record<string, string> {
60
+ const env: Record<string, string> = {};
61
+ for (const [k, v] of Object.entries(process.env)) if (v !== undefined) env[k] = v;
62
+ env.GIT_CONFIG_GLOBAL = '/dev/null';
63
+ env.GIT_CONFIG_SYSTEM = '/dev/null';
64
+ env.GIT_CONFIG_NOSYSTEM = '1';
65
+ env.GIT_TERMINAL_PROMPT = '0';
66
+ env.GIT_ATTR_NOSYSTEM = '1';
67
+ return env;
68
+ }
69
+
70
+ export type RunGitResult = { code: number; stdout: Buffer; stderr: string };
71
+
72
+ /**
73
+ * Run `git <args>` in `dir` (synchronous — the twin's read path is synchronous). Non-zero
74
+ * exit is returned to the caller (many probes are expected to fail, e.g. cat-file -e); a
75
+ * FAILED SPAWN (git missing/broken) throws loudly — the git plane cannot exist without git.
76
+ */
77
+ export function runGit(dir: string, args: string[], opts: { stdin?: Uint8Array; env?: Record<string, string> } = {}): RunGitResult {
78
+ let proc: ReturnType<typeof Bun.spawnSync>;
79
+ // A large stdin (a push's packfile) goes through a file, not a pipe: with a pipe, a child that fills its
80
+ // own stdout before draining stdin deadlocks the synchronous spawn — a push of a real repository hung the
81
+ // twin for good. A file has no such back-pressure.
82
+ let stdinFile: string | undefined;
83
+ let stdin: Uint8Array | ReturnType<typeof Bun.file> | undefined = opts.stdin;
84
+ if (opts.stdin !== undefined && opts.stdin.length > 65_536) {
85
+ stdinFile = join(tmpdir(), `twin-git-stdin-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
86
+ writeFileSync(stdinFile, opts.stdin);
87
+ stdin = Bun.file(stdinFile);
88
+ }
89
+ try {
90
+ proc = Bun.spawnSync({
91
+ cmd: ['git', ...args],
92
+ cwd: dir,
93
+ stdin,
94
+ stdout: 'pipe',
95
+ stderr: 'pipe',
96
+ env: { ...gitEnv(), ...(opts.env ?? {}) },
97
+ });
98
+ } catch (e) {
99
+ throw new Error(`github twin git plane: failed to spawn \`git ${args[0] ?? ''}\` — is the git binary on PATH? (${e instanceof Error ? e.message : String(e)})`);
100
+ } finally {
101
+ if (stdinFile) rmSync(stdinFile, { force: true });
102
+ }
103
+ if (proc.exitCode === null) {
104
+ throw new Error(`github twin git plane: \`git ${args.join(' ')}\` did not run (killed before exit) — cannot serve the git plane without a working git binary`);
105
+ }
106
+ return { code: proc.exitCode, stdout: Buffer.from(proc.stdout ?? new Uint8Array()), stderr: new TextDecoder().decode(proc.stderr ?? new Uint8Array()) };
107
+ }
108
+
109
+ /** Run git and THROW (loudly, with stderr) on a non-zero exit — for calls that must succeed. */
110
+ function mustGit(dir: string, args: string[], opts: { stdin?: Uint8Array } = {}): RunGitResult {
111
+ const res = runGit(dir, args, opts);
112
+ if (res.code !== 0) {
113
+ throw new Error(`github twin git plane: \`git ${args.join(' ')}\` failed (exit ${res.code}): ${res.stderr.trim() || res.stdout.toString('utf8').trim()}`);
114
+ }
115
+ return res;
116
+ }
117
+
118
+ /** Idempotently create the bare repo for `repo` (HEAD → refs/heads/<defaultBranch>). */
119
+ export function ensureBareRepo(repo: string, root?: string, defaultBranch = 'main'): string {
120
+ const dir = bareRepoDir(repo, root);
121
+ if (existsSync(join(dir, 'HEAD'))) return dir;
122
+ mkdirSync(dir, { recursive: true });
123
+ mustGit(dir, ['init', '--bare', '--quiet']);
124
+ // Not `init -b`: symbolic-ref works on every git ≥ 2.x and re-pointing is idempotent.
125
+ mustGit(dir, ['symbolic-ref', 'HEAD', `refs/heads/${defaultBranch}`]);
126
+ return dir;
127
+ }
128
+
129
+ export function objectExistsInPlane(repo: string, sha: string, root?: string): boolean {
130
+ if (!hasBareRepo(repo, root)) return false;
131
+ if (!/^[0-9a-f]{40}$/i.test(sha)) return false;
132
+ return runGit(bareRepoDir(repo, root), ['cat-file', '-e', sha]).code === 0;
133
+ }
134
+
135
+ export function objectTypeInPlane(repo: string, sha: string, root?: string): string | null {
136
+ if (!hasBareRepo(repo, root) || !/^[0-9a-f]{40}$/i.test(sha)) return null;
137
+ const res = runGit(bareRepoDir(repo, root), ['cat-file', '-t', sha]);
138
+ return res.code === 0 ? res.stdout.toString('utf8').trim() : null;
139
+ }
140
+
141
+ // ── Objects: blobs ─────────────────────────────────────────────────────────────────────────
142
+ /** Write a REAL blob object; returns the real git sha. */
143
+ export function writeGitBlobToPlane(repo: string, contentB64: string, root?: string): string {
144
+ const dir = ensureBareRepo(repo, root);
145
+ const res = mustGit(dir, ['hash-object', '-w', '--stdin'], { stdin: Buffer.from(contentB64, 'base64') });
146
+ return res.stdout.toString('utf8').trim();
147
+ }
148
+
149
+ export function readGitBlobFromPlane(repo: string, sha: string, root?: string): { content_b64: string; size: number } | null {
150
+ if (objectTypeInPlane(repo, sha, root) !== 'blob') return null;
151
+ const res = mustGit(bareRepoDir(repo, root), ['cat-file', 'blob', sha]);
152
+ return { content_b64: res.stdout.toString('base64'), size: res.stdout.length };
153
+ }
154
+
155
+ // ── Objects: trees ─────────────────────────────────────────────────────────────────────────
156
+ export type PlaneTreeEntry = { path: string; mode: string; type: string; sha: string; size?: number };
157
+
158
+ function lsTreeRecursive(dir: string, sha: string): Map<string, { mode: string; type: string; sha: string }> {
159
+ // `-r -t` lists blobs AND the intermediate trees; we keep only the LEAF entries (blob /
160
+ // commit(submodule)) — subtrees are rebuilt from the leaf paths on write.
161
+ const res = mustGit(dir, ['ls-tree', '-r', '-z', sha]);
162
+ const out = new Map<string, { mode: string; type: string; sha: string }>();
163
+ for (const rec of res.stdout.toString('utf8').split('\0')) {
164
+ if (!rec) continue;
165
+ const m = /^(\d+) (\w+) ([0-9a-f]{40})\t(.+)$/s.exec(rec);
166
+ if (m) out.set(m[4]!, { mode: m[1]!, type: m[2]!, sha: m[3]! });
167
+ }
168
+ return out;
169
+ }
170
+
171
+ /** mktree (with `--missing`, matching the twin's lenient legacy surface) one LEVEL of entries. */
172
+ function mktreeLevel(dir: string, entries: Array<{ name: string; mode: string; type: string; sha: string }>): string {
173
+ const input = entries.map((e) => `${e.mode} ${e.type} ${e.sha}\t${e.name}`).join('\n');
174
+ const res = mustGit(dir, ['mktree', '--missing'], { stdin: Buffer.from(input.length ? `${input}\n` : '', 'utf8') });
175
+ return res.stdout.toString('utf8').trim();
176
+ }
177
+
178
+ /** Build a REAL (possibly nested) tree from flat API entries; returns the real root-tree sha. */
179
+ function buildTreeFromFlat(dir: string, flat: Map<string, { mode: string; type: string; sha: string }>): string {
180
+ type Node = { entries: Map<string, { mode: string; type: string; sha: string }>; children: Map<string, Node> };
181
+ const rootNode: Node = { entries: new Map(), children: new Map() };
182
+ for (const [path, e] of flat) {
183
+ const parts = path.split('/');
184
+ let node = rootNode;
185
+ for (let i = 0; i < parts.length - 1; i++) {
186
+ const seg = parts[i]!;
187
+ if (!node.children.has(seg)) node.children.set(seg, { entries: new Map(), children: new Map() });
188
+ node = node.children.get(seg)!;
189
+ }
190
+ node.entries.set(parts[parts.length - 1]!, e);
191
+ }
192
+ const write = (node: Node): string => {
193
+ const level: Array<{ name: string; mode: string; type: string; sha: string }> = [];
194
+ for (const [name, child] of node.children) level.push({ name, mode: '040000', type: 'tree', sha: write(child) });
195
+ for (const [name, e] of node.entries) level.push({ name, mode: e.mode, type: e.type, sha: e.sha });
196
+ return mktreeLevel(dir, level);
197
+ };
198
+ return write(rootNode);
199
+ }
200
+
201
+ /**
202
+ * Write a REAL tree object from GitHub-API-shaped entries (nested `a/b/c` paths supported,
203
+ * as real GitHub supports). With `baseTreeSha` (and the base present in the plane), the
204
+ * entries OVERLAY the base's recursive listing — an entry with an empty/missing sha deletes
205
+ * its path (GitHub's tree-deletion convention, `sha: null`).
206
+ */
207
+ export function writeGitTreeToPlane(repo: string, entries: PlaneTreeEntry[], baseTreeSha: string | undefined, root?: string): string {
208
+ const dir = ensureBareRepo(repo, root);
209
+ const flat = baseTreeSha && objectTypeInPlane(repo, baseTreeSha, root) === 'tree'
210
+ ? lsTreeRecursive(dir, baseTreeSha)
211
+ : new Map<string, { mode: string; type: string; sha: string }>();
212
+ for (const e of entries) {
213
+ if (!e.sha) { flat.delete(e.path); continue; }
214
+ flat.set(e.path, { mode: e.mode, type: e.type, sha: e.sha });
215
+ }
216
+ return buildTreeFromFlat(dir, flat);
217
+ }
218
+
219
+ export function readGitTreeFromPlane(repo: string, sha: string, root?: string): PlaneTreeEntry[] | null {
220
+ if (objectTypeInPlane(repo, sha, root) !== 'tree') return null;
221
+ const res = mustGit(bareRepoDir(repo, root), ['ls-tree', '-l', '-z', sha]);
222
+ const out: PlaneTreeEntry[] = [];
223
+ for (const rec of res.stdout.toString('utf8').split('\0')) {
224
+ if (!rec) continue;
225
+ // size column: bytes for a present blob, `-` for trees, `BAD` for a missing object
226
+ // (the lenient legacy surface permits tree entries naming unwritten blobs).
227
+ const m = /^(\d+) (\w+) ([0-9a-f]{40}) +(\S+)\t(.+)$/s.exec(rec);
228
+ if (!m) continue;
229
+ const entry: PlaneTreeEntry = { path: m[5]!, mode: m[1]!, type: m[2]!, sha: m[3]! };
230
+ if (/^\d+$/.test(m[4]!)) entry.size = Number(m[4]);
231
+ out.push(entry);
232
+ }
233
+ return out;
234
+ }
235
+
236
+ // ── Objects: commits + annotated tags ──────────────────────────────────────────────────────
237
+ export type PlaneIdent = { name: string; email: string };
238
+ const TWIN_IDENT: PlaneIdent = { name: 'github-twin', email: 'github-twin@localhost' };
239
+
240
+ function identLine(kind: 'author' | 'committer' | 'tagger', ident: PlaneIdent | undefined, occurredAt: string): string {
241
+ const who = ident ?? TWIN_IDENT;
242
+ const when = Math.floor((Date.parse(occurredAt) || 0) / 1000);
243
+ return `${kind} ${who.name} <${who.email}> ${when} +0000`;
244
+ }
245
+
246
+ /**
247
+ * Write a REAL commit object (hand-framed per gitformat-commit, hashed+stored by git itself
248
+ * via `hash-object -t commit -w`); returns the real sha. hash-object validates the object's
249
+ * FORMAT but not connectivity, so the twin's legacy lenient surface (a commit naming a tree
250
+ * sha nobody has written) still produces a real, addressable object — exactly the dangling
251
+ * objects git itself permits.
252
+ */
253
+ export function writeGitCommitToPlane(
254
+ repo: string,
255
+ commit: { message: string; treeSha: string; parents: string[]; author?: PlaneIdent; committer?: PlaneIdent; occurredAt: string },
256
+ root?: string,
257
+ ): string {
258
+ const dir = ensureBareRepo(repo, root);
259
+ const lines = [
260
+ `tree ${commit.treeSha}`,
261
+ ...commit.parents.map((p) => `parent ${p}`),
262
+ identLine('author', commit.author, commit.occurredAt),
263
+ identLine('committer', commit.committer ?? commit.author, commit.occurredAt),
264
+ '',
265
+ commit.message,
266
+ ];
267
+ const res = mustGit(dir, ['hash-object', '-t', 'commit', '-w', '--stdin'], { stdin: Buffer.from(`${lines.join('\n')}\n`, 'utf8') });
268
+ return res.stdout.toString('utf8').trim();
269
+ }
270
+
271
+ export type PlaneCommit = { sha: string; message: string; tree_sha: string; parents: string[]; author_name?: string; author_email?: string; created_at?: string };
272
+
273
+ export function readGitCommitFromPlane(repo: string, sha: string, root?: string): PlaneCommit | null {
274
+ if (objectTypeInPlane(repo, sha, root) !== 'commit') return null;
275
+ const raw = mustGit(bareRepoDir(repo, root), ['cat-file', 'commit', sha]).stdout.toString('utf8');
276
+ const sep = raw.indexOf('\n\n');
277
+ const header = sep >= 0 ? raw.slice(0, sep) : raw;
278
+ const message = sep >= 0 ? raw.slice(sep + 2).replace(/\n$/, '') : '';
279
+ const tree = /^tree ([0-9a-f]{40})$/m.exec(header)?.[1] ?? '';
280
+ const parents = [...header.matchAll(/^parent ([0-9a-f]{40})$/gm)].map((m) => m[1]!);
281
+ const author = /^author (.*) <(.*)> (\d+) ([+-]\d{4})$/m.exec(header);
282
+ const out: PlaneCommit = { sha, message, tree_sha: tree, parents };
283
+ if (author) {
284
+ out.author_name = author[1]!;
285
+ out.author_email = author[2]!;
286
+ out.created_at = new Date(Number(author[3]) * 1000).toISOString();
287
+ }
288
+ return out;
289
+ }
290
+
291
+ /** Write a REAL annotated-tag object; returns the real sha. */
292
+ export function writeGitTagToPlane(
293
+ repo: string,
294
+ tag: { tag: string; message: string; objectSha: string; objectType: string; tagger?: PlaneIdent; occurredAt: string },
295
+ root?: string,
296
+ ): string {
297
+ const dir = ensureBareRepo(repo, root);
298
+ const lines = [
299
+ `object ${tag.objectSha}`,
300
+ `type ${tag.objectType}`,
301
+ `tag ${tag.tag}`,
302
+ identLine('tagger', tag.tagger, tag.occurredAt),
303
+ '',
304
+ tag.message,
305
+ ];
306
+ const res = mustGit(dir, ['hash-object', '-t', 'tag', '-w', '--stdin'], { stdin: Buffer.from(`${lines.join('\n')}\n`, 'utf8') });
307
+ return res.stdout.toString('utf8').trim();
308
+ }
309
+
310
+ export type PlaneTag = { sha: string; tag: string; message: string; object_sha: string; object_type: string; tagger_name?: string; created_at?: string };
311
+
312
+ export function readGitTagFromPlane(repo: string, sha: string, root?: string): PlaneTag | null {
313
+ if (objectTypeInPlane(repo, sha, root) !== 'tag') return null;
314
+ const raw = mustGit(bareRepoDir(repo, root), ['cat-file', 'tag', sha]).stdout.toString('utf8');
315
+ const sep = raw.indexOf('\n\n');
316
+ const header = sep >= 0 ? raw.slice(0, sep) : raw;
317
+ const message = sep >= 0 ? raw.slice(sep + 2).replace(/\n$/, '') : '';
318
+ const out: PlaneTag = {
319
+ sha,
320
+ tag: /^tag (.*)$/m.exec(header)?.[1] ?? '',
321
+ message,
322
+ object_sha: /^object ([0-9a-f]{40})$/m.exec(header)?.[1] ?? '',
323
+ object_type: /^type (\w+)$/m.exec(header)?.[1] ?? 'commit',
324
+ };
325
+ const tagger = /^tagger (.*) <.*> (\d+) ([+-]\d{4})$/m.exec(header);
326
+ if (tagger) {
327
+ out.tagger_name = tagger[1]!;
328
+ out.created_at = new Date(Number(tagger[2]) * 1000).toISOString();
329
+ }
330
+ return out;
331
+ }
332
+
333
+ // ── Refs ───────────────────────────────────────────────────────────────────────────────────
334
+ /**
335
+ * Materialize a control-plane ref write into the bare repo. Returns true when the ref now
336
+ * exists in the plane; false when the target object does not exist there OR is not fully
337
+ * CONNECTED (`rev-list --objects` walks every reachable object) — a ref to a dangling
338
+ * commit (the twin's lenient legacy surface permits commits naming trees nobody wrote)
339
+ * stays control-plane-only, so an advertised ref is always actually clonable.
340
+ */
341
+ export function updateGitRefInPlane(repo: string, ref: string, sha: string, root?: string): boolean {
342
+ if (!hasBareRepo(repo, root)) return false;
343
+ if (!objectExistsInPlane(repo, sha, root)) return false;
344
+ const dir = bareRepoDir(repo, root);
345
+ if (runGit(dir, ['rev-list', '--objects', sha]).code !== 0) return false; // dangling chain — not clonable
346
+ return runGit(dir, ['update-ref', ref, sha]).code === 0;
347
+ }
348
+
349
+ export function deleteGitRefInPlane(repo: string, ref: string, root?: string): void {
350
+ if (!hasBareRepo(repo, root)) return;
351
+ runGit(bareRepoDir(repo, root), ['update-ref', '-d', ref]); // non-existence is fine
352
+ }
353
+
354
+ export type PlaneRef = { ref: string; object_sha: string; object_type: string };
355
+
356
+ // ── Repo lifecycle + diffs (REST ↔ plane convergence) ──────────────────────────────────────
357
+ /** Re-point the bare repo's HEAD symref (a REST default_branch change IS this operation). */
358
+ export function setBareRepoHead(repo: string, branch: string, root?: string): void {
359
+ if (!hasBareRepo(repo, root)) return;
360
+ mustGit(bareRepoDir(repo, root), ['symbolic-ref', 'HEAD', `refs/heads/${branch}`]);
361
+ }
362
+
363
+ /** Remove a deleted repo's bare repo — a later re-create must NOT inherit its history. */
364
+ export function deleteBareRepo(repo: string, root?: string): void {
365
+ if (!hasBareRepo(repo, root)) return;
366
+ rmSync(bareRepoDir(repo, root), { recursive: true, force: true });
367
+ }
368
+
369
+ /**
370
+ * Fork = copy the parent's object store + refs (what real GitHub does server-side). A file
371
+ * copy of a bare repo is a valid bare repo; `cpSync` keeps this synchronous like the rest of
372
+ * the plane. No parent bare repo → an empty bare repo (the twin's lenient surface).
373
+ */
374
+ export function forkBareRepo(parentRepo: string, forkRepo: string, root?: string, defaultBranch = 'main'): void {
375
+ const dst = bareRepoDir(forkRepo, root); // validates the fork name before any copy
376
+ if (hasBareRepo(forkRepo, root)) return;
377
+ if (!hasBareRepo(parentRepo, root)) { ensureBareRepo(forkRepo, root, defaultBranch); return; }
378
+ mkdirSync(dst, { recursive: true });
379
+ cpSync(bareRepoDir(parentRepo, root), dst, { recursive: true });
380
+ }
381
+
382
+ /** True when `ancestor` is an ancestor of `descendant` (the fast-forward test). */
383
+ export function isAncestorInPlane(repo: string, ancestor: string, descendant: string, root?: string): boolean {
384
+ if (!hasBareRepo(repo, root)) return false;
385
+ return runGit(bareRepoDir(repo, root), ['merge-base', '--is-ancestor', ancestor, descendant]).code === 0;
386
+ }
387
+
388
+ export type PlaneDiffFile = { filename: string; status: string; additions: number; deletions: number; sha?: string };
389
+
390
+ /** The merge base of two commits, or null when none exists / either is absent. */
391
+ export function mergeBaseInPlane(repo: string, a: string, b: string, root?: string): string | null {
392
+ if (!objectExistsInPlane(repo, a, root) || !objectExistsInPlane(repo, b, root)) return null;
393
+ const res = runGit(bareRepoDir(repo, root), ['merge-base', a, b]);
394
+ return res.code === 0 ? res.stdout.toString('utf8').trim() : null;
395
+ }
396
+
397
+ /**
398
+ * The changed-file list between two commits, from the REAL object store (`diff-tree -r`) —
399
+ * what backs GET /pulls/:n/files and compare when both sides exist in the plane. THREE-DOT
400
+ * semantics like real GitHub: the diff runs from merge-base(base,head) to head, so a base
401
+ * branch that advanced past the PR's branch point never leaks ITS OWN changes into the PR's
402
+ * file list (merge-gate scope classification depends on exactly this). Unrelated histories
403
+ * (no merge base) fall back to the plain two-commit diff. Binary files report 0/0 (git
404
+ * prints `-`) rather than fabricated counts; renames are declared scope (no -M — plumbing
405
+ * reports adds+deletes, which is honest, never a mis-paired record). `sha` is the REAL
406
+ * post-image blob sha from --raw (absent for deletions).
407
+ */
408
+ export function diffFilesInPlane(repo: string, baseSha: string, headSha: string, root?: string): PlaneDiffFile[] | null {
409
+ if (!objectExistsInPlane(repo, baseSha, root) || !objectExistsInPlane(repo, headSha, root)) return null;
410
+ const from = mergeBaseInPlane(repo, baseSha, headSha, root) ?? baseSha;
411
+ const dir = bareRepoDir(repo, root);
412
+ const num = runGit(dir, ['diff-tree', '-r', '--no-commit-id', '--numstat', '-z', from, headSha]);
413
+ const nam = runGit(dir, ['diff-tree', '-r', '--no-commit-id', '--name-status', '-z', from, headSha]);
414
+ const raw = runGit(dir, ['diff-tree', '-r', '--no-commit-id', '--raw', '-z', from, headSha]);
415
+ if (num.code !== 0 || nam.code !== 0 || raw.code !== 0) return null;
416
+ const statusByPath = new Map<string, string>();
417
+ const namParts = nam.stdout.toString('utf8').split('\0');
418
+ for (let i = 0; i + 1 < namParts.length; i += 2) {
419
+ const s = namParts[i]!; const p = namParts[i + 1]!;
420
+ if (!s || !p) continue;
421
+ const code = s[0];
422
+ statusByPath.set(p, code === 'A' ? 'added' : code === 'D' ? 'removed' : 'modified');
423
+ }
424
+ // --raw -z records: ":oldmode newmode oldsha newsha status\0path\0" — the post-image blob
425
+ // sha is the REAL object id (all-zeros for a deletion).
426
+ const shaByPath = new Map<string, string>();
427
+ const rawParts = raw.stdout.toString('utf8').split('\0');
428
+ for (let i = 0; i + 1 < rawParts.length; i += 2) {
429
+ const meta = rawParts[i]!; const p = rawParts[i + 1]!;
430
+ const m = /^:\d+ \d+ [0-9a-f]{40} ([0-9a-f]{40}) \w+$/.exec(meta);
431
+ if (m && p && m[1] !== '0'.repeat(40)) shaByPath.set(p, m[1]!);
432
+ }
433
+ const out: PlaneDiffFile[] = [];
434
+ for (const rec of num.stdout.toString('utf8').split('\0')) {
435
+ if (!rec) continue;
436
+ const m = /^([0-9-]+)\t([0-9-]+)\t(.*)$/s.exec(rec);
437
+ if (!m || !m[3]) continue;
438
+ const filename = m[3];
439
+ const sha = shaByPath.get(filename);
440
+ out.push({
441
+ filename,
442
+ status: statusByPath.get(filename) ?? 'modified',
443
+ additions: m[1] === '-' ? 0 : Number(m[1]),
444
+ deletions: m[2] === '-' ? 0 : Number(m[2]),
445
+ ...(sha !== undefined ? { sha } : {}),
446
+ });
447
+ }
448
+ return out;
449
+ }
450
+
451
+ /**
452
+ * Conservative git branch-name validation (the load-bearing subset of
453
+ * git-check-ref-format(1)) — a name this rejects would make `symbolic-ref`/`update-ref`
454
+ * throw, so callers 422 instead of surfacing a 500.
455
+ */
456
+ export function isValidBranchName(name: string): boolean {
457
+ if (!name || name.length > 250) return false;
458
+ if (/[\s~^:?*[\\\x00-\x1f\x7f]/.test(name)) return false;
459
+ if (name.includes('..') || name.includes('@{') || name.includes('//')) return false;
460
+ if (name.startsWith('/') || name.endsWith('/') || name.startsWith('.') || name.endsWith('.') || name.endsWith('.lock')) return false;
461
+ return true;
462
+ }
463
+
464
+ /** All refs in the bare repo (`for-each-ref`) — the protocol-visible ref set. */
465
+ export function listGitPlaneRefs(repo: string, root?: string): PlaneRef[] {
466
+ if (!hasBareRepo(repo, root)) return [];
467
+ const res = mustGit(bareRepoDir(repo, root), ['for-each-ref', '--format=%(objectname) %(objecttype) %(refname)']);
468
+ const out: PlaneRef[] = [];
469
+ for (const line of res.stdout.toString('utf8').split('\n')) {
470
+ if (!line) continue;
471
+ const m = /^([0-9a-f]{40}) (\w+) (.+)$/.exec(line);
472
+ if (m) out.push({ object_sha: m[1]!, object_type: m[2]!, ref: m[3]! });
473
+ }
474
+ return out;
475
+ }
476
+
477
+ // ── Pull-request landing (REST merge ↔ plane convergence) ──────────────────────────────────
478
+ const GITHUB_IDENT: PlaneIdent = { name: 'GitHub', email: 'noreply@github.com' };
479
+
480
+ /**
481
+ * Land `headSha` on branch `baseBranch` the way GitHub's merge button does: a REAL merge commit
482
+ * (git merge-tree, no worktree) whose first parent is the base tip, or a single-parent commit of the
483
+ * merged tree for the squash/rebase methods (declared deviation: rebase is folded to one commit).
484
+ * Returns the base branch's new tip; `{ conflict }` when the trees do not merge cleanly; null when
485
+ * the repository has no git plane or the base branch does not exist there. The base tip moves
486
+ * atomically (update-ref with the expected old value), so a concurrent push loses to nobody.
487
+ */
488
+ export function mergeIntoBranchInPlane(
489
+ repo: string,
490
+ baseBranch: string,
491
+ headSha: string,
492
+ message: string,
493
+ method: 'merge' | 'squash' | 'rebase',
494
+ occurredAt: string,
495
+ root?: string,
496
+ ): { sha: string } | { conflict: true } | null {
497
+ if (!hasBareRepo(repo, root)) return null;
498
+ const dir = bareRepoDir(repo, root);
499
+ const base = runGit(dir, ['rev-parse', '--verify', '--quiet', `refs/heads/${baseBranch}^{commit}`]);
500
+ if (base.code !== 0) return null;
501
+ const baseSha = base.stdout.toString('utf8').trim();
502
+ if (runGit(dir, ['merge-base', '--is-ancestor', headSha, baseSha]).code === 0) return { sha: baseSha };
503
+ const merged = runGit(dir, ['merge-tree', '--write-tree', baseSha, headSha]);
504
+ if (merged.code === 1) return { conflict: true };
505
+ if (merged.code !== 0) throw new Error(`github twin git plane: merge-tree failed (exit ${merged.code}): ${merged.stderr.trim()}`);
506
+ const treeSha = merged.stdout.toString('utf8').split('\n')[0]!.trim();
507
+ const parents = method === 'merge' ? [baseSha, headSha] : [baseSha];
508
+ const sha = writeGitCommitToPlane(repo, { message, treeSha, parents, author: GITHUB_IDENT, committer: GITHUB_IDENT, occurredAt }, root);
509
+ if (runGit(dir, ['update-ref', `refs/heads/${baseBranch}`, sha, baseSha]).code !== 0) return null;
510
+ return { sha };
511
+ }