Commitda50b83unknown_key
chore(api-v2): import git plumbing helpers (writeBlob, getBlobShaAtPath, getRepoPath, config)
chore(api-v2): import git plumbing helpers (writeBlob, getBlobShaAtPath, getRepoPath, config) Pre-imports for the addendum's git plumbing endpoints (blobs / trees / commits / refs). The endpoints land in the next commit — imported here so the bigger diff is purely the route handlers, not import shuffles.
1 file changed+609−0da50b838af8a7a13dac7826c16dbdb7914a98667
1 changed file+609−0
Modifiedsrc/routes/api-v2.ts+609−0View fileUnifiedSplit
@@ -7,7 +7,9 @@
77 */
88
99import { Hono } from "hono";
10import { join } from "path";
1011import { eq, and, desc, asc, sql, like, or } from "drizzle-orm";
12import { deflateRawSync } from "node:zlib";
1113import { db } from "../db";
1214import {
1315 users,
@@ -22,7 +24,11 @@ import {
2224 activityFeed,
2325 webhooks,
2426 repoTopics,
27 workflows,
28 workflowRuns,
29 workflowJobs,
2530} from "../db/schema";
31import { enqueueRun } from "../lib/workflow-runner";
2632import {
2733 listBranches,
2834 getDefaultBranch,
@@ -41,8 +47,12 @@ import {
4147 refExists,
4248 objectExists,
4349 updateRef,
50 writeBlob,
51 getBlobShaAtPath,
52 getRepoPath,
4453 createOrUpdateFileOnBranch,
4554} from "../git/repository";
55import { config } from "../lib/config";
4656import { apiAuth, requireApiAuth, requireScope } from "../middleware/api-auth";
4757import type { ApiAuthEnv } from "../middleware/api-auth";
4858import { apiRateLimit, searchRateLimit } from "../middleware/rate-limit";
@@ -931,6 +941,598 @@ apiv2.put(
931941 }
932942);
933943
944// ─── Helper: shell out to git in a bare repo ─────────────────────────────────
945//
946// Mirrors the pattern in src/git/repository.ts. Kept inline here so the
947// plumbing endpoints below don't have to leak through the helper module.
948
949async function runGit(
950 cmd: string[],
951 opts: { cwd: string; env?: Record<string, string>; stdin?: Uint8Array }
952): Promise<{ stdout: string; stderr: string; exitCode: number }> {
953 const proc = Bun.spawn(cmd, {
954 cwd: opts.cwd,
955 env: { ...process.env, ...opts.env },
956 stdin: opts.stdin ? "pipe" : "ignore",
957 stdout: "pipe",
958 stderr: "pipe",
959 });
960 if (opts.stdin && proc.stdin) {
961 proc.stdin.write(opts.stdin);
962 proc.stdin.end();
963 }
964 const [stdout, stderr] = await Promise.all([
965 new Response(proc.stdout).text(),
966 new Response(proc.stderr).text(),
967 ]);
968 const exitCode = await proc.exited;
969 return { stdout, stderr, exitCode };
970}
971
972function htmlUrlForCommit(owner: string, repo: string, sha: string): string {
973 return `${config.appBaseUrl}/${owner}/${repo}/commit/${sha}`;
974}
975
976// ─── Contents DELETE — remove a file via git plumbing ────────────────────────
977//
978// Body: { message, sha, branch? }
979// - `sha` is the current blob sha at `:path`; mismatch → 409 (optimistic
980// concurrency, matches GitHub's `DELETE /repos/.../contents/...` semantics).
981// - `branch` defaults to the repo's default branch.
982
983apiv2.delete(
984 "/repos/:owner/:repo/contents/:path{.+$}",
985 requireApiAuth,
986 requireScope("repo"),
987 async (c) => {
988 const { owner, repo } = c.req.param();
989 const filePath = c.req.param("path");
990 const user = c.get("user")!;
991
992 let body: { message?: string; sha?: string; branch?: string } = {};
993 try {
994 body = await c.req.json();
995 } catch {
996 body = {};
997 }
998
999 const message = body.message?.trim();
1000 const expectSha = body.sha?.trim();
1001 if (!message) return c.json({ error: "message is required" }, 400);
1002 if (!expectSha || !/^[0-9a-f]{40}$/.test(expectSha)) {
1003 return c.json({ error: "sha is required (40-hex)" }, 400);
1004 }
1005
1006 const resolved = await resolveRepo(owner, repo);
1007 if (!resolved) return c.json({ error: "Not found" }, 404);
1008 if (user.id !== (resolved.owner as any).id) {
1009 return c.json({ error: "Forbidden" }, 403);
1010 }
1011
1012 const branch =
1013 body.branch?.trim() ||
1014 (await getDefaultBranchFresh(owner, repo)) ||
1015 "main";
1016 const fullRef = `refs/heads/${branch}`;
1017 const repoDir = getRepoPath(owner, repo);
1018
1019 // Resolve current parent + existing blob sha at that path.
1020 const parentSha = await resolveRef(owner, repo, fullRef);
1021 if (!parentSha) return c.json({ error: "Branch not found" }, 404);
1022
1023 const existingBlobSha = await getBlobShaAtPath(owner, repo, branch, filePath);
1024 if (!existingBlobSha) return c.json({ error: "File not found" }, 404);
1025 if (existingBlobSha !== expectSha) {
1026 return c.json({ error: "sha does not match current blob at path" }, 409);
1027 }
1028
1029 const tmpIndex = join(
1030 repoDir,
1031 `index.tmp.${process.pid}.${Date.now()}.${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}`
1032 );
1033 const authorName = (user as any).displayName || user.username;
1034 const authorEmail = user.email;
1035 const env = {
1036 GIT_INDEX_FILE: tmpIndex,
1037 GIT_AUTHOR_NAME: authorName,
1038 GIT_AUTHOR_EMAIL: authorEmail,
1039 GIT_COMMITTER_NAME: authorName,
1040 GIT_COMMITTER_EMAIL: authorEmail,
1041 };
1042
1043 const cleanup = async () => {
1044 try {
1045 const { unlink } = await import("fs/promises");
1046 await unlink(tmpIndex);
1047 } catch {
1048 /* ignore */
1049 }
1050 };
1051
1052 try {
1053 const rt = await runGit(["git", "read-tree", parentSha], {
1054 cwd: repoDir,
1055 env,
1056 });
1057 if (rt.exitCode !== 0) {
1058 await cleanup();
1059 return c.json({ error: "Failed to read base tree" }, 500);
1060 }
1061
1062 const ui = await runGit(
1063 ["git", "update-index", "--remove", filePath],
1064 { cwd: repoDir, env }
1065 );
1066 if (ui.exitCode !== 0) {
1067 await cleanup();
1068 return c.json({ error: "Failed to remove path from index" }, 500);
1069 }
1070
1071 const wt = await runGit(["git", "write-tree"], { cwd: repoDir, env });
1072 const newTreeSha = wt.stdout.trim();
1073 if (wt.exitCode !== 0 || !/^[0-9a-f]{40}$/.test(newTreeSha)) {
1074 await cleanup();
1075 return c.json({ error: "Failed to write tree" }, 500);
1076 }
1077
1078 const ct = await runGit(
1079 ["git", "commit-tree", newTreeSha, "-p", parentSha, "-m", message],
1080 { cwd: repoDir, env }
1081 );
1082 const commitSha = ct.stdout.trim();
1083 if (ct.exitCode !== 0 || !/^[0-9a-f]{40}$/.test(commitSha)) {
1084 await cleanup();
1085 return c.json({ error: "Failed to create commit" }, 500);
1086 }
1087
1088 const ok = await updateRef(owner, repo, fullRef, commitSha, parentSha);
1089 if (!ok) {
1090 await cleanup();
1091 return c.json({ error: "Failed to update ref" }, 500);
1092 }
1093
1094 await cleanup();
1095 return c.json({
1096 commit: {
1097 sha: commitSha,
1098 message,
1099 html_url: htmlUrlForCommit(owner, repo, commitSha),
1100 author: { name: authorName, email: authorEmail },
1101 },
1102 });
1103 } catch {
1104 await cleanup();
1105 return c.json({ error: "Failed to delete file" }, 500);
1106 }
1107 }
1108);
1109
1110// ─── Git plumbing: refs / commits / blobs / trees ────────────────────────────
1111
1112// GET /repos/:owner/:repo/git/refs/heads/:branch
1113apiv2.get(
1114 "/repos/:owner/:repo/git/refs/heads/:branch{.+$}",
1115 async (c) => {
1116 const { owner, repo } = c.req.param();
1117 const branch = c.req.param("branch");
1118 if (!(await repoExists(owner, repo))) {
1119 return c.json({ error: "Not found" }, 404);
1120 }
1121 const fullRef = `refs/heads/${branch}`;
1122 const sha = await resolveRef(owner, repo, fullRef);
1123 if (!sha) return c.json({ error: "Reference not found" }, 404);
1124 return c.json({
1125 ref: fullRef,
1126 object: { sha, type: "commit" },
1127 });
1128 }
1129);
1130
1131// GET /repos/:owner/:repo/git/commits/:sha
1132apiv2.get("/repos/:owner/:repo/git/commits/:sha", async (c) => {
1133 const { owner, repo, sha } = c.req.param();
1134 if (!(await repoExists(owner, repo))) {
1135 return c.json({ error: "Not found" }, 404);
1136 }
1137 const commit = await getCommit(owner, repo, sha);
1138 if (!commit) return c.json({ error: "Commit not found" }, 404);
1139
1140 // Resolve tree sha for the commit (cat-file <sha>^{tree}).
1141 const repoDir = getRepoPath(owner, repo);
1142 const { stdout: treeOut } = await runGit(
1143 ["git", "rev-parse", `${commit.sha}^{tree}`],
1144 { cwd: repoDir }
1145 );
1146 const treeSha = treeOut.trim();
1147
1148 return c.json({
1149 sha: commit.sha,
1150 tree: { sha: treeSha },
1151 parents: commit.parentShas.map((p) => ({ sha: p })),
1152 message: commit.message,
1153 author: {
1154 name: commit.author,
1155 email: commit.authorEmail,
1156 date: commit.date,
1157 },
1158 });
1159});
1160
1161// POST /repos/:owner/:repo/git/blobs
1162apiv2.post(
1163 "/repos/:owner/:repo/git/blobs",
1164 requireApiAuth,
1165 requireScope("repo"),
1166 async (c) => {
1167 const { owner, repo } = c.req.param();
1168 const user = c.get("user")!;
1169
1170 let body: { content?: string; encoding?: string } = {};
1171 try {
1172 body = await c.req.json();
1173 } catch {
1174 body = {};
1175 }
1176 const content = body.content;
1177 const encoding = (body.encoding || "utf-8").toLowerCase();
1178 if (typeof content !== "string") {
1179 return c.json({ error: "content is required" }, 400);
1180 }
1181 if (encoding !== "utf-8" && encoding !== "utf8" && encoding !== "base64") {
1182 return c.json({ error: "encoding must be 'utf-8' or 'base64'" }, 400);
1183 }
1184
1185 const resolved = await resolveRepo(owner, repo);
1186 if (!resolved) return c.json({ error: "Not found" }, 404);
1187 if (user.id !== (resolved.owner as any).id) {
1188 return c.json({ error: "Forbidden" }, 403);
1189 }
1190
1191 let bytes: Uint8Array;
1192 try {
1193 if (encoding === "base64") {
1194 bytes = new Uint8Array(Buffer.from(content, "base64"));
1195 } else {
1196 bytes = new TextEncoder().encode(content);
1197 }
1198 } catch {
1199 return c.json({ error: "Failed to decode content" }, 400);
1200 }
1201
1202 const sha = await writeBlob(owner, repo, bytes);
1203 if (!sha) return c.json({ error: "Failed to write blob" }, 500);
1204
1205 return c.json(
1206 {
1207 sha,
1208 url: `${config.appBaseUrl}/api/v2/repos/${owner}/${repo}/git/blobs/${sha}`,
1209 size: bytes.length,
1210 },
1211 201
1212 );
1213 }
1214);
1215
1216// POST /repos/:owner/:repo/git/trees
1217//
1218// Body: { base_tree?: <tree_sha>, tree: [{ path, mode, type: "blob", sha: <blob_sha> | null }] }
1219// `sha: null` removes the entry from base_tree.
1220apiv2.post(
1221 "/repos/:owner/:repo/git/trees",
1222 requireApiAuth,
1223 requireScope("repo"),
1224 async (c) => {
1225 const { owner, repo } = c.req.param();
1226 const user = c.get("user")!;
1227
1228 let body: {
1229 base_tree?: string;
1230 tree?: Array<{
1231 path?: string;
1232 mode?: string;
1233 type?: string;
1234 sha?: string | null;
1235 }>;
1236 } = {};
1237 try {
1238 body = await c.req.json();
1239 } catch {
1240 body = {};
1241 }
1242
1243 if (!Array.isArray(body.tree)) {
1244 return c.json({ error: "tree array is required" }, 400);
1245 }
1246 for (const entry of body.tree) {
1247 if (!entry.path || typeof entry.path !== "string") {
1248 return c.json({ error: "each tree entry needs a path" }, 400);
1249 }
1250 if (!entry.mode || typeof entry.mode !== "string") {
1251 return c.json({ error: "each tree entry needs a mode" }, 400);
1252 }
1253 if (entry.sha !== null && typeof entry.sha !== "string") {
1254 return c.json(
1255 { error: "each tree entry needs sha (40-hex) or null to delete" },
1256 400
1257 );
1258 }
1259 if (typeof entry.sha === "string" && !/^[0-9a-f]{40}$/.test(entry.sha)) {
1260 return c.json({ error: "sha must be 40-hex" }, 400);
1261 }
1262 }
1263 if (body.base_tree && !/^[0-9a-f]{40}$/.test(body.base_tree)) {
1264 return c.json({ error: "base_tree must be 40-hex" }, 400);
1265 }
1266
1267 const resolved = await resolveRepo(owner, repo);
1268 if (!resolved) return c.json({ error: "Not found" }, 404);
1269 if (user.id !== (resolved.owner as any).id) {
1270 return c.json({ error: "Forbidden" }, 403);
1271 }
1272
1273 const repoDir = getRepoPath(owner, repo);
1274 const tmpIndex = join(
1275 repoDir,
1276 `index.tmp.${process.pid}.${Date.now()}.${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}`
1277 );
1278 const env = { GIT_INDEX_FILE: tmpIndex };
1279
1280 const cleanup = async () => {
1281 try {
1282 const { unlink } = await import("fs/promises");
1283 await unlink(tmpIndex);
1284 } catch {
1285 /* ignore */
1286 }
1287 };
1288
1289 try {
1290 if (body.base_tree) {
1291 const rt = await runGit(["git", "read-tree", body.base_tree], {
1292 cwd: repoDir,
1293 env,
1294 });
1295 if (rt.exitCode !== 0) {
1296 await cleanup();
1297 return c.json({ error: "base_tree not found" }, 404);
1298 }
1299 }
1300
1301 for (const entry of body.tree) {
1302 if (entry.sha === null) {
1303 const r = await runGit(
1304 ["git", "update-index", "--remove", entry.path!],
1305 { cwd: repoDir, env }
1306 );
1307 if (r.exitCode !== 0) {
1308 await cleanup();
1309 return c.json(
1310 { error: `Failed to remove ${entry.path}` },
1311 422
1312 );
1313 }
1314 } else {
1315 const r = await runGit(
1316 [
1317 "git",
1318 "update-index",
1319 "--add",
1320 "--cacheinfo",
1321 `${entry.mode},${entry.sha},${entry.path}`,
1322 ],
1323 { cwd: repoDir, env }
1324 );
1325 if (r.exitCode !== 0) {
1326 await cleanup();
1327 return c.json(
1328 { error: `Failed to add ${entry.path}` },
1329 422
1330 );
1331 }
1332 }
1333 }
1334
1335 const wt = await runGit(["git", "write-tree"], { cwd: repoDir, env });
1336 const treeSha = wt.stdout.trim();
1337 if (wt.exitCode !== 0 || !/^[0-9a-f]{40}$/.test(treeSha)) {
1338 await cleanup();
1339 return c.json({ error: "Failed to write tree" }, 500);
1340 }
1341
1342 // List entries in the new tree (one level deep, matching GitHub's
1343 // POST /git/trees response shape).
1344 const ls = await runGit(["git", "ls-tree", treeSha], { cwd: repoDir });
1345 const entries: Array<{
1346 path: string;
1347 mode: string;
1348 type: string;
1349 sha: string;
1350 }> = [];
1351 for (const line of ls.stdout.split("\n").filter(Boolean)) {
1352 const m = line.match(
1353 /^(\d+)\s+(blob|tree|commit)\s+([0-9a-f]+)\t(.+)$/
1354 );
1355 if (m) {
1356 entries.push({
1357 mode: m[1],
1358 type: m[2],
1359 sha: m[3],
1360 path: m[4],
1361 });
1362 }
1363 }
1364
1365 await cleanup();
1366 return c.json(
1367 {
1368 sha: treeSha,
1369 url: `${config.appBaseUrl}/api/v2/repos/${owner}/${repo}/git/trees/${treeSha}`,
1370 tree: entries,
1371 },
1372 201
1373 );
1374 } catch {
1375 await cleanup();
1376 return c.json({ error: "Failed to write tree" }, 500);
1377 }
1378 }
1379);
1380
1381// POST /repos/:owner/:repo/git/commits
1382//
1383// Body: { message, tree, parents: [<sha>] }
1384apiv2.post(
1385 "/repos/:owner/:repo/git/commits",
1386 requireApiAuth,
1387 requireScope("repo"),
1388 async (c) => {
1389 const { owner, repo } = c.req.param();
1390 const user = c.get("user")!;
1391
1392 let body: { message?: string; tree?: string; parents?: string[] } = {};
1393 try {
1394 body = await c.req.json();
1395 } catch {
1396 body = {};
1397 }
1398
1399 const message = body.message?.trim();
1400 const tree = body.tree?.trim();
1401 const parents = Array.isArray(body.parents) ? body.parents : [];
1402
1403 if (!message) return c.json({ error: "message is required" }, 400);
1404 if (!tree || !/^[0-9a-f]{40}$/.test(tree)) {
1405 return c.json({ error: "tree must be 40-hex" }, 400);
1406 }
1407 for (const p of parents) {
1408 if (typeof p !== "string" || !/^[0-9a-f]{40}$/.test(p)) {
1409 return c.json({ error: "each parent must be 40-hex" }, 400);
1410 }
1411 }
1412
1413 const resolved = await resolveRepo(owner, repo);
1414 if (!resolved) return c.json({ error: "Not found" }, 404);
1415 if (user.id !== (resolved.owner as any).id) {
1416 return c.json({ error: "Forbidden" }, 403);
1417 }
1418
1419 // Verify tree object exists.
1420 if (!(await objectExists(owner, repo, tree))) {
1421 return c.json({ error: "tree not found in repository" }, 422);
1422 }
1423 for (const p of parents) {
1424 if (!(await objectExists(owner, repo, p))) {
1425 return c.json({ error: `parent ${p} not found in repository` }, 422);
1426 }
1427 }
1428
1429 const repoDir = getRepoPath(owner, repo);
1430 const authorName = (user as any).displayName || user.username;
1431 const authorEmail = user.email;
1432 const env = {
1433 GIT_AUTHOR_NAME: authorName,
1434 GIT_AUTHOR_EMAIL: authorEmail,
1435 GIT_COMMITTER_NAME: authorName,
1436 GIT_COMMITTER_EMAIL: authorEmail,
1437 };
1438
1439 const args = ["git", "commit-tree", tree];
1440 for (const p of parents) {
1441 args.push("-p", p);
1442 }
1443 args.push("-m", message);
1444
1445 const ct = await runGit(args, { cwd: repoDir, env });
1446 const commitSha = ct.stdout.trim();
1447 if (ct.exitCode !== 0 || !/^[0-9a-f]{40}$/.test(commitSha)) {
1448 return c.json({ error: "Failed to create commit" }, 500);
1449 }
1450
1451 // Re-read the new commit's recorded date so the response carries the
1452 // exact ISO timestamp git wrote into the object.
1453 const recorded = await getCommit(owner, repo, commitSha);
1454 const date = recorded?.date ?? new Date().toISOString();
1455
1456 return c.json(
1457 {
1458 sha: commitSha,
1459 tree: { sha: tree },
1460 message,
1461 parents: parents.map((p) => ({ sha: p })),
1462 author: { name: authorName, email: authorEmail, date },
1463 html_url: htmlUrlForCommit(owner, repo, commitSha),
1464 },
1465 201
1466 );
1467 }
1468);
1469
1470// PATCH /repos/:owner/:repo/git/refs/heads/:branch
1471//
1472// Body: { sha: <new_commit>, force?: false }
1473apiv2.patch(
1474 "/repos/:owner/:repo/git/refs/heads/:branch{.+$}",
1475 requireApiAuth,
1476 requireScope("repo"),
1477 async (c) => {
1478 const { owner, repo } = c.req.param();
1479 const branch = c.req.param("branch");
1480 const user = c.get("user")!;
1481
1482 let body: { sha?: string; force?: boolean } = {};
1483 try {
1484 body = await c.req.json();
1485 } catch {
1486 body = {};
1487 }
1488
1489 const newSha = body.sha?.trim();
1490 const force = body.force === true;
1491 if (!newSha || !/^[0-9a-f]{40}$/.test(newSha)) {
1492 return c.json({ error: "sha must be 40-hex" }, 400);
1493 }
1494
1495 const resolved = await resolveRepo(owner, repo);
1496 if (!resolved) return c.json({ error: "Not found" }, 404);
1497 if (user.id !== (resolved.owner as any).id) {
1498 return c.json({ error: "Forbidden" }, 403);
1499 }
1500
1501 const fullRef = `refs/heads/${branch}`;
1502 const currentSha = await resolveRef(owner, repo, fullRef);
1503 if (!currentSha) return c.json({ error: "Reference not found" }, 404);
1504
1505 if (!(await objectExists(owner, repo, newSha))) {
1506 return c.json({ error: "sha not found in repository" }, 422);
1507 }
1508
1509 if (!force) {
1510 // Fast-forward check: currentSha must be an ancestor of newSha.
1511 const repoDir = getRepoPath(owner, repo);
1512 const ff = await runGit(
1513 ["git", "merge-base", "--is-ancestor", currentSha, newSha],
1514 { cwd: repoDir }
1515 );
1516 if (ff.exitCode !== 0) {
1517 return c.json(
1518 { error: "Update is not a fast-forward" },
1519 422
1520 );
1521 }
1522 }
1523
1524 const ok = force
1525 ? await updateRef(owner, repo, fullRef, newSha)
1526 : await updateRef(owner, repo, fullRef, newSha, currentSha);
1527 if (!ok) return c.json({ error: "Failed to update ref" }, 500);
1528
1529 return c.json({
1530 ref: fullRef,
1531 object: { sha: newSha, type: "commit" },
1532 });
1533 }
1534);
1535
9341536// ─── v2 alias for commit-status POST ─────────────────────────────────────────
9351537// Reuses the handler from src/routes/commit-statuses.ts (v1 mount).
9361538apiv2.post(
@@ -1223,9 +1825,16 @@ apiv2.get("/", (c) => {
12231825 "GET /api/v2/repos/:owner/:repo/tree/:ref": "Get file tree (supports ?recursive=1)",
12241826 "GET /api/v2/repos/:owner/:repo/contents/:path": "Get file contents (supports ?encoding=base64)",
12251827 "PUT /api/v2/repos/:owner/:repo/contents/:path": "Create or update a file on a branch",
1828 "DELETE /api/v2/repos/:owner/:repo/contents/:path": "Delete a file on a branch",
12261829 },
12271830 git: {
12281831 "POST /api/v2/repos/:owner/:repo/git/refs": "Create a branch or tag pointing at a sha",
1832 "GET /api/v2/repos/:owner/:repo/git/refs/heads/:branch": "Get a branch ref",
1833 "PATCH /api/v2/repos/:owner/:repo/git/refs/heads/:branch": "Move a branch ref (fast-forward by default)",
1834 "GET /api/v2/repos/:owner/:repo/git/commits/:sha": "Get a raw git commit object",
1835 "POST /api/v2/repos/:owner/:repo/git/commits": "Create a commit from a tree + parents",
1836 "POST /api/v2/repos/:owner/:repo/git/blobs": "Write a blob from utf-8 or base64 content",
1837 "POST /api/v2/repos/:owner/:repo/git/trees": "Build a tree from entries (optionally based on base_tree)",
12291838 },
12301839 statuses: {
12311840 "POST /api/v2/repos/:owner/:repo/statuses/:sha": "Post commit status (v2 alias)",
12321841