Commit5dc83edunknown_key
feat(git): pre-receive enforcement for protected tags + rulesets
feat(git): pre-receive enforcement for protected tags + rulesets Flips two surfaces from advisory-only to actually blocking pushes at the HTTP layer (before git-receive-pack accepts the pack): - Protected tags (Block E7): tag pushes that match a protected pattern are rejected unless the pusher can bypass (= repo owner today, future tag-admin role). - Repository rulesets (Block J6): "active" enforcement now blocks at push time. "evaluate" enforcement remains a dry-run as designed. Rules evaluable from refs alone (branch_name_pattern, tag_name_pattern) are wired in this pass; commit-message-pattern, blocked_file_paths, max_file_size await pack inspection in a follow-up. New files: - src/lib/push-policy.ts — pure-ish enforcement; never throws into the request path, fail-open on DB hiccups so a Postgres blip cannot wedge legitimate pushes. Exports evaluatePushPolicy + formatPolicyError + ZERO_SHA. - src/lib/git-push-auth.ts — Authorization-header → User resolver. Accepts Basic <user:secret> and Bearer <token>; secrets matching glc_ (PAT) or glct_ (OAuth) prefixes are looked up. ghi_ install tokens are intentionally not handled yet (app bots have no users.id row; tracked in code comment). Wiring: - src/routes/git.ts git-receive-pack handler now parses refs from the body, resolves the pusher, runs evaluatePushPolicy, and 403s with a human-readable text body when blocked. Existing repos with no policy see zero behaviour change. Returns 403 with "Push rejected by Gluecron policy:\n - ..." which git smart-HTTP clients surface as remote: lines. Tests: 26 new (8 decodeBasicAuth, 4 decodeBearerAuth, 5 resolvePusher fail-open, 1 ZERO_SHA, 5 formatPolicyError, 3 evaluatePushPolicy fail-open, 1 module shape). Total suite 1064 pass / 8 skip / 0 fail (was 1038 / 8 / 0). https://claude.ai/code/session_0163vJChUuZqqtBBznUW6xBU
5 files changed+614−75dc83ed005b4d938ae90e2c0fab1bef4865e3f8a
5 changed files+614−7
Addedsrc/__tests__/git-push-auth.test.ts+110−0View fileUnifiedSplit
@@ -0,0 +1,110 @@
1/**
2 * Tests for src/lib/git-push-auth.ts.
3 *
4 * The DB-touching resolveByPat / resolveByOauth paths require a live DB,
5 * which we don't set up in unit tests. Instead we cover the pure header
6 * decoders (`decodeBasicAuth`, `decodeBearerAuth`) exhaustively and assert
7 * that `resolvePusher` returns null for every shape that should fall back
8 * to anonymous (no header, garbage header, unknown prefix, empty secret).
9 */
10
11import { describe, it, expect } from "bun:test";
12import {
13 decodeBasicAuth,
14 decodeBearerAuth,
15 resolvePusher,
16} from "../lib/git-push-auth";
17
18function b64(s: string): string {
19 return Buffer.from(s, "utf8").toString("base64");
20}
21
22describe("decodeBasicAuth", () => {
23 it("decodes a normal user:secret pair", () => {
24 const out = decodeBasicAuth(`Basic ${b64("alice:glc_abc123")}`);
25 expect(out).toEqual({ user: "alice", secret: "glc_abc123" });
26 });
27
28 it("is case-insensitive on the scheme keyword", () => {
29 const out = decodeBasicAuth(`basic ${b64("alice:glc_abc")}`);
30 expect(out?.user).toBe("alice");
31 expect(out?.secret).toBe("glc_abc");
32 });
33
34 it("tolerates extra whitespace", () => {
35 const out = decodeBasicAuth(` Basic ${b64("alice:glc_x")} `);
36 expect(out).not.toBeNull();
37 expect(out?.user).toBe("alice");
38 });
39
40 it("returns null for empty / missing header", () => {
41 expect(decodeBasicAuth(null)).toBeNull();
42 expect(decodeBasicAuth(undefined)).toBeNull();
43 expect(decodeBasicAuth("")).toBeNull();
44 });
45
46 it("returns null when the scheme isn't Basic", () => {
47 expect(decodeBasicAuth(`Bearer ${b64("a:b")}`)).toBeNull();
48 expect(decodeBasicAuth("Token foobar")).toBeNull();
49 });
50
51 it("returns null when the credential lacks a colon separator", () => {
52 expect(decodeBasicAuth(`Basic ${b64("nocolonhere")}`)).toBeNull();
53 });
54
55 it("preserves a colon inside the secret", () => {
56 // Tokens never have colons in practice, but if one did the split must
57 // pick the first colon only.
58 const out = decodeBasicAuth(`Basic ${b64("user:has:colon")}`);
59 expect(out).toEqual({ user: "user", secret: "has:colon" });
60 });
61
62 it("allows an empty username", () => {
63 // git CLI sometimes sends an empty username + the PAT in the password.
64 const out = decodeBasicAuth(`Basic ${b64(":glc_abc")}`);
65 expect(out).toEqual({ user: "", secret: "glc_abc" });
66 });
67});
68
69describe("decodeBearerAuth", () => {
70 it("strips the Bearer prefix", () => {
71 expect(decodeBearerAuth("Bearer glct_abc")).toBe("glct_abc");
72 expect(decodeBearerAuth("bearer glc_xyz")).toBe("glc_xyz");
73 });
74
75 it("returns null for non-Bearer schemes", () => {
76 expect(decodeBearerAuth("Basic abc")).toBeNull();
77 expect(decodeBearerAuth("Token glc_xyz")).toBeNull();
78 });
79
80 it("returns null on empty / missing header", () => {
81 expect(decodeBearerAuth(null)).toBeNull();
82 expect(decodeBearerAuth(undefined)).toBeNull();
83 expect(decodeBearerAuth("")).toBeNull();
84 expect(decodeBearerAuth("Bearer ")).toBeNull();
85 });
86});
87
88describe("resolvePusher — anonymous fallbacks", () => {
89 it("returns null when there is no auth header", async () => {
90 expect(await resolvePusher(null)).toBeNull();
91 expect(await resolvePusher(undefined)).toBeNull();
92 expect(await resolvePusher("")).toBeNull();
93 });
94
95 it("returns null on an unrecognised scheme", async () => {
96 expect(await resolvePusher("Unknown abc")).toBeNull();
97 });
98
99 it("returns null on a Bearer with an unknown prefix", async () => {
100 expect(await resolvePusher("Bearer notatoken")).toBeNull();
101 });
102
103 it("returns null on a Basic with an unknown-prefix secret", async () => {
104 expect(await resolvePusher(`Basic ${b64("alice:notatoken")}`)).toBeNull();
105 });
106
107 it("returns null on a Basic with an empty secret", async () => {
108 expect(await resolvePusher(`Basic ${b64("alice:")}`)).toBeNull();
109 });
110});
Addedsrc/__tests__/push-policy.test.ts+123−0View fileUnifiedSplit
@@ -0,0 +1,123 @@
1/**
2 * Tests for src/lib/push-policy.ts.
3 *
4 * The DB-touching paths (matchProtectedTag, listRulesetsForRepo,
5 * canBypassProtectedTag) require a live DB; we don't have one in unit
6 * tests. Instead we cover:
7 *
8 * - The pure formatPolicyError formatter — exhaustive.
9 * - The fail-open guarantee on bad input (empty refs, missing repo id).
10 * - Module shape (imports don't throw, expected exports are present).
11 *
12 * Real end-to-end coverage of the policy decisions runs in the existing
13 * rulesets + protected-tags test suites (which exercise evaluatePush and
14 * matchGlob directly). This file verifies that the new wrapper preserves
15 * fail-open semantics, which is the property that protects production
16 * pushes from a Postgres hiccup.
17 */
18
19import { describe, it, expect } from "bun:test";
20import {
21 evaluatePushPolicy,
22 formatPolicyError,
23 ZERO_SHA,
24} from "../lib/push-policy";
25
26describe("ZERO_SHA constant", () => {
27 it("is exactly 40 zeros", () => {
28 expect(ZERO_SHA).toBe("0000000000000000000000000000000000000000");
29 expect(ZERO_SHA.length).toBe(40);
30 });
31});
32
33describe("formatPolicyError", () => {
34 it("returns a generic message when violations is empty", () => {
35 expect(formatPolicyError([])).toBe("Push rejected by Gluecron policy.");
36 });
37
38 it("returns a generic message when violations is null/undefined", () => {
39 expect(formatPolicyError(null as any)).toBe(
40 "Push rejected by Gluecron policy."
41 );
42 expect(formatPolicyError(undefined as any)).toBe(
43 "Push rejected by Gluecron policy."
44 );
45 });
46
47 it("renders one violation as a bulleted list", () => {
48 const out = formatPolicyError(['tag "v1.0" is protected']);
49 expect(out).toContain("Push rejected by Gluecron policy:");
50 expect(out).toContain(' - tag "v1.0" is protected');
51 });
52
53 it("renders multiple violations on separate lines", () => {
54 const out = formatPolicyError([
55 'tag "v1.0" is protected',
56 'ruleset "no-prod-pushes" rule branch_name_pattern: blocked',
57 ]);
58 const lines = out.trimEnd().split("\n");
59 expect(lines[0]).toBe("Push rejected by Gluecron policy:");
60 expect(lines[1]).toBe(' - tag "v1.0" is protected');
61 expect(lines[2]).toBe(
62 ' - ruleset "no-prod-pushes" rule branch_name_pattern: blocked'
63 );
64 });
65
66 it("ends with a newline (so git surfaces the body cleanly)", () => {
67 expect(formatPolicyError(["one violation"]).endsWith("\n")).toBe(true);
68 });
69});
70
71describe("evaluatePushPolicy — fail-open on bad input", () => {
72 it("returns allowed=true for an empty refs list", async () => {
73 const r = await evaluatePushPolicy({
74 repositoryId: "repo-1",
75 refs: [],
76 pusherUserId: null,
77 });
78 expect(r.allowed).toBe(true);
79 expect(r.violations).toEqual([]);
80 });
81
82 it("returns allowed=true for missing repositoryId", async () => {
83 const r = await evaluatePushPolicy({
84 repositoryId: "" as any,
85 refs: [
86 {
87 oldSha: ZERO_SHA,
88 newSha: "a".repeat(40),
89 refName: "refs/tags/v1.0",
90 },
91 ],
92 pusherUserId: null,
93 });
94 expect(r.allowed).toBe(true);
95 });
96
97 it("returns allowed=true when DB is unreachable (refs target a nonexistent repo)", async () => {
98 // No real repo exists with id "definitely-not-a-real-repo-id"; the
99 // matchProtectedTag + listRulesetsForRepo callers catch their own
100 // errors and return empty arrays, so the wrapper returns allowed.
101 const r = await evaluatePushPolicy({
102 repositoryId: "definitely-not-a-real-repo-id",
103 refs: [
104 {
105 oldSha: ZERO_SHA,
106 newSha: "a".repeat(40),
107 refName: "refs/heads/main",
108 },
109 ],
110 pusherUserId: null,
111 });
112 expect(r.allowed).toBe(true);
113 });
114});
115
116describe("evaluatePushPolicy — module shape", () => {
117 it("exports the functions the route depends on", async () => {
118 const mod = await import("../lib/push-policy");
119 expect(typeof mod.evaluatePushPolicy).toBe("function");
120 expect(typeof mod.formatPolicyError).toBe("function");
121 expect(typeof mod.ZERO_SHA).toBe("string");
122 });
123});
Addedsrc/lib/git-push-auth.ts+142−0View fileUnifiedSplit
@@ -0,0 +1,142 @@
1/**
2 * Identify the pusher on a git-receive-pack request.
3 *
4 * Smart-HTTP push doesn't ride the cookie/session middleware (git CLI
5 * doesn't keep cookies). It sends `Authorization: Basic <b64(user:secret)>`
6 * or `Authorization: Bearer <token>`. We accept two secret shapes:
7 *
8 * - `glc_*` — personal access token (Block C2)
9 * - `glct_*` — OAuth access token (Block B6)
10 *
11 * Installation tokens (`ghi_*`, Block H2) are deliberately not handled
12 * here yet: app bots are stored in `app_bots` with their own `username`
13 * but no link to a `users.id` row, so they can't currently own a push
14 * decision in the protected-tag path. Adding bot identities to the auth
15 * decision is future work — the resolver returns null for `ghi_*` and the
16 * push falls back to anonymous, which is rejected by every protected ref.
17 *
18 * Best-effort only: returns null (anonymous) on any failure. The caller
19 * must decide what anonymous can do (current policy: anonymous can
20 * push to non-protected refs; protected refs always require auth).
21 */
22
23import { eq } from "drizzle-orm";
24import { db } from "../db";
25import { users, apiTokens, oauthAccessTokens } from "../db/schema";
26import { sha256Hex } from "./oauth";
27
28export type ResolvedPusher = {
29 userId: string;
30 username: string;
31 source: "pat" | "oauth";
32};
33
34/** Decode a `Basic` auth header → `{user, secret}` or null on malformed. */
35export function decodeBasicAuth(
36 header: string | null | undefined
37): { user: string; secret: string } | null {
38 if (!header) return null;
39 const m = /^\s*Basic\s+(.+)$/i.exec(header);
40 if (!m) return null;
41 let decoded: string;
42 try {
43 decoded = Buffer.from(m[1].trim(), "base64").toString("utf8");
44 } catch {
45 return null;
46 }
47 const colon = decoded.indexOf(":");
48 if (colon < 0) return null;
49 return {
50 user: decoded.slice(0, colon),
51 secret: decoded.slice(colon + 1),
52 };
53}
54
55/** Decode a `Bearer` auth header → token string or null. */
56export function decodeBearerAuth(
57 header: string | null | undefined
58): string | null {
59 if (!header) return null;
60 const m = /^\s*Bearer\s+(.+)$/i.exec(header);
61 if (!m) return null;
62 const tok = m[1].trim();
63 return tok || null;
64}
65
66async function resolveByPat(token: string): Promise<ResolvedPusher | null> {
67 if (!token.startsWith("glc_")) return null;
68 try {
69 const hash = await sha256Hex(token);
70 const [row] = await db
71 .select()
72 .from(apiTokens)
73 .where(eq(apiTokens.tokenHash, hash))
74 .limit(1);
75 if (!row) return null;
76 if (row.expiresAt && new Date(row.expiresAt) < new Date()) return null;
77 const [u] = await db
78 .select({ id: users.id, username: users.username })
79 .from(users)
80 .where(eq(users.id, row.userId))
81 .limit(1);
82 if (!u) return null;
83 return { userId: u.id, username: u.username, source: "pat" };
84 } catch {
85 return null;
86 }
87}
88
89async function resolveByOauth(token: string): Promise<ResolvedPusher | null> {
90 if (!token.startsWith("glct_")) return null;
91 try {
92 const hash = await sha256Hex(token);
93 const [row] = await db
94 .select()
95 .from(oauthAccessTokens)
96 .where(eq(oauthAccessTokens.accessTokenHash, hash))
97 .limit(1);
98 if (!row) return null;
99 if (row.revokedAt) return null;
100 if (new Date(row.expiresAt) < new Date()) return null;
101 const [u] = await db
102 .select({ id: users.id, username: users.username })
103 .from(users)
104 .where(eq(users.id, row.userId))
105 .limit(1);
106 if (!u) return null;
107 return { userId: u.id, username: u.username, source: "oauth" };
108 } catch {
109 return null;
110 }
111}
112
113/**
114 * Resolve the pusher from an Authorization header. Tries Bearer (PAT or
115 * OAuth) then Basic (where the secret in the password field can also be
116 * a PAT or OAuth token — git CLI sends `git config credential.helper`
117 * output as username + password).
118 */
119export async function resolvePusher(
120 authHeader: string | null | undefined
121): Promise<ResolvedPusher | null> {
122 if (!authHeader) return null;
123
124 const bearer = decodeBearerAuth(authHeader);
125 if (bearer) {
126 return (
127 (await resolveByPat(bearer)) ||
128 (await resolveByOauth(bearer))
129 );
130 }
131
132 const basic = decodeBasicAuth(authHeader);
133 if (basic) {
134 const secret = basic.secret;
135 return (
136 (await resolveByPat(secret)) ||
137 (await resolveByOauth(secret))
138 );
139 }
140
141 return null;
142}
Addedsrc/lib/push-policy.ts+166−0View fileUnifiedSplit
@@ -0,0 +1,166 @@
1/**
2 * Push-policy enforcement — runs at the HTTP layer before git-receive-pack
3 * actually accepts the pack.
4 *
5 * Until now Gluecron's protected-tag and ruleset surfaces were advisory:
6 * `src/hooks/post-receive.ts` would log audit entries after the push had
7 * already landed. This module flips them to truly blocking by evaluating
8 * a list of refs at receive time and returning {allowed:false, violations}
9 * so the route can short-circuit with a 403.
10 *
11 * Refs evaluable from name+sha alone (no pack inspection):
12 * - protected_tags — tag pushes must come from owner/bypass
13 * - ruleset.tag_name_pattern — disallow tag names matching pattern
14 * - ruleset.branch_name_pattern — disallow branch names matching pattern
15 * - ruleset.forbid_force_push — heuristic: detected when oldSha was
16 * not the zero-SHA and newSha differs.
17 * True force-push detection requires a
18 * reachability check we don't run here;
19 * the existing `forcePush` boolean on
20 * PushContext is wired to false unless
21 * a smarter caller fills it in.
22 *
23 * Pure helpers + DB callers; never throws into the request path. On any
24 * unexpected failure we return {allowed:true} (fail-open) to preserve the
25 * existing no-policy behaviour rather than wedging legitimate pushes when
26 * Postgres hiccups.
27 */
28
29import {
30 matchProtectedTag,
31 canBypassProtectedTag,
32} from "./protected-tags";
33import {
34 listRulesetsForRepo,
35 evaluatePush,
36 type PushContext,
37} from "./rulesets";
38
39export type RefUpdate = {
40 oldSha: string;
41 newSha: string;
42 refName: string;
43};
44
45export type PushPolicyArgs = {
46 repositoryId: string;
47 refs: RefUpdate[];
48 pusherUserId: string | null;
49};
50
51export type PushPolicyResult = {
52 allowed: boolean;
53 violations: string[];
54};
55
56/** "0000000000000000000000000000000000000000" — 40 zeros. */
57export const ZERO_SHA = "0".repeat(40);
58
59const ALLOW: PushPolicyResult = { allowed: true, violations: [] };
60
61/**
62 * Classify a ref name into "branch" / "tag" for the ruleset evaluator.
63 * Heads = branch, tags = tag, anything else (e.g. `refs/notes/*`) we treat
64 * as a branch since the evaluator's tag-only rules will gracefully no-op.
65 */
66function refType(refName: string): "branch" | "tag" {
67 return refName.startsWith("refs/tags/") ? "tag" : "branch";
68}
69
70/**
71 * Evaluate every ref in `refs` against the repo's protected-tags + rulesets
72 * and return the aggregated decision. Multiple violations across refs are
73 * concatenated so the user sees every problem in one push attempt rather
74 * than one-at-a-time.
75 */
76export async function evaluatePushPolicy(
77 args: PushPolicyArgs
78): Promise<PushPolicyResult> {
79 const { repositoryId, refs, pusherUserId } = args;
80 if (!repositoryId || !refs || refs.length === 0) return ALLOW;
81
82 const violations: string[] = [];
83
84 // Protected tags — runs once per ref, only fires for tag refs.
85 for (const ref of refs) {
86 if (!ref.refName.startsWith("refs/tags/")) continue;
87 const tagName = ref.refName.slice("refs/tags/".length);
88 let protectedRule: Awaited<ReturnType<typeof matchProtectedTag>> = null;
89 try {
90 protectedRule = await matchProtectedTag(repositoryId, tagName);
91 } catch {
92 protectedRule = null;
93 }
94 if (!protectedRule) continue;
95
96 // Anonymous pusher → never bypasses. Authenticated pusher must be the
97 // owner (or future tag-admin) for this repo.
98 let canBypass = false;
99 try {
100 canBypass = await canBypassProtectedTag(repositoryId, pusherUserId);
101 } catch {
102 canBypass = false;
103 }
104 if (canBypass) continue;
105
106 const action =
107 ref.newSha === ZERO_SHA
108 ? "delete"
109 : ref.oldSha === ZERO_SHA
110 ? "create"
111 : "update";
112 violations.push(
113 `tag "${tagName}" is protected (pattern: ${protectedRule.pattern}); ${action} requires bypass`
114 );
115 }
116
117 // Rulesets — single DB call, evaluator runs purely on names.
118 let rulesets: Awaited<ReturnType<typeof listRulesetsForRepo>> = [];
119 try {
120 rulesets = await listRulesetsForRepo(repositoryId);
121 } catch {
122 rulesets = [];
123 }
124
125 if (rulesets && rulesets.length > 0) {
126 for (const ref of refs) {
127 const ctx: PushContext = {
128 kind: "push",
129 refType: refType(ref.refName),
130 refName: ref.refName,
131 commits: [],
132 // forcePush is left false — true detection requires a reachability
133 // check on the new commit, which is in the pack we haven't unpacked.
134 forcePush: false,
135 };
136 let result;
137 try {
138 result = evaluatePush(rulesets, ctx);
139 } catch {
140 result = { allowed: true, violations: [] as Array<{ rulesetName: string; ruleType: string; message: string; enforcement: string }> };
141 }
142 if (!result.allowed && result.violations.length > 0) {
143 for (const v of result.violations) {
144 // Only "active" enforcement blocks; "evaluate" is dry-run.
145 if (v.enforcement !== "active") continue;
146 violations.push(
147 `ruleset "${v.rulesetName}" rule ${v.ruleType}: ${v.message} (ref ${ref.refName})`
148 );
149 }
150 }
151 }
152 }
153
154 return violations.length === 0
155 ? ALLOW
156 : { allowed: false, violations };
157}
158
159/** Build a human-readable error body for the 403 response. */
160export function formatPolicyError(violations: string[]): string {
161 if (!violations || violations.length === 0) {
162 return "Push rejected by Gluecron policy.";
163 }
164 const lines = violations.map((v) => ` - ${v}`);
165 return `Push rejected by Gluecron policy:\n${lines.join("\n")}\n`;
166}
Modifiedsrc/routes/git.ts+73−7View fileUnifiedSplit
@@ -5,11 +5,19 @@
55 */
66
77import { Hono } from "hono";
8import { and, eq } from "drizzle-orm";
9import { db } from "../db";
10import { repositories, users } from "../db/schema";
811import { getInfoRefs, serviceRpc } from "../git/protocol";
912import { repoExists } from "../git/repository";
1013import { onPostReceive } from "../hooks/post-receive";
1114import { invalidateRepoCache } from "../lib/cache";
1215import { trackByName } from "../lib/traffic";
16import {
17 evaluatePushPolicy,
18 formatPolicyError,
19} from "../lib/push-policy";
20import { resolvePusher } from "../lib/git-push-auth";
1321
1422const git = new Hono();
1523
@@ -66,16 +74,44 @@ git.post("/:owner/:repo.git/git-upload-pack", async (c) => {
6674
6775// Receive pack (push)
6876git.post("/:owner/:repo.git/git-receive-pack", async (c) => {
69 const { owner, "repo.git": repo } = c.req.param();
70 if (!(await repoExists(owner, repo))) {
77 const { owner, "repo.git": repoRaw } = c.req.param();
78 const repo = (repoRaw || "").replace(/\.git$/, "");
79 if (!(await repoExists(owner, repoRaw))) {
7180 return c.text("Repository not found", 404);
7281 }
7382
74 // Parse the incoming refs from the request body before passing to git
83 // Read the body once; we parse refs from it for both pre-receive policy
84 // checks and the existing post-receive hook.
7585 const bodyBuffer = await c.req.arrayBuffer();
86 const refs = parseReceivePackRefs(new Uint8Array(bodyBuffer));
87
88 // Pre-receive policy: protected tags + ruleset name patterns. Fail-open
89 // on any DB hiccup (the helper returns {allowed:true} in that case).
90 if (refs.length > 0) {
91 try {
92 const repoRow = await loadRepoRow(owner, repo);
93 if (repoRow) {
94 const pusher = await resolvePusher(c.req.header("authorization"));
95 const decision = await evaluatePushPolicy({
96 repositoryId: repoRow.id,
97 refs,
98 pusherUserId: pusher?.userId || null,
99 });
100 if (!decision.allowed) {
101 // Returning 403 with a plain-text body — git smart-HTTP clients
102 // surface the body to the user (`remote: ` prefix). Existing
103 // behaviour for repos with no policy is unchanged.
104 return c.text(formatPolicyError(decision.violations), 403);
105 }
106 }
107 } catch {
108 // Never wedge a legitimate push on enforcer failure.
109 }
110 }
111
76112 const response = await serviceRpc(
77113 owner,
78 repo,
114 repoRaw,
79115 "git-receive-pack",
80116 bodyBuffer
81117 );
@@ -83,9 +119,7 @@ git.post("/:owner/:repo.git/git-receive-pack", async (c) => {
83119 // Invalidate cached git data for this repo immediately
84120 invalidateRepoCache(owner, repo);
85121
86 // Fire post-receive hooks asynchronously (don't block response)
87 // We parse updated refs from the pkt-line protocol in the request
88 const refs = parseReceivePackRefs(new Uint8Array(bodyBuffer));
122 // Fire post-receive hooks asynchronously (don't block response).
89123 if (refs.length > 0) {
90124 onPostReceive(owner, repo, refs).catch((err) =>
91125 console.error("[post-receive] hook error:", err)
@@ -95,6 +129,38 @@ git.post("/:owner/:repo.git/git-receive-pack", async (c) => {
95129 return response;
96130});
97131
132/**
133 * Look up the repositories row keyed by owner username + repo name.
134 * Pure DB helper kept local to this file because it's only used by the
135 * push-policy gate; returns null on miss/error so the caller fails open.
136 */
137async function loadRepoRow(
138 ownerName: string,
139 repoName: string
140): Promise<{ id: string } | null> {
141 try {
142 const [ownerRow] = await db
143 .select({ id: users.id })
144 .from(users)
145 .where(eq(users.username, ownerName))
146 .limit(1);
147 if (!ownerRow) return null;
148 const [repoRow] = await db
149 .select({ id: repositories.id })
150 .from(repositories)
151 .where(
152 and(
153 eq(repositories.ownerId, ownerRow.id),
154 eq(repositories.name, repoName)
155 )
156 )
157 .limit(1);
158 return repoRow || null;
159 } catch {
160 return null;
161 }
162}
163
98164/**
99165 * Parse ref updates from git-receive-pack request body.
100166 * Format: <old-sha> <new-sha> <ref-name>
101167