@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.
- package/README.md +162 -20
- package/client/github-mirror.tsx +10 -4
- package/package.json +2 -2
- package/src/cli.ts +3 -2
- package/src/github-a11y-snapshot.uitest.ts +117 -0
- package/src/github-budget.ts +198 -0
- package/src/github-capabilities.ts +502 -23
- package/src/github-connector.ts +832 -61
- package/src/github-events.ts +11 -1
- package/src/github-git-http.ts +248 -0
- package/src/github-git-plane.ts +511 -0
- package/src/github-graphql.ts +212 -1
- package/src/github-journey.uitest.ts +193 -0
- package/src/github-mirror-state.ts +369 -0
- package/src/github-mirror-ui.ts +53 -372
- package/src/github-server.ts +125 -21
- package/src/github-shared.ts +26 -0
- package/src/github-twin.ts +821 -53
- package/src/github-ui-conformance.ts +2 -2
- package/src/index.ts +71 -5
- package/test-fixtures/github-openapi-operations.json +467 -34
|
@@ -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
|
+
}
|