CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
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 | /**
* Git Smart HTTP routes.
*
* Mounted at /:owner/:repo.git/ — handles clone, fetch, push.
*/
import { Hono } from "hono";
import { getInfoRefs, serviceRpc } from "../git/protocol";
import { repoExists } from "../git/repository";
import { onPostReceive } from "../hooks/post-receive";
import { invalidateRepoCache } from "../lib/cache";
import { trackByName } from "../lib/traffic";
const git = new Hono();
function parseGitParams(c: { req: { param: () => Record<string, string> } }) {
const params = c.req.param() as Record<string, string>;
const owner = params["owner"];
const repo = (params["repo.git"] || params["repo"] || "").replace(/\.git$/, "");
return { owner, repo };
}
// Discovery: GET /:owner/:repo.git/info/refs?service=...
git.get("/:owner/:repo.git/info/refs", async (c) => {
const { owner, repo } = parseGitParams(c);
const service = c.req.query("service");
if (!service || !["git-upload-pack", "git-receive-pack"].includes(service)) {
return c.text("Invalid service", 400);
}
if (!(await repoExists(owner, repo))) {
return c.text("Repository not found", 404);
}
return getInfoRefs(owner, repo, service);
});
// GET /:owner/:repo.git/HEAD
git.get("/:owner/:repo.git/HEAD", async (c) => {
const { owner, repo } = parseGitParams(c);
if (!(await repoExists(owner, repo))) {
return c.text("Repository not found", 404);
}
const path = `repos/${owner}/${repo}.git/HEAD`;
const file = Bun.file(path);
if (!(await file.exists())) return c.text("Not found", 404);
return c.text(await file.text());
});
// Upload pack (clone/fetch)
git.post("/:owner/:repo.git/git-upload-pack", async (c) => {
const { owner, repo } = parseGitParams(c);
if (!(await repoExists(owner, repo))) {
return c.text("Repository not found", 404);
}
// F1 — fire-and-forget clone tracking.
trackByName(owner, repo, "clone", {
ip: c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || null,
userAgent: c.req.header("user-agent") || null,
}).catch(() => {});
return serviceRpc(owner, repo, "git-upload-pack", c.req.raw.body);
});
// Receive pack (push)
git.post("/:owner/:repo.git/git-receive-pack", async (c) => {
const { owner, repo } = parseGitParams(c);
if (!(await repoExists(owner, repo))) {
return c.text("Repository not found", 404);
}
// Parse the incoming refs from the request body before passing to git
const bodyBuffer = await c.req.arrayBuffer();
const response = await serviceRpc(
owner,
repo,
"git-receive-pack",
bodyBuffer
);
// Invalidate cached git data for this repo immediately
invalidateRepoCache(owner, repo);
// Fire post-receive hooks asynchronously (don't block response)
// We parse updated refs from the pkt-line protocol in the request
const refs = parseReceivePackRefs(new Uint8Array(bodyBuffer));
if (refs.length > 0) {
onPostReceive(owner, repo, refs).catch((err) =>
console.error("[post-receive] hook error:", err)
);
}
return response;
});
/**
* Parse ref updates from git-receive-pack request body.
* Format: <old-sha> <new-sha> <ref-name>
*/
function parseReceivePackRefs(
data: Uint8Array
): Array<{ oldSha: string; newSha: string; refName: string }> {
const text = new TextDecoder().decode(data);
const refs: Array<{ oldSha: string; newSha: string; refName: string }> = [];
// Pkt-line format: 4-hex-length followed by data
let offset = 0;
while (offset < text.length) {
const lenHex = text.slice(offset, offset + 4);
const len = parseInt(lenHex, 16);
if (len === 0) {
offset += 4;
break; // flush packet
}
if (len < 4) break;
const line = text.slice(offset + 4, offset + len);
offset += len;
// Match: <old-sha> <new-sha> <ref-name>\0<capabilities>
// or: <old-sha> <new-sha> <ref-name>
const match = line.match(
/^([0-9a-f]{40}) ([0-9a-f]{40}) ([^\0\n]+)/
);
if (match) {
refs.push({
oldSha: match[1],
newSha: match[2],
refName: match[3].trim(),
});
}
}
return refs;
}
export default git;
|