@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.
- package/README.md +88 -36
- package/client/github-mirror.css +277 -319
- package/client/github-mirror.d.ts +418 -0
- package/client/github-mirror.js +485 -0
- package/client/github-mirror.tsx +153 -357
- package/client/pulls-rest.ts +159 -0
- package/client/pulls-workspace.tsx +841 -0
- package/dist/client/github-mirror.bundle.js +239 -0
- package/dist/client/github-mirror.css +916 -0
- package/dist/client/github-mirror.d.ts +418 -0
- package/dist/client/github-mirror.js +485 -0
- package/dist/client/github-mirror.tsx +1315 -0
- package/dist/client/pulls-rest.d.ts +42 -0
- package/dist/client/pulls-rest.js +140 -0
- package/dist/client/pulls-rest.ts +159 -0
- package/dist/client/pulls-workspace.bundle.js +22 -0
- package/dist/client/pulls-workspace.d.ts +114 -0
- package/dist/client/pulls-workspace.js +418 -0
- package/dist/client/pulls-workspace.tsx +841 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +40 -0
- package/dist/src/generated/graphql-sdl.gen.json +1 -0
- package/dist/src/generated/graphql.gen.json +1 -0
- package/dist/src/generated/surface.gen.json +1 -0
- package/dist/src/generated/ui.gen.json +1 -0
- package/dist/src/github-budget.d.ts +69 -0
- package/dist/src/github-budget.js +172 -0
- package/dist/src/github-capabilities.d.ts +5 -0
- package/dist/src/github-capabilities.js +4468 -0
- package/dist/src/github-conformance.d.ts +43 -0
- package/dist/src/github-conformance.js +76 -0
- package/dist/src/github-connector.d.ts +307 -0
- package/dist/src/github-connector.js +1398 -0
- package/dist/src/github-events.d.ts +41 -0
- package/dist/src/github-events.js +232 -0
- package/dist/src/github-git-http.d.ts +49 -0
- package/dist/src/github-git-http.js +185 -0
- package/dist/src/github-git-plane.d.ts +114 -0
- package/dist/src/github-git-plane.js +407 -0
- package/dist/src/github-mirror-state.d.ts +2 -0
- package/dist/src/github-mirror-state.js +335 -0
- package/dist/src/github-mirror-ui.d.ts +20 -0
- package/dist/src/github-mirror-ui.js +101 -0
- package/dist/src/github-server.d.ts +14 -0
- package/dist/src/github-server.js +240 -0
- package/dist/src/github-shared.d.ts +12 -0
- package/dist/src/github-shared.js +21 -0
- package/dist/src/github-twin.d.ts +1411 -0
- package/dist/src/github-twin.js +4084 -0
- package/dist/src/github-ui-conformance.d.ts +4 -0
- package/dist/src/github-ui-conformance.js +105 -0
- package/dist/src/github-ui-structure.d.ts +18 -0
- package/dist/src/github-ui-structure.js +251 -0
- package/dist/src/graphql-wire.d.ts +22 -0
- package/dist/src/graphql-wire.js +89 -0
- package/dist/src/index.d.ts +17 -0
- package/dist/src/index.js +104 -0
- package/dist/src/manifest.d.ts +8 -0
- package/dist/src/manifest.js +597 -0
- package/dist/src/npm-registry.d.ts +7 -0
- package/dist/src/npm-registry.js +47 -0
- package/dist/src/screens/app-installation.d.ts +3 -0
- package/dist/src/screens/app-installation.js +166 -0
- package/dist/src/screens/app-manifest.d.ts +3 -0
- package/dist/src/screens/app-manifest.js +81 -0
- package/dist/src/screens/oauth.d.ts +15 -0
- package/dist/src/screens/oauth.js +257 -0
- package/dist/src/screens/session.d.ts +8 -0
- package/dist/src/screens/session.js +170 -0
- package/dist/src/semantics/actions.d.ts +2 -0
- package/dist/src/semantics/actions.js +413 -0
- package/dist/src/semantics/activity.d.ts +4 -0
- package/dist/src/semantics/activity.js +161 -0
- package/dist/src/semantics/apps.d.ts +2 -0
- package/dist/src/semantics/apps.js +144 -0
- package/dist/src/semantics/branches.d.ts +2 -0
- package/dist/src/semantics/branches.js +136 -0
- package/dist/src/semantics/checks.d.ts +2 -0
- package/dist/src/semantics/checks.js +176 -0
- package/dist/src/semantics/code-scanning-upload.d.ts +2 -0
- package/dist/src/semantics/code-scanning-upload.js +97 -0
- package/dist/src/semantics/codespaces.d.ts +2 -0
- package/dist/src/semantics/codespaces.js +58 -0
- package/dist/src/semantics/commits.d.ts +2 -0
- package/dist/src/semantics/commits.js +109 -0
- package/dist/src/semantics/contents.d.ts +2 -0
- package/dist/src/semantics/contents.js +131 -0
- package/dist/src/semantics/deployments.d.ts +2 -0
- package/dist/src/semantics/deployments.js +127 -0
- package/dist/src/semantics/gists.d.ts +2 -0
- package/dist/src/semantics/gists.js +86 -0
- package/dist/src/semantics/git.d.ts +2 -0
- package/dist/src/semantics/git.js +235 -0
- package/dist/src/semantics/graphql.d.ts +7 -0
- package/dist/src/semantics/graphql.js +512 -0
- package/dist/src/semantics/index.d.ts +5 -0
- package/dist/src/semantics/index.js +62 -0
- package/dist/src/semantics/issues.d.ts +2 -0
- package/dist/src/semantics/issues.js +456 -0
- package/dist/src/semantics/keys.d.ts +2 -0
- package/dist/src/semantics/keys.js +66 -0
- package/dist/src/semantics/labels.d.ts +2 -0
- package/dist/src/semantics/labels.js +100 -0
- package/dist/src/semantics/meta.d.ts +12 -0
- package/dist/src/semantics/meta.js +144 -0
- package/dist/src/semantics/notifications.d.ts +4 -0
- package/dist/src/semantics/notifications.js +62 -0
- package/dist/src/semantics/orgs.d.ts +2 -0
- package/dist/src/semantics/orgs.js +420 -0
- package/dist/src/semantics/packages.d.ts +2 -0
- package/dist/src/semantics/packages.js +55 -0
- package/dist/src/semantics/pages.d.ts +2 -0
- package/dist/src/semantics/pages.js +176 -0
- package/dist/src/semantics/projects.d.ts +2 -0
- package/dist/src/semantics/projects.js +239 -0
- package/dist/src/semantics/pulls.d.ts +2 -0
- package/dist/src/semantics/pulls.js +462 -0
- package/dist/src/semantics/push-reactions.d.ts +101 -0
- package/dist/src/semantics/push-reactions.js +513 -0
- package/dist/src/semantics/releases.d.ts +19 -0
- package/dist/src/semantics/releases.js +231 -0
- package/dist/src/semantics/repo-invitations.d.ts +2 -0
- package/dist/src/semantics/repo-invitations.js +98 -0
- package/dist/src/semantics/repos.d.ts +17 -0
- package/dist/src/semantics/repos.js +390 -0
- package/dist/src/semantics/rulesets.d.ts +2 -0
- package/dist/src/semantics/rulesets.js +111 -0
- package/dist/src/semantics/search.d.ts +2 -0
- package/dist/src/semantics/search.js +84 -0
- package/dist/src/semantics/security.d.ts +2 -0
- package/dist/src/semantics/security.js +177 -0
- package/dist/src/semantics/shared.d.ts +61 -0
- package/dist/src/semantics/shared.js +151 -0
- package/dist/src/semantics/users.d.ts +2 -0
- package/dist/src/semantics/users.js +255 -0
- package/dist/src/semantics/webhooks.d.ts +2 -0
- package/dist/src/semantics/webhooks.js +107 -0
- package/dist/test-fixtures/github-a11y-reference.pr-list.SOURCE.md +56 -0
- package/dist/test-fixtures/github-a11y-reference.pr-list.json +1447 -0
- package/dist/test-fixtures/github-comment-schema.SOURCE.md +11 -0
- package/dist/test-fixtures/github-comment-schema.json +702 -0
- package/dist/test-fixtures/github-known-deviations.json +94 -0
- package/dist/test-fixtures/github-openapi-operations.SOURCE.md +76 -0
- package/dist/test-fixtures/github-openapi-operations.json +362 -0
- package/dist/test-fixtures/github-pull-schema.SOURCE.md +37 -0
- package/dist/test-fixtures/github-pull-schema.json +3601 -0
- package/dist/test-fixtures/github-review-schema.SOURCE.md +12 -0
- package/dist/test-fixtures/github-review-schema.json +236 -0
- package/package.json +20 -11
- package/src/cli.ts +7 -6
- package/src/generated/graphql-sdl.gen.json +1 -0
- package/src/generated/graphql.gen.json +1 -0
- package/src/generated/surface.gen.json +1 -0
- package/src/generated/ui.gen.json +1 -0
- package/src/github-a11y-snapshot.uitest.ts +5 -5
- package/src/github-budget.ts +4 -4
- package/src/github-capabilities.ts +1993 -556
- package/src/github-conformance.ts +12 -7
- package/src/github-connector.ts +108 -96
- package/src/github-events.ts +225 -95
- package/src/github-git-http.ts +48 -84
- package/src/github-git-plane.ts +247 -385
- package/src/github-journey.uitest.ts +28 -44
- package/src/github-mirror-state.ts +23 -61
- package/src/github-mirror-ui.ts +16 -10
- package/src/github-server.ts +158 -60
- package/src/github-shared.ts +1 -1
- package/src/github-twin.ts +1525 -4494
- package/src/github-ui-conformance.ts +7 -8
- package/src/github-ui-structure.ts +19 -18
- package/src/graphql-wire.ts +115 -0
- package/src/index.ts +50 -7
- package/src/manifest.ts +605 -0
- package/src/npm-registry.ts +43 -0
- package/src/screens/app-installation.tsx +217 -0
- package/src/screens/app-manifest.tsx +97 -0
- package/src/screens/oauth.tsx +258 -0
- package/src/screens/session.tsx +193 -0
- package/src/semantics/actions.ts +395 -0
- package/src/semantics/activity.ts +171 -0
- package/src/semantics/apps.ts +133 -0
- package/src/semantics/branches.ts +133 -0
- package/src/semantics/checks.ts +163 -0
- package/src/semantics/code-scanning-upload.ts +92 -0
- package/src/semantics/codespaces.ts +58 -0
- package/src/semantics/commits.ts +109 -0
- package/src/semantics/contents.ts +112 -0
- package/src/semantics/deployments.ts +116 -0
- package/src/semantics/gists.ts +85 -0
- package/src/semantics/git.ts +226 -0
- package/src/semantics/graphql.ts +505 -0
- package/src/semantics/index.ts +68 -0
- package/src/semantics/issues.ts +434 -0
- package/src/semantics/keys.ts +66 -0
- package/src/semantics/labels.ts +91 -0
- package/src/semantics/meta.ts +139 -0
- package/src/semantics/notifications.ts +58 -0
- package/src/semantics/orgs.ts +383 -0
- package/src/semantics/packages.ts +59 -0
- package/src/semantics/pages.ts +154 -0
- package/src/semantics/projects.ts +246 -0
- package/src/semantics/pulls.ts +421 -0
- package/src/semantics/push-reactions.ts +471 -0
- package/src/semantics/releases.ts +213 -0
- package/src/semantics/repo-invitations.ts +112 -0
- package/src/semantics/repos.ts +373 -0
- package/src/semantics/rulesets.ts +107 -0
- package/src/semantics/search.ts +83 -0
- package/src/semantics/security.ts +153 -0
- package/src/semantics/shared.ts +181 -0
- package/src/semantics/users.ts +252 -0
- package/src/semantics/webhooks.ts +101 -0
- package/test-fixtures/github-known-deviations.json +7 -8
- package/test-fixtures/github-openapi-operations.json +46 -227
- package/src/github-graphql.ts +0 -398
package/src/github-git-plane.ts
CHANGED
|
@@ -1,47 +1,28 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
-
//
|
|
10
|
-
//
|
|
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/
|
|
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
|
-
/**
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
110
|
-
|
|
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
|
-
/**
|
|
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 (
|
|
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
|
-
|
|
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) ||
|
|
137
|
-
const
|
|
138
|
-
return
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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 (
|
|
151
|
-
const
|
|
152
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
|
172
|
-
function
|
|
173
|
-
|
|
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:
|
|
194
|
-
for (const [name, child] of node.children) level.push({ name, mode: '
|
|
195
|
-
for (const [name, e] of node.entries) level.push({ name, mode: e.mode
|
|
196
|
-
return
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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(
|
|
129
|
+
return buildTreeFromFlat(store, flat);
|
|
217
130
|
}
|
|
218
131
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
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
|
-
|
|
243
|
-
|
|
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
|
-
|
|
259
|
-
const
|
|
260
|
-
|
|
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 (
|
|
275
|
-
const
|
|
276
|
-
|
|
277
|
-
const
|
|
278
|
-
|
|
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
|
-
|
|
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
|
-
|
|
298
|
-
|
|
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 (
|
|
314
|
-
const
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
//
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
245
|
+
refsOf(repo, root).setHead(`refs/heads/${branch}`);
|
|
361
246
|
}
|
|
362
247
|
|
|
363
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
export function isAncestorInPlane(repo: string, ancestor: string, descendant: string, root?: string): boolean {
|
|
384
|
-
if (!hasBareRepo(repo, root)) return false;
|
|
385
|
-
return
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
export type PlaneDiffFile = { filename: string; status: string; additions: number; deletions: number; sha?: string };
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
-
/**
|
|
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
|
|
296
|
+
const store = objectStore(root);
|
|
468
297
|
const out: PlaneRef[] = [];
|
|
469
|
-
for (const
|
|
470
|
-
|
|
471
|
-
|
|
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
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
*
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
|
499
|
-
const
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
const
|
|
504
|
-
|
|
505
|
-
if (
|
|
506
|
-
|
|
507
|
-
const
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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
|
}
|