@volter/twin-github 0.1.2 → 2.0.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.
Files changed (215) hide show
  1. package/README.md +88 -36
  2. package/client/github-mirror.css +277 -319
  3. package/client/github-mirror.d.ts +418 -0
  4. package/client/github-mirror.js +485 -0
  5. package/client/github-mirror.tsx +153 -357
  6. package/client/pulls-rest.ts +159 -0
  7. package/client/pulls-workspace.tsx +841 -0
  8. package/dist/client/github-mirror.bundle.js +239 -0
  9. package/dist/client/github-mirror.css +916 -0
  10. package/dist/client/github-mirror.d.ts +418 -0
  11. package/dist/client/github-mirror.js +485 -0
  12. package/dist/client/github-mirror.tsx +1315 -0
  13. package/dist/client/pulls-rest.d.ts +42 -0
  14. package/dist/client/pulls-rest.js +140 -0
  15. package/dist/client/pulls-rest.ts +159 -0
  16. package/dist/client/pulls-workspace.bundle.js +22 -0
  17. package/dist/client/pulls-workspace.d.ts +114 -0
  18. package/dist/client/pulls-workspace.js +418 -0
  19. package/dist/client/pulls-workspace.tsx +841 -0
  20. package/dist/src/cli.d.ts +2 -0
  21. package/dist/src/cli.js +40 -0
  22. package/dist/src/generated/graphql-sdl.gen.json +1 -0
  23. package/dist/src/generated/graphql.gen.json +1 -0
  24. package/dist/src/generated/surface.gen.json +1 -0
  25. package/dist/src/generated/ui.gen.json +1 -0
  26. package/dist/src/github-budget.d.ts +69 -0
  27. package/dist/src/github-budget.js +172 -0
  28. package/dist/src/github-capabilities.d.ts +5 -0
  29. package/dist/src/github-capabilities.js +4468 -0
  30. package/dist/src/github-conformance.d.ts +43 -0
  31. package/dist/src/github-conformance.js +76 -0
  32. package/dist/src/github-connector.d.ts +307 -0
  33. package/dist/src/github-connector.js +1398 -0
  34. package/dist/src/github-events.d.ts +41 -0
  35. package/dist/src/github-events.js +232 -0
  36. package/dist/src/github-git-http.d.ts +49 -0
  37. package/dist/src/github-git-http.js +185 -0
  38. package/dist/src/github-git-plane.d.ts +114 -0
  39. package/dist/src/github-git-plane.js +407 -0
  40. package/dist/src/github-mirror-state.d.ts +2 -0
  41. package/dist/src/github-mirror-state.js +335 -0
  42. package/dist/src/github-mirror-ui.d.ts +20 -0
  43. package/dist/src/github-mirror-ui.js +101 -0
  44. package/dist/src/github-server.d.ts +14 -0
  45. package/dist/src/github-server.js +240 -0
  46. package/dist/src/github-shared.d.ts +12 -0
  47. package/dist/src/github-shared.js +21 -0
  48. package/dist/src/github-twin.d.ts +1411 -0
  49. package/dist/src/github-twin.js +4084 -0
  50. package/dist/src/github-ui-conformance.d.ts +4 -0
  51. package/dist/src/github-ui-conformance.js +105 -0
  52. package/dist/src/github-ui-structure.d.ts +18 -0
  53. package/dist/src/github-ui-structure.js +251 -0
  54. package/dist/src/graphql-wire.d.ts +22 -0
  55. package/dist/src/graphql-wire.js +89 -0
  56. package/dist/src/index.d.ts +17 -0
  57. package/dist/src/index.js +104 -0
  58. package/dist/src/manifest.d.ts +8 -0
  59. package/dist/src/manifest.js +597 -0
  60. package/dist/src/npm-registry.d.ts +7 -0
  61. package/dist/src/npm-registry.js +47 -0
  62. package/dist/src/screens/app-installation.d.ts +3 -0
  63. package/dist/src/screens/app-installation.js +166 -0
  64. package/dist/src/screens/app-manifest.d.ts +3 -0
  65. package/dist/src/screens/app-manifest.js +81 -0
  66. package/dist/src/screens/oauth.d.ts +15 -0
  67. package/dist/src/screens/oauth.js +257 -0
  68. package/dist/src/screens/session.d.ts +8 -0
  69. package/dist/src/screens/session.js +170 -0
  70. package/dist/src/semantics/actions.d.ts +2 -0
  71. package/dist/src/semantics/actions.js +413 -0
  72. package/dist/src/semantics/activity.d.ts +4 -0
  73. package/dist/src/semantics/activity.js +161 -0
  74. package/dist/src/semantics/apps.d.ts +2 -0
  75. package/dist/src/semantics/apps.js +144 -0
  76. package/dist/src/semantics/branches.d.ts +2 -0
  77. package/dist/src/semantics/branches.js +136 -0
  78. package/dist/src/semantics/checks.d.ts +2 -0
  79. package/dist/src/semantics/checks.js +176 -0
  80. package/dist/src/semantics/code-scanning-upload.d.ts +2 -0
  81. package/dist/src/semantics/code-scanning-upload.js +97 -0
  82. package/dist/src/semantics/codespaces.d.ts +2 -0
  83. package/dist/src/semantics/codespaces.js +58 -0
  84. package/dist/src/semantics/commits.d.ts +2 -0
  85. package/dist/src/semantics/commits.js +109 -0
  86. package/dist/src/semantics/contents.d.ts +2 -0
  87. package/dist/src/semantics/contents.js +131 -0
  88. package/dist/src/semantics/deployments.d.ts +2 -0
  89. package/dist/src/semantics/deployments.js +127 -0
  90. package/dist/src/semantics/gists.d.ts +2 -0
  91. package/dist/src/semantics/gists.js +86 -0
  92. package/dist/src/semantics/git.d.ts +2 -0
  93. package/dist/src/semantics/git.js +235 -0
  94. package/dist/src/semantics/graphql.d.ts +7 -0
  95. package/dist/src/semantics/graphql.js +512 -0
  96. package/dist/src/semantics/index.d.ts +5 -0
  97. package/dist/src/semantics/index.js +62 -0
  98. package/dist/src/semantics/issues.d.ts +2 -0
  99. package/dist/src/semantics/issues.js +456 -0
  100. package/dist/src/semantics/keys.d.ts +2 -0
  101. package/dist/src/semantics/keys.js +66 -0
  102. package/dist/src/semantics/labels.d.ts +2 -0
  103. package/dist/src/semantics/labels.js +100 -0
  104. package/dist/src/semantics/meta.d.ts +12 -0
  105. package/dist/src/semantics/meta.js +144 -0
  106. package/dist/src/semantics/notifications.d.ts +4 -0
  107. package/dist/src/semantics/notifications.js +62 -0
  108. package/dist/src/semantics/orgs.d.ts +2 -0
  109. package/dist/src/semantics/orgs.js +420 -0
  110. package/dist/src/semantics/packages.d.ts +2 -0
  111. package/dist/src/semantics/packages.js +55 -0
  112. package/dist/src/semantics/pages.d.ts +2 -0
  113. package/dist/src/semantics/pages.js +176 -0
  114. package/dist/src/semantics/projects.d.ts +2 -0
  115. package/dist/src/semantics/projects.js +239 -0
  116. package/dist/src/semantics/pulls.d.ts +2 -0
  117. package/dist/src/semantics/pulls.js +462 -0
  118. package/dist/src/semantics/push-reactions.d.ts +101 -0
  119. package/dist/src/semantics/push-reactions.js +513 -0
  120. package/dist/src/semantics/releases.d.ts +19 -0
  121. package/dist/src/semantics/releases.js +231 -0
  122. package/dist/src/semantics/repo-invitations.d.ts +2 -0
  123. package/dist/src/semantics/repo-invitations.js +98 -0
  124. package/dist/src/semantics/repos.d.ts +17 -0
  125. package/dist/src/semantics/repos.js +390 -0
  126. package/dist/src/semantics/rulesets.d.ts +2 -0
  127. package/dist/src/semantics/rulesets.js +111 -0
  128. package/dist/src/semantics/search.d.ts +2 -0
  129. package/dist/src/semantics/search.js +84 -0
  130. package/dist/src/semantics/security.d.ts +2 -0
  131. package/dist/src/semantics/security.js +177 -0
  132. package/dist/src/semantics/shared.d.ts +61 -0
  133. package/dist/src/semantics/shared.js +151 -0
  134. package/dist/src/semantics/users.d.ts +2 -0
  135. package/dist/src/semantics/users.js +255 -0
  136. package/dist/src/semantics/webhooks.d.ts +2 -0
  137. package/dist/src/semantics/webhooks.js +107 -0
  138. package/dist/test-fixtures/github-a11y-reference.pr-list.SOURCE.md +56 -0
  139. package/dist/test-fixtures/github-a11y-reference.pr-list.json +1447 -0
  140. package/dist/test-fixtures/github-comment-schema.SOURCE.md +11 -0
  141. package/dist/test-fixtures/github-comment-schema.json +702 -0
  142. package/dist/test-fixtures/github-known-deviations.json +94 -0
  143. package/dist/test-fixtures/github-openapi-operations.SOURCE.md +76 -0
  144. package/dist/test-fixtures/github-openapi-operations.json +362 -0
  145. package/dist/test-fixtures/github-pull-schema.SOURCE.md +37 -0
  146. package/dist/test-fixtures/github-pull-schema.json +3601 -0
  147. package/dist/test-fixtures/github-review-schema.SOURCE.md +12 -0
  148. package/dist/test-fixtures/github-review-schema.json +236 -0
  149. package/package.json +20 -11
  150. package/src/cli.ts +7 -6
  151. package/src/generated/graphql-sdl.gen.json +1 -0
  152. package/src/generated/graphql.gen.json +1 -0
  153. package/src/generated/surface.gen.json +1 -0
  154. package/src/generated/ui.gen.json +1 -0
  155. package/src/github-a11y-snapshot.uitest.ts +5 -5
  156. package/src/github-budget.ts +4 -4
  157. package/src/github-capabilities.ts +1993 -556
  158. package/src/github-conformance.ts +12 -7
  159. package/src/github-connector.ts +108 -96
  160. package/src/github-events.ts +225 -95
  161. package/src/github-git-http.ts +48 -84
  162. package/src/github-git-plane.ts +247 -385
  163. package/src/github-journey.uitest.ts +28 -44
  164. package/src/github-mirror-state.ts +23 -61
  165. package/src/github-mirror-ui.ts +16 -10
  166. package/src/github-server.ts +158 -60
  167. package/src/github-shared.ts +1 -1
  168. package/src/github-twin.ts +1525 -4494
  169. package/src/github-ui-conformance.ts +7 -8
  170. package/src/github-ui-structure.ts +19 -18
  171. package/src/graphql-wire.ts +115 -0
  172. package/src/index.ts +50 -7
  173. package/src/manifest.ts +605 -0
  174. package/src/npm-registry.ts +43 -0
  175. package/src/screens/app-installation.tsx +217 -0
  176. package/src/screens/app-manifest.tsx +97 -0
  177. package/src/screens/oauth.tsx +258 -0
  178. package/src/screens/session.tsx +193 -0
  179. package/src/semantics/actions.ts +395 -0
  180. package/src/semantics/activity.ts +171 -0
  181. package/src/semantics/apps.ts +133 -0
  182. package/src/semantics/branches.ts +133 -0
  183. package/src/semantics/checks.ts +163 -0
  184. package/src/semantics/code-scanning-upload.ts +92 -0
  185. package/src/semantics/codespaces.ts +58 -0
  186. package/src/semantics/commits.ts +109 -0
  187. package/src/semantics/contents.ts +112 -0
  188. package/src/semantics/deployments.ts +116 -0
  189. package/src/semantics/gists.ts +85 -0
  190. package/src/semantics/git.ts +226 -0
  191. package/src/semantics/graphql.ts +505 -0
  192. package/src/semantics/index.ts +68 -0
  193. package/src/semantics/issues.ts +434 -0
  194. package/src/semantics/keys.ts +66 -0
  195. package/src/semantics/labels.ts +91 -0
  196. package/src/semantics/meta.ts +139 -0
  197. package/src/semantics/notifications.ts +58 -0
  198. package/src/semantics/orgs.ts +383 -0
  199. package/src/semantics/packages.ts +59 -0
  200. package/src/semantics/pages.ts +154 -0
  201. package/src/semantics/projects.ts +246 -0
  202. package/src/semantics/pulls.ts +421 -0
  203. package/src/semantics/push-reactions.ts +471 -0
  204. package/src/semantics/releases.ts +213 -0
  205. package/src/semantics/repo-invitations.ts +112 -0
  206. package/src/semantics/repos.ts +373 -0
  207. package/src/semantics/rulesets.ts +107 -0
  208. package/src/semantics/search.ts +83 -0
  209. package/src/semantics/security.ts +153 -0
  210. package/src/semantics/shared.ts +181 -0
  211. package/src/semantics/users.ts +252 -0
  212. package/src/semantics/webhooks.ts +101 -0
  213. package/test-fixtures/github-known-deviations.json +7 -8
  214. package/test-fixtures/github-openapi-operations.json +46 -227
  215. package/src/github-graphql.ts +0 -398
