-
Notifications
You must be signed in to change notification settings - Fork 57
Expand file tree
/
Copy pathapiCommit.ts
More file actions
407 lines (381 loc) · 16.1 KB
/
Copy pathapiCommit.ts
File metadata and controls
407 lines (381 loc) · 16.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
import { execFileSync } from "node:child_process";
import { lstat, readlink } from "node:fs/promises";
import { join } from "node:path";
import { log } from "./cli.ts";
import { $ } from "./shell.ts";
/**
* commit creation via the GitHub REST git-database API (blob → tree → commit
* → ref). commits created with an installation token and no custom
* author/committer are signed server-side by GitHub and show as Verified —
* this is how Dependabot, Renovate (platformCommit), and claude-code-action
* satisfy "require signed commits" branch protection. design adapted from
* anthropics/claude-code-action's github-file-ops server (MIT), with modes
* read via lstat (their stat() call can never detect symlinks) and blob
* content routed through `git hash-object` so clean filters and text/eol
* normalization apply exactly as a local `git commit` would.
*/
const GITHUB_API = "https://api.github.com";
/** undocumented create-blob ceiling is ~40MiB per prior art
* (IAreKyleW00t/verified-bot-commit); refuse before uploading so the agent
* gets an actionable error instead of a proxy-level 413. */
const MAX_BLOB_BYTES = 30 * 1024 * 1024;
const BLOB_UPLOAD_CONCURRENCY = 8;
/** one working-tree change to include in an API commit. */
export type ChangedFile = { path: string; deleted: boolean };
/** paths from git plumbing are repo-root-relative regardless of cwd, and
* monorepo runs chdir into payload.cwd — resolve fs access against the root. */
function getRepoRoot(): string {
return $("git", ["rev-parse", "--show-toplevel"], { log: false }).trim();
}
async function gh(params: {
token: string;
method: "GET" | "POST" | "PATCH";
path: string;
body?: unknown;
}): Promise<{ status: number; json: unknown }> {
const response = await fetch(`${GITHUB_API}${params.path}`, {
method: params.method,
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${params.token}`,
"X-GitHub-Api-Version": "2022-11-28",
"Content-Type": "application/json",
},
body: params.body === undefined ? null : JSON.stringify(params.body),
});
const text = await response.text();
// proxy-level failures (413, secondary-limit blocks) can be HTML/plain text
let json: unknown;
try {
json = text ? JSON.parse(text) : null;
} catch {
json = text.slice(0, 500);
}
return { status: response.status, json };
}
function ghError(method: string, path: string, result: { status: number; json: unknown }): Error {
return new Error(`${method} ${path} failed (${result.status}): ${JSON.stringify(result.json)}`);
}
function isRetryable(status: number): boolean {
return status === 403 || status === 429 || status >= 500;
}
/**
* all working-tree changes vs HEAD: tracked changes (committed-to-index,
* staged, and unstaged all collapse into `git diff HEAD`) plus untracked
* files from `git status`. respects .gitignore. throws on unresolved
* conflicts — the caller's guidance is to resolve and `git add` first.
*/
export function detectWorkingTreeChanges(): ChangedFile[] {
const byPath = new Map<string, ChangedFile>();
const diff = $("git", ["diff", "--name-status", "--no-renames", "-z", "HEAD"], { log: false });
const tokens = diff.split("\0").filter((t) => t.length > 0);
for (let i = 0; i + 1 < tokens.length; i += 2) {
const status = tokens[i];
const path = tokens[i + 1];
if (status === undefined || path === undefined) break;
if (status === "U") {
throw new Error(
`'${path}' has unresolved merge conflicts. resolve the conflicts, stage the result with git add, then retry.`
);
}
byPath.set(path, { path, deleted: status === "D" });
}
const porcelain = $("git", ["status", "--porcelain=v1", "-z", "-uall", "--no-renames"], {
log: false,
});
for (const entry of porcelain.split("\0")) {
// skip paths already seen via the diff: `git rm --cached <f>` reports the
// path as both deleted (index) and untracked (worktree) — the deletion
// entry alone reproduces the untrack-but-keep intent remotely.
if (entry.startsWith("?? ") && !byPath.has(entry.slice(3))) {
const path = entry.slice(3);
byPath.set(path, { path, deleted: false });
}
}
return [...byPath.values()];
}
/**
* refuse content the API path cannot faithfully commit: git-lfs files (the
* pointer would be committed without the lfs object upload that the git
* pre-push hook performs) and directories (nested repositories / submodule
* pointers). other clean filters are fine — blob content goes through
* `git hash-object`, which applies them exactly like a local commit.
*/
export async function assertApiCommittable(files: ChangedFile[]): Promise<void> {
const present = files.filter((f) => !f.deleted).map((f) => f.path);
if (present.length === 0) return;
const root = getRepoRoot();
// absolute paths: check-attr resolves relative args against cwd, which is
// payload.cwd in monorepo runs — not the repo root the paths come from
const attrs = $(
"git",
["check-attr", "filter", "-z", "--", ...present.map((p) => join(root, p))],
{ log: false }
);
const parts = attrs.split("\0");
for (let i = 0; i + 2 < parts.length; i += 3) {
if (parts[i + 2] === "lfs") {
throw new Error(
`'${parts[i]}' is tracked by git-lfs, which signed commits can't upload. remove it from the change set or ask the user to disable signed commits for this repo.`
);
}
}
for (const path of present) {
const stat = await lstat(join(root, path));
if (stat.isDirectory()) {
throw new Error(
`'${path}' is a directory (nested repository or submodule?) — signed commits only support files and symlinks.`
);
}
}
}
/** upload one file as a blob and return its tree entry. content comes from
* `git hash-object -w` + `cat-file blob`, so clean filters and text/eol
* normalization match a local commit byte-for-byte (and the post-commit
* resync leaves git status clean); modes come from lstat so executables
* (100755) and symlinks (120000) round-trip correctly. */
async function createBlobEntry(params: {
token: string;
repoPath: string;
repoRoot: string;
path: string;
}): Promise<{ path: string; mode: string; type: "blob"; sha: string }> {
const absPath = join(params.repoRoot, params.path);
const stat = await lstat(absPath);
if (stat.size > MAX_BLOB_BYTES) {
throw new Error(
`'${params.path}' is ${Math.round(stat.size / 1024 / 1024)}MB — too large for signed commits (the GitHub blob API rejects large uploads). use git-lfs for large assets or ask the user to disable signed commits for this repo.`
);
}
let mode: string;
let content: Buffer;
if (stat.isSymbolicLink()) {
mode = "120000";
// read the link target as raw bytes — a UTF-8 round-trip would corrupt
// non-UTF-8 targets and break the byte-exact guarantee
content = await readlink(absPath, { encoding: "buffer" });
} else {
mode = stat.mode & 0o100 ? "100755" : "100644";
const cleanSha = $("git", ["hash-object", "-w", "--", absPath], { log: false }).trim();
content = execFileSync("git", ["cat-file", "blob", cleanSha], {
cwd: params.repoRoot,
maxBuffer: 2 * MAX_BLOB_BYTES,
});
}
const path = `${params.repoPath}/git/blobs`;
let result: { status: number; json: unknown } | undefined;
for (const delayMs of [0, 1000, 3000]) {
if (delayMs > 0) await new Promise((r) => setTimeout(r, delayMs));
result = await gh({
token: params.token,
method: "POST",
path,
body: { content: content.toString("base64"), encoding: "base64" },
});
if (result.status === 201) {
const blob = result.json as { sha: string };
return { path: params.path, mode, type: "blob", sha: blob.sha };
}
if (!isRetryable(result.status)) break;
log.info(`blob upload for ${params.path} got ${result.status}, retrying`);
}
if (!result) throw new Error(`POST ${path} failed`);
throw ghError("POST", path, result);
}
/** intermittent 403s on ref updates ("Resource not accessible by
* integration") are a known transient GitHub API failure mode — observed in
* production by claude-code-action. retry those, 429, and 5xx with backoff. */
async function updateRefWithRetry(params: {
token: string;
repoPath: string;
remoteBranch: string;
sha: string;
}): Promise<void> {
const path = `${params.repoPath}/git/refs/heads/${encodeBranchPath(params.remoteBranch)}`;
let lastResult: { status: number; json: unknown } | undefined;
for (const delayMs of [0, 1000, 3000]) {
if (delayMs > 0) await new Promise((r) => setTimeout(r, delayMs));
const result = await gh({
token: params.token,
method: "PATCH",
path,
body: { sha: params.sha, force: false },
});
if (result.status === 200) return;
// 422 covers more than non-fast-forward (deleted ref, ruleset
// rejections) — only emit the concurrent-push recovery when the body
// says so; otherwise surface the raw error.
if (result.status === 422) {
const detail = JSON.stringify(result.json);
if (/fast.forward/i.test(detail)) {
throw new Error(
`the remote branch '${params.remoteBranch}' moved while committing (concurrent push). ` +
`fetch it with git_fetch, integrate with git merge --no-commit origin/${params.remoteBranch}, ` +
`resolve any conflicts, git add the results, then retry commit_changes.`
);
}
throw ghError("PATCH", path, result);
}
lastResult = result;
if (!isRetryable(result.status)) break;
log.info(`ref update got ${result.status}, retrying`);
}
if (lastResult) throw ghError("PATCH", path, lastResult);
throw new Error(`PATCH ${path} failed`);
}
/** branch names land inside REST URL paths. reject anything git's own
* check-ref-format forbids that could also confuse a URL or smuggle a
* traversal, then percent-encode per segment (slashes stay literal). */
function encodeBranchPath(branch: string): string {
return branch.split("/").map(encodeURIComponent).join("/");
}
function validateRemoteBranch(branch: string): void {
// SECURITY: a tampered branch config could otherwise retarget the REST
// calls (e.g. heads/x/../../tags/v1 PATCHes a tag, bypassing the
// push_tags permission gate).
const bad =
branch.startsWith("-") ||
branch.startsWith("/") ||
branch.endsWith("/") ||
branch.includes("..") ||
branch.includes("@{") ||
/[\s~^:?*[\]\\]/.test(branch);
if (bad) throw new Error(`invalid remote branch name '${branch}'`);
}
/**
* create one GitHub-signed commit on `remoteBranch` containing `files` read
* from the working tree, parented on `parents` (first parent supplies the
* base tree; a second parent — MERGE_HEAD — makes it a true merge commit so
* base-branch integration doesn't pollute the PR diff). empty `files` with
* two parents concludes a merge that resolved to the first parent's tree.
* creates the remote branch at the new commit when it doesn't exist yet.
*/
export async function createSignedCommit(params: {
token: string;
owner: string;
repo: string;
remoteBranch: string;
message: string;
parents: string[];
files: ChangedFile[];
}): Promise<{ sha: string; createdBranch: boolean }> {
validateRemoteBranch(params.remoteBranch);
const repoPath = `/repos/${params.owner}/${params.repo}`;
const branchPath = `${repoPath}/git/ref/heads/${encodeBranchPath(params.remoteBranch)}`;
// freshness: when the branch exists remotely, its tip must be one of the
// new commit's parents — otherwise we'd build on a stale base and the
// non-fast-forward ref update below would reject anyway. failing here
// gives the agent targeted recovery guidance before any upload work.
const refResult = await gh({ token: params.token, method: "GET", path: branchPath });
const branchExists = refResult.status === 200;
if (branchExists) {
const ref = refResult.json as { object: { sha: string } };
const remoteTip = ref.object.sha;
if (!params.parents.includes(remoteTip)) {
throw new Error(
isAncestorOfHead(remoteTip)
? `your local branch has commits that were never pushed — signed-commits mode can't push local commits. ` +
`run git reset --mixed ${remoteTip} (keeps every change in the working tree), then retry commit_changes.`
: `the remote branch '${params.remoteBranch}' has commits you don't have locally (tip ${remoteTip.slice(0, 7)}). ` +
`fetch it with git_fetch, integrate with git merge --no-commit origin/${params.remoteBranch}, ` +
`resolve any conflicts, git add the results, then retry commit_changes.`
);
}
} else if (refResult.status !== 404) {
throw ghError("GET", branchPath, refResult);
}
const baseParent = params.parents[0];
if (!baseParent) throw new Error("createSignedCommit requires at least one parent");
const baseTree = $("git", ["rev-parse", `${baseParent}^{tree}`], { log: false }).trim();
let treeSha = baseTree;
if (params.files.length > 0) {
const repoRoot = getRepoRoot();
const additions = params.files.filter((f) => !f.deleted);
const blobEntries: Awaited<ReturnType<typeof createBlobEntry>>[] = [];
for (let i = 0; i < additions.length; i += BLOB_UPLOAD_CONCURRENCY) {
const chunk = additions.slice(i, i + BLOB_UPLOAD_CONCURRENCY);
blobEntries.push(
...(await Promise.all(
chunk.map((f) =>
createBlobEntry({ token: params.token, repoPath, repoRoot, path: f.path })
)
))
);
}
const deletionEntries = params.files
.filter((f) => f.deleted)
.map((f) => ({ path: f.path, mode: "100644", type: "blob" as const, sha: null }));
const treeResult = await gh({
token: params.token,
method: "POST",
path: `${repoPath}/git/trees`,
body: { base_tree: baseTree, tree: [...blobEntries, ...deletionEntries] },
});
if (treeResult.status !== 201) {
throw wrapUnknownBaseError("POST", `${repoPath}/git/trees`, treeResult, params.remoteBranch);
}
treeSha = (treeResult.json as { sha: string }).sha;
}
// no author/committer fields: that's the documented condition for GitHub
// to sign the commit server-side as the app (author = pullfrog[bot]).
const commitResult = await gh({
token: params.token,
method: "POST",
path: `${repoPath}/git/commits`,
body: { message: params.message, tree: treeSha, parents: params.parents },
});
if (commitResult.status !== 201) {
throw wrapUnknownBaseError(
"POST",
`${repoPath}/git/commits`,
commitResult,
params.remoteBranch
);
}
const commit = commitResult.json as { sha: string };
if (branchExists) {
await updateRefWithRetry({
token: params.token,
repoPath,
remoteBranch: params.remoteBranch,
sha: commit.sha,
});
} else {
const createResult = await gh({
token: params.token,
method: "POST",
path: `${repoPath}/git/refs`,
body: { ref: `refs/heads/${params.remoteBranch}`, sha: commit.sha },
});
if (createResult.status !== 201) {
throw ghError("POST", `${repoPath}/git/refs`, createResult);
}
}
return { sha: commit.sha, createdBranch: !branchExists };
}
function isAncestorOfHead(sha: string): boolean {
try {
$("git", ["merge-base", "--is-ancestor", sha, "HEAD"], { log: false });
return true;
} catch {
return false;
}
}
/** tree/commit creation 404/422s usually mean the base tree or parent isn't
* on the remote — i.e. the agent created local commits, which this mode
* can't push. attach the recovery instead of a bare API error. */
function wrapUnknownBaseError(
method: string,
path: string,
result: { status: number; json: unknown },
remoteBranch: string
): Error {
if (result.status === 404 || result.status === 422) {
return new Error(
`${ghError(method, path, result).message}\n\n` +
`this usually means your local branch has commits that were never pushed — signed-commits mode can't push local commits. ` +
`run git reset --mixed origin/${remoteBranch} (or the commit you branched from; this keeps every change in the working tree), then retry commit_changes.`
);
}
return ghError(method, path, result);
}