@@ -1,47 +1,28 @@
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.
1
+ // GITHUB GIT PLANE — the pack half of the kernel's git library (the engine slot,
2
+ // docs/contributing/architecture.md#the-engine-slot). Every repository's git objects live in ONE content-addressed
3
+ // object store per world over the blob seam (R11) — a fork copies refs, never objects; a deleted
4
+ // repository drops its refs — and every repository's refs live in the world store, carried by a
5
+ // snapshot like any other world state. Trees, commits and tags go through the library's codecs;
6
+ // ancestry, merge base and file diffs through its history. No `git` binary anywhere in this module.
8
7
  //
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';
8
+ // The names and semantics are the ones the twin's REST plumbing has always called; the calls are
9
+ // async now, because the store is.
32
10
  import { join } from 'node:path';
33
- import { worldPaths } from '@volter/twin';
11
+ import { getActiveBlobStore, getActiveWorldStore, git, worldPaths } from '@volter/world-core';
12
+ const { GitObjectStore, GitRefs, decodeCommit, decodeTag, decodeTree, diffFiles, encodeCommit, encodeTag, encodeTree, isAncestor, mergeBase, mergeText } = git;
13
+ type GitObjectStore = InstanceType<typeof git.GitObjectStore>;
14
+ type GitRefs = InstanceType<typeof git.GitRefs>;
15
+ type Commit = git.Commit; type Ident = git.Ident; type TreeEntry = git.TreeEntry;
34
16
 
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
17
  const SEGMENT_RE = /^[A-Za-z0-9_.-]+$/;
18
+ const SHA_RE = /^[0-9a-f]{40}$/i;
38
19
 
39
20
  /** The git plane root for a twin instance: `<state>/github/git`. */
40
21
  export function githubGitPlaneDir(root?: string): string {
41
22
  return join(worldPaths('github', root).dir, 'git');
42
23
  }
43
24
 
44
- /** The bare repo dir for `owner/name` (validated — a hostile name cannot escape the plane dir). */
25
+ /** A repository's ref prefix in the world store — `<plane>/<owner>/<name>.git` — after validating the name. */
45
26
  export function bareRepoDir(repo: string, root?: string): string {
46
27
  const [owner, name, extra] = repo.split('/');
47
28
  if (!owner || !name || extra !== undefined || !SEGMENT_RE.test(owner) || !SEGMENT_RE.test(name) || owner === '..' || name === '..' || owner === '.' || name === '.') {
@@ -50,134 +31,70 @@ export function bareRepoDir(repo: string, root?: string): string {
50
31
  return join(githubGitPlaneDir(root), owner, `${name}.git`);
51
32
  }
52
33
 
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()) };
34
+ /** ONE object store per world: content-addressed, shared by every repository of the world. */
35
+ function objectStore(root?: string): GitObjectStore {
36
+ return new GitObjectStore(getActiveBlobStore(), join(githubGitPlaneDir(root), 'objects'));
37
+ }
38
+ function refsOf(repo: string, root?: string): GitRefs {
39
+ return new GitRefs(getActiveWorldStore(), bareRepoDir(repo, root));
107
40
  }
108
41
 
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;
42
+ export function hasBareRepo(repo: string, root?: string): boolean {
43
+ return getActiveWorldStore().exists(join(bareRepoDir(repo, root), 'HEAD'));
116
44
  }
117
45
 
118
- /** Idempotently create the bare repo for `repo` (HEAD → refs/heads/<defaultBranch>). */
46
+ /** Create the repository's refs (HEAD → the default branch) when absent; answers its prefix. */
119
47
  export function ensureBareRepo(repo: string, root?: string, defaultBranch = 'main'): string {
120
48
  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}`]);
49
+ if (!hasBareRepo(repo, root)) refsOf(repo, root).setHead(`refs/heads/${defaultBranch}`);
126
50
  return dir;
127
51
  }
128
52
 
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;
53
+ export async function objectExistsInPlane(repo: string, sha: string, root?: string): Promise<boolean> {
54
+ if (!hasBareRepo(repo, root) || !SHA_RE.test(sha)) return false;
55
+ return objectStore(root).has(sha.toLowerCase());
133
56
  }
134
57
 
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;
58
+ export async function objectTypeInPlane(repo: string, sha: string, root?: string): Promise<string | null> {
59
+ if (!hasBareRepo(repo, root) || !SHA_RE.test(sha)) return null;
60
+ const obj = await objectStore(root).read(sha.toLowerCase());
61
+ return obj?.type ?? null;
139
62
  }
140
63
 
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();
64
+ export async function writeGitBlobToPlane(repo: string, contentB64: string, root?: string): Promise<string> {
65
+ ensureBareRepo(repo, root);
66
+ return objectStore(root).write('blob', new Uint8Array(Buffer.from(contentB64, 'base64')));
147
67
  }
148
68
 
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 };
69
+ export async function readGitBlobFromPlane(repo: string, sha: string, root?: string): Promise<{ content_b64: string; size: number } | null> {
70
+ if (!hasBareRepo(repo, root) || !SHA_RE.test(sha)) return null;
71
+ const obj = await objectStore(root).read(sha.toLowerCase());
72
+ if (!obj || obj.type !== 'blob') return null;
73
+ return { content_b64: Buffer.from(obj.payload).toString('base64'), size: obj.payload.length };
153
74
  }
154
75
 
155
- // ── Objects: trees ─────────────────────────────────────────────────────────────────────────
156
76
  export type PlaneTreeEntry = { path: string; mode: string; type: string; sha: string; size?: number };
157
77
 
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]);
78
+ const isTreeMode = (mode: string): boolean => mode.replace(/^0+(?=\d)/, '') === '40000';
79
+ const modeOut = (mode: string): string => (isTreeMode(mode) ? '040000' : mode);
80
+ const typeOfMode = (mode: string): string => (isTreeMode(mode) ? 'tree' : mode.replace(/^0+/, '') === '160000' ? 'commit' : 'blob');
81
+
82
+ /** `ls-tree -r`: every blob under `sha`, keyed by its full path. */
83
+ async function lsTreeRecursive(store: GitObjectStore, sha: string, prefix = ''): Promise<Map<string, { mode: string; type: string; sha: string }>> {
162
84
  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]! });
85
+ const obj = await store.read(sha);
86
+ if (!obj || obj.type !== 'tree') return out;
87
+ for (const e of decodeTree(obj.payload)) {
88
+ const path = prefix ? `${prefix}/${e.name}` : e.name;
89
+ if (isTreeMode(e.mode)) { for (const [p, v] of await lsTreeRecursive(store, e.sha, path)) out.set(p, v); }
90
+ else out.set(path, { mode: e.mode, type: typeOfMode(e.mode), sha: e.sha });
167
91
  }
168
92
  return out;
169
93
  }
170
94
 
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> };
95
+ /** `mktree`, level by level, from a flat path → entry map. */
96
+ async function buildTreeFromFlat(store: GitObjectStore, flat: Map<string, { mode: string; type: string; sha: string }>): Promise<string> {
97
+ type Node = { entries: Map<string, { mode: string; sha: string }>; children: Map<string, Node> };
181
98
  const rootNode: Node = { entries: new Map(), children: new Map() };
182
99
  for (const [path, e] of flat) {
183
100
  const parts = path.split('/');
@@ -187,272 +104,184 @@ function buildTreeFromFlat(dir: string, flat: Map<string, { mode: string; type:
187
104
  if (!node.children.has(seg)) node.children.set(seg, { entries: new Map(), children: new Map() });
188
105
  node = node.children.get(seg)!;
189
106
  }
190
- node.entries.set(parts[parts.length - 1]!, e);
107
+ node.entries.set(parts[parts.length - 1]!, { mode: e.mode, sha: e.sha });
191
108
  }
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);
109
+ const write = async (node: Node): Promise<string> => {
110
+ const level: TreeEntry[] = [];
111
+ for (const [name, child] of node.children) level.push({ name, mode: '40000', sha: await write(child) });
112
+ for (const [name, e] of node.entries) level.push({ name, mode: e.mode.replace(/^0+(?=\d)/, ''), sha: e.sha });
113
+ return store.write('tree', encodeTree(level));
197
114
  };
198
115
  return write(rootNode);
199
116
  }
200
117
 
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)
118
+ /** A tree from `entries` over `baseTreeSha` (an entry with no sha deletes its path). */
119
+ export async function writeGitTreeToPlane(repo: string, entries: PlaneTreeEntry[], baseTreeSha: string | undefined, root?: string): Promise<string> {
120
+ ensureBareRepo(repo, root);
121
+ const store = objectStore(root);
122
+ const flat = baseTreeSha && (await objectTypeInPlane(repo, baseTreeSha, root)) === 'tree'
123
+ ? await lsTreeRecursive(store, baseTreeSha.toLowerCase())
211
124
  : new Map<string, { mode: string; type: string; sha: string }>();
212
125
  for (const e of entries) {
213
126
  if (!e.sha) { flat.delete(e.path); continue; }
214
127
  flat.set(e.path, { mode: e.mode, type: e.type, sha: e.sha });
215
128
  }
216
- return buildTreeFromFlat(dir, flat);
129
+ return buildTreeFromFlat(store, flat);
217
130
  }
218
131
 
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]);
132
+ /** `ls-tree -l`: one level, blob sizes measured. */
133
+ export async function readGitTreeFromPlane(repo: string, sha: string, root?: string): Promise<PlaneTreeEntry[] | null> {
134
+ if (!hasBareRepo(repo, root) || !SHA_RE.test(sha)) return null;
135
+ const store = objectStore(root);
136
+ const obj = await store.read(sha.toLowerCase());
137
+ if (!obj || obj.type !== 'tree') return null;
222
138
  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]);
139
+ for (const e of decodeTree(obj.payload)) {
140
+ const entry: PlaneTreeEntry = { path: e.name, mode: modeOut(e.mode), type: typeOfMode(e.mode), sha: e.sha };
141
+ if (entry.type === 'blob') { const b = await store.read(e.sha); if (b) entry.size = b.payload.length; }
231
142
  out.push(entry);
232
143
  }
233
144
  return out;
234
145
  }
235
146
 
236
- // ── Objects: commits + annotated tags ──────────────────────────────────────────────────────
237
- export type PlaneIdent = { name: string; email: string };
147
+ export type PlaneIdent = { name: string; email: string; date?: string };
238
148
  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 {
149
+ const identOf = (ident: PlaneIdent | undefined, occurredAt: string): Ident => {
241
150
  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(
151
+ return { name: who.name, email: who.email, time: Math.floor((Date.parse(who.date ?? occurredAt) || 0) / 1000), tz: '+0000' };
152
+ };
153
+
154
+ export async function writeGitCommitToPlane(
254
155
  repo: string,
255
156
  commit: { message: string; treeSha: string; parents: string[]; author?: PlaneIdent; committer?: PlaneIdent; occurredAt: string },
256
157
  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();
158
+ ): Promise<string> {
159
+ ensureBareRepo(repo, root);
160
+ const c: Commit = { tree: commit.treeSha, parents: commit.parents, author: identOf(commit.author, commit.occurredAt), committer: identOf(commit.committer ?? commit.author, commit.occurredAt), message: commit.message };
161
+ return objectStore(root).write('commit', encodeCommit(c));
269
162
  }
270
163
 
271
164
  export type PlaneCommit = { sha: string; message: string; tree_sha: string; parents: string[]; author_name?: string; author_email?: string; created_at?: string };
272
165
 
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;
166
+ export async function readGitCommitFromPlane(repo: string, sha: string, root?: string): Promise<PlaneCommit | null> {
167
+ if (!hasBareRepo(repo, root) || !SHA_RE.test(sha)) return null;
168
+ const obj = await objectStore(root).read(sha.toLowerCase());
169
+ if (!obj || obj.type !== 'commit') return null;
170
+ const c = decodeCommit(obj.payload);
171
+ return { sha: sha.toLowerCase(), message: c.message.replace(/\n$/, ''), tree_sha: c.tree, parents: c.parents, author_name: c.author.name, author_email: c.author.email, created_at: new Date(c.author.time * 1000).toISOString() };
289
172
  }
290
173
 
291
- /** Write a REAL annotated-tag object; returns the real sha. */
292
- export function writeGitTagToPlane(
174
+ export async function writeGitTagToPlane(
293
175
  repo: string,
294
176
  tag: { tag: string; message: string; objectSha: string; objectType: string; tagger?: PlaneIdent; occurredAt: string },
295
177
  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();
178
+ ): Promise<string> {
179
+ ensureBareRepo(repo, root);
180
+ return objectStore(root).write('tag', encodeTag({ object: tag.objectSha, type: tag.objectType as 'commit', tag: tag.tag, tagger: identOf(tag.tagger, tag.occurredAt), message: tag.message }));
308
181
  }
309
182
 
310
183
  export type PlaneTag = { sha: string; tag: string; message: string; object_sha: string; object_type: string; tagger_name?: string; created_at?: string };
311
184
 
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
- }
185
+ export async function readGitTagFromPlane(repo: string, sha: string, root?: string): Promise<PlaneTag | null> {
186
+ if (!hasBareRepo(repo, root) || !SHA_RE.test(sha)) return null;
187
+ const obj = await objectStore(root).read(sha.toLowerCase());
188
+ return !obj || obj.type !== 'tag' ? null : annotatedTag(sha, obj.payload);
189
+ }
190
+ /** An annotated tag object's fields. */
191
+ function annotatedTag(sha: string, payload: Uint8Array): PlaneTag {
192
+ const t = decodeTag(payload);
193
+ const out: PlaneTag = { sha: sha.toLowerCase(), tag: t.tag, message: t.message.replace(/\n$/, ''), object_sha: t.object, object_type: t.type };
194
+ if (t.tagger) { out.tagger_name = t.tagger.name; out.created_at = new Date(t.tagger.time * 1000).toISOString(); }
330
195
  return out;
331
196
  }
332
197
 
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;
198
+ // Objects are immutable, so a chain once found whole stays whole while its objects are kept: the walk stops at
199
+ // an object an earlier walk proved (still present), as git's connectivity check stops at what it already has.
200
+ // Without it every ref update re-read the repository's whole history.
201
+ const provenWhole = new WeakMap<object, Map<string, Set<string>>>();
202
+
203
+ /** Every object `sha` reaches (commits through parents and trees, trees through entries) is present. */
204
+ async function chainComplete(store: GitObjectStore, sha: string, root?: string, limit = 20000): Promise<boolean> {
205
+ const blobs = getActiveBlobStore(); const planes = provenWhole.get(blobs) ?? provenWhole.set(blobs, new Map()).get(blobs)!;
206
+ const plane = githubGitPlaneDir(root); const proven = planes.get(plane) ?? planes.set(plane, new Set()).get(plane)!;
207
+ const seen = new Set<string>(); const stack = [sha];
208
+ while (stack.length) {
209
+ const s = stack.pop()!;
210
+ if (seen.has(s)) continue; seen.add(s);
211
+ if (seen.size > limit) return true; // bounded: a huge history is not a dangling one
212
+ if (proven.has(s)) { if (await store.has(s)) continue; proven.clear(); return false; }
213
+ const obj = await store.read(s);
214
+ if (!obj) return false;
215
+ if (obj.type === 'commit') { const c = decodeCommit(obj.payload); stack.push(c.tree, ...c.parents); }
216
+ else if (obj.type === 'tree') { for (const e of decodeTree(obj.payload)) if (!/^160000$/.test(e.mode.replace(/^0+/, ''))) stack.push(e.sha); }
217
+ else if (obj.type === 'tag') stack.push(decodeTag(obj.payload).object);
218
+ }
219
+ for (const s of seen) proven.add(s);
220
+ return true;
347
221
  }
348
222
 
349
- export function deleteGitRefInPlane(repo: string, ref: string, root?: string): void {
223
+ /** Point `ref` at `sha` — only at an object the plane holds whole (a dangling chain is not clonable). */
224
+ export async function updateGitRefInPlane(repo: string, ref: string, sha: string, root?: string, expected?: string | null): Promise<boolean> {
225
+ if (!hasBareRepo(repo, root) || !SHA_RE.test(sha)) return false;
226
+ const store = objectStore(root);
227
+ if (!(await store.has(sha.toLowerCase())) || !(await chainComplete(store, sha.toLowerCase(), root))) return false;
228
+ const refs = refsOf(repo, root);
229
+ return refs.withLock(() => {
230
+ if (expected !== undefined && refs.get(ref) !== expected) return false;
231
+ refs.set(ref, sha.toLowerCase());
232
+ return true;
233
+ });
234
+ }
235
+
236
+ export async function deleteGitRefInPlane(repo: string, ref: string, root?: string): Promise<void> {
350
237
  if (!hasBareRepo(repo, root)) return;
351
- runGit(bareRepoDir(repo, root), ['update-ref', '-d', ref]); // non-existence is fine
238
+ refsOf(repo, root).delete(ref);
352
239
  }
353
240
 
354
241
  export type PlaneRef = { ref: string; object_sha: string; object_type: string };
355
242
 
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
243
  export function setBareRepoHead(repo: string, branch: string, root?: string): void {
359
244
  if (!hasBareRepo(repo, root)) return;
360
- mustGit(bareRepoDir(repo, root), ['symbolic-ref', 'HEAD', `refs/heads/${branch}`]);
245
+ refsOf(repo, root).setHead(`refs/heads/${branch}`);
361
246
  }
362
247
 
363
- /** Remove a deleted repo's bare repo — a later re-create must NOT inherit its history. */
248
+ /** Drop the repository's refs; its objects stay in the world's store (content-addressed, shared). */
364
249
  export function deleteBareRepo(repo: string, root?: string): void {
365
250
  if (!hasBareRepo(repo, root)) return;
366
- rmSync(bareRepoDir(repo, root), { recursive: true, force: true });
251
+ const refs = refsOf(repo, root);
252
+ for (const r of refs.list()) refs.delete(r.name);
253
+ getActiveWorldStore().remove(join(bareRepoDir(repo, root), 'HEAD'));
367
254
  }
368
255
 
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
- */
256
+ /** A fork shares the world's object store: copy the refs and HEAD, nothing else. */
374
257
  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
258
+ bareRepoDir(forkRepo, root); // validates the fork name before any copy
376
259
  if (hasBareRepo(forkRepo, root)) return;
377
260
  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;
261
+ const from = refsOf(parentRepo, root); const to = refsOf(forkRepo, root);
262
+ to.setHead(from.head());
263
+ for (const r of from.list()) to.set(r.name, r.sha);
264
+ }
265
+
266
+ export async function isAncestorInPlane(repo: string, ancestor: string, descendant: string, root?: string): Promise<boolean> {
267
+ if (!hasBareRepo(repo, root) || !SHA_RE.test(ancestor) || !SHA_RE.test(descendant)) return false;
268
+ return isAncestor(objectStore(root), ancestor.toLowerCase(), descendant.toLowerCase());
269
+ }
270
+
271
+ export type PlaneDiffFile = { filename: string; status: string; additions: number; deletions: number; sha?: string; patch?: string };
272
+
273
+ export async function mergeBaseInPlane(repo: string, a: string, b: string, root?: string): Promise<string | null> {
274
+ if (!(await objectExistsInPlane(repo, a, root)) || !(await objectExistsInPlane(repo, b, root))) return null;
275
+ return mergeBase(objectStore(root), a.toLowerCase(), b.toLowerCase());
276
+ }
277
+
278
+ /** GitHub's compare: merge-base(base, head)..head, as the library's `diffFiles` computes it. */
279
+ export async function diffFilesInPlane(repo: string, baseSha: string, headSha: string, root?: string, opts: { patch?: boolean } = {}): Promise<PlaneDiffFile[] | null> {
280
+ if (!(await objectExistsInPlane(repo, baseSha, root)) || !(await objectExistsInPlane(repo, headSha, root))) return null;
281
+ const files = await diffFiles(objectStore(root), baseSha.toLowerCase(), headSha.toLowerCase(), opts);
282
+ return files === null ? null : files.map((f: git.DiffFile) => ({ filename: f.filename, status: f.status, additions: f.additions, deletions: f.deletions, ...(f.sha !== undefined ? { sha: f.sha } : {}), ...(f.patch !== undefined ? { patch: f.patch } : {}) }));
449
283
  }
450
284
 
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
285
  export function isValidBranchName(name: string): boolean {
457
286
  if (!name || name.length > 250) return false;
458
287
  if (/[\s~^:?*[\\\x00-\x1f\x7f]/.test(name)) return false;
@@ -461,51 +290,84 @@ export function isValidBranchName(name: string): boolean {
461
290
  return true;
462
291
  }
463
292
 
464
- /** All refs in the bare repo (`for-each-ref`) — the protocol-visible ref set. */
465
- export function listGitPlaneRefs(repo: string, root?: string): PlaneRef[] {
293
+ /** `for-each-ref`: every ref with its object's sha and type. */
294
+ export async function listGitPlaneRefs(repo: string, root?: string): Promise<PlaneRef[]> {
466
295
  if (!hasBareRepo(repo, root)) return [];
467
- const res = mustGit(bareRepoDir(repo, root), ['for-each-ref', '--format=%(objectname) %(objecttype) %(refname)']);
296
+ const store = objectStore(root);
468
297
  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]! });
298
+ for (const r of refsOf(repo, root).list()) {
299
+ const obj = await store.read(r.sha);
300
+ out.push({ ref: r.name, object_sha: r.sha, object_type: obj?.type ?? 'commit' });
473
301
  }
474
302
  return out;
475
303
  }
476
304
 
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 {
305
+ /** The library's view of a repository, for smart HTTP. */
306
+ export function planeRepo(repo: string, root?: string): { store: GitObjectStore; refs: GitRefs } {
307
+ return { store: objectStore(root), refs: refsOf(repo, root) };
308
+ }
309
+
310
+ type FlatEntry = { mode: string; type: string; sha: string };
311
+ /** A file both sides of a merge changed: its three texts merged into one blob, or nothing when they conflict (a side
312
+ * that is not a text blob, modes that disagree, or edits that overlap). */
313
+ async function mergeBothSides(store: GitObjectStore, original: FlatEntry | undefined, ours: FlatEntry | undefined, theirs: FlatEntry | undefined): Promise<FlatEntry | null> {
314
+ if (!ours || !theirs || !original || ours.type !== 'blob' || theirs.type !== 'blob' || original.type !== 'blob') return null;
315
+ const mode = ours.mode === theirs.mode ? ours.mode : ours.mode === original.mode ? theirs.mode : theirs.mode === original.mode ? ours.mode : null;
316
+ if (!mode || !mode.startsWith('100')) return null;
317
+ const objects = await Promise.all([original, ours, theirs].map(entry => store.read(entry.sha)));
318
+ if (objects.some(obj => !obj || obj.type !== 'blob' || obj.payload.includes(0))) return null;
319
+ let contents: string[];
320
+ try { contents = objects.map(obj => new TextDecoder('utf-8', { fatal: true }).decode(obj!.payload)); }
321
+ catch { return null; }
322
+ const content = mergeText(contents[0]!, contents[1]!, contents[2]!);
323
+ return content === null ? null : { mode, type: 'blob', sha: await store.write('blob', new TextEncoder().encode(content)) };
324
+ }
325
+
326
+ /** Merge three trees, publishing the resulting ref only if its original tip still holds.
327
+ * Text edits merge against the common ancestor; no conflicted tree is published. A dry run answers
328
+ * the merged tree without writing a commit or moving the branch (a pull request's mergeability). */
329
+ export async function mergeIntoBranchInPlane(
330
+ repo: string, baseBranch: string, headSha: string, message: string,
331
+ method: 'merge' | 'squash' | 'rebase', occurredAt: string, root?: string, merger?: PlaneIdent, dryRun = false,
332
+ ): Promise<{ sha: string } | { conflict: string } | null> {
497
333
  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 };
334
+ const refs = refsOf(repo, root);
335
+ const ref = `refs/heads/${baseBranch}`;
336
+ const baseSha = refs.get(ref);
337
+ if (!baseSha) return null;
338
+ const store = objectStore(root);
339
+ const base = await readGitCommitFromPlane(repo, baseSha, root);
340
+ const head = await readGitCommitFromPlane(repo, headSha, root);
341
+ if (!base || !head) return { conflict: 'missing commit' };
342
+ if (await isAncestor(store, headSha, baseSha)) return { sha: baseSha };
343
+ const ancestorSha = await mergeBase(store, baseSha, headSha);
344
+ if (!ancestorSha) return { conflict: 'unrelated histories' };
345
+ const ancestor = ancestorSha ? await readGitCommitFromPlane(repo, ancestorSha, root) : null;
346
+ const before = ancestor ? await lsTreeRecursive(store, ancestor.tree_sha) : new Map<string, { mode: string; type: string; sha: string }>();
347
+ const left = await lsTreeRecursive(store, base.tree_sha);
348
+ const right = await lsTreeRecursive(store, head.tree_sha);
349
+ const merged = new Map(left);
350
+ const same = (a: { mode: string; sha: string } | undefined, b: { mode: string; sha: string } | undefined) => a?.mode === b?.mode && a?.sha === b?.sha;
351
+ for (const path of new Set([...before.keys(), ...left.keys(), ...right.keys()])) {
352
+ const original = before.get(path), ours = left.get(path), theirs = right.get(path);
353
+ if (same(ours, theirs) || same(theirs, original)) continue;
354
+ // both sides changed the file: its texts merge against the ancestor's, or the merge conflicts
355
+ if (!same(ours, original)) { const both = await mergeBothSides(store, original, ours, theirs); if (!both) return { conflict: path }; merged.set(path, both); continue; }
356
+ if (theirs) merged.set(path, theirs); else merged.delete(path);
357
+ }
358
+ // A file/directory collision must not produce a tree containing both names.
359
+ for (const path of merged.keys()) {
360
+ const parts = path.split('/');
361
+ for (let i = 1; i < parts.length; i++) if (merged.has(parts.slice(0, i).join('/'))) return { conflict: path };
362
+ }
363
+ const treeSha = await buildTreeFromFlat(store, merged);
364
+ if (dryRun) return { sha: treeSha };
365
+ // GitHub authors a merge made through the API or the web as the person who merged, committed by GitHub itself.
366
+ const sha = await writeGitCommitToPlane(repo, { message, treeSha, parents: method === 'merge' ? [baseSha, headSha] : [baseSha], occurredAt,
367
+ ...(merger ? { author: merger, committer: { name: 'GitHub', email: 'noreply@github.com' } } : {}) }, root);
368
+ return refs.withLock(() => {
369
+ if (refs.get(ref) !== baseSha) return { conflict: 'base moved' };
370
+ refs.set(ref, sha);
371
+ return { sha };
372
+ });
511
373
  }