Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commita2b53b6

fix(git): restore Smart HTTP transport — strip .git from :repo.git route param

fix(git): restore Smart HTTP transport — strip .git from :repo.git route param

The Hono upgrade changed what the :repo.git pattern captures: handlers now
receive "name.git" (suffix included) where they previously got the bare name.
Every Smart HTTP handler passed that raw segment to repoExists/getRepoPath,
which append ".git" themselves — resolving to "<name>.git.git" on disk and
404ing every HTTP clone, fetch, and push platform-wide (the silent breakage
behind the 2026-05-15 stranded-commit incident and the dead push-to-deploy
loop). The gitParams() helper that strips the suffix existed but was unused.

- info/refs, HEAD, upload-pack, receive-pack now resolve names via gitParams()
- HEAD route also drops its hardcoded relative "repos/…" path for getRepoPath
  (was broken on absolute GIT_REPOS_PATH installs like /data/repos)
- clone traffic tracking now records under the correct repo name
- regression tests pin gitParams behavior AND live Hono param naming so a
  future Hono bump fails tests instead of silently killing production clones

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ccanty labs committed on July 12, 2026Parent: eb1cdf2
2 files changed+6926a2b53b653a8812ca6d17bd3b67d9528011f08298
2 changed files+69−26
Modifiedsrc/__tests__/api.test.ts+48−0View fileUnifiedSplit
5959 expect(res.status).toBe(404);
6060 });
6161});
62
63describe("Git HTTP param stripping (Hono :repo.git regression)", () => {
64 // The Hono upgrade changed what `:repo.git` captures: the param now
65 // arrives as "repo.git" → "<name>.git" (suffix included). Every handler
66 // must resolve names through gitParams, which strips the suffix — passing
67 // the raw segment to repoExists/getRepoPath yields "<name>.git.git" on
68 // disk and 404s every clone/push platform-wide.
69 const { gitParams } = require("../routes/git").__test;
70 const fakeCtx = (params: Record<string, string>) =>
71 ({ req: { param: () => params } }) as any;
72
73 it("strips .git from the modern 'repo.git' param key", () => {
74 expect(gitParams(fakeCtx({ owner: "a", "repo.git": "proj.git" }))).toEqual({
75 owner: "a",
76 repo: "proj",
77 });
78 });
79
80 it("handles dotted repo names (only the final .git is stripped)", () => {
81 expect(
82 gitParams(fakeCtx({ owner: "ccantynz", "repo.git": "Gluecron.com.git" }))
83 ).toEqual({ owner: "ccantynz", repo: "Gluecron.com" });
84 });
85
86 it("falls back to the legacy 'repo' param key", () => {
87 expect(gitParams(fakeCtx({ owner: "a", repo: "proj.git" }))).toEqual({
88 owner: "a",
89 repo: "proj",
90 });
91 });
92
93 it("pins live Hono param behavior for the :repo.git pattern", async () => {
94 // If a future Hono bump changes param naming again, this fails loudly
95 // instead of silently 404ing production clones.
96 const { Hono } = await import("hono");
97 const probe = new Hono();
98 let seen: Record<string, string> = {};
99 probe.get("/:owner/:repo.git/info/refs", (c) => {
100 seen = c.req.param() as Record<string, string>;
101 return c.text("ok");
102 });
103 await probe.request("/alice/My.Repo.git/info/refs");
104 expect(gitParams({ req: { param: () => seen } } as any)).toEqual({
105 owner: "alice",
106 repo: "My.Repo",
107 });
108 });
109});
Modifiedsrc/routes/git.ts+21−26View fileUnifiedSplit
99import { db } from "../db";
1010import { repositories, users } from "../db/schema";
1111import { getInfoRefs, serviceRpc } from "../git/protocol";
12import { repoExists } from "../git/repository";
12import { repoExists, getRepoPath } from "../git/repository";
13import { join } from "path";
1314import { onPostReceive } from "../hooks/post-receive";
1415import { invalidateRepoCache } from "../lib/cache";
1516import { trackByName } from "../lib/traffic";
3940
4041// Discovery: GET /:owner/:repo.git/info/refs?service=...
4142git.get("/:owner/:repo.git/info/refs", async (c) => {
42 const { owner, "repo.git": repo } = c.req.param();
43 // gitParams strips the ".git" suffix regardless of how the Hono version in
44 // play names the ":repo.git" param. Everything downstream (repoExists,
45 // getRepoPath, getInfoRefs) expects the bare repo NAME — passing the raw
46 // "name.git" segment resolves to "<name>.git.git" on disk and 404s every
47 // clone/push (the regression that broke Smart HTTP after the Hono bump).
48 const { owner, repo } = gitParams(c);
4349 const service = c.req.query("service");
4450
4551 if (!service || !["git-upload-pack", "git-receive-pack"].includes(service)) {
5359 // C2/C3 — authorize before disclosing refs. upload-pack (clone/fetch)
5460 // needs read; receive-pack (push) needs write. Anonymous read of a public
5561 // repo passes (resolveRepoAccess → "read"); private repos require auth.
56 const repoName = repo.replace(/\.git$/, "");
5762 const required: RepoAccessLevel =
5863 service === "git-receive-pack" ? "write" : "read";
5964 // info/refs is the discovery step: on insufficient-with-auth we return 404
6065 // for both services (privacy-preserving — don't confirm the repo exists to
6166 // a non-collaborator). The actual receive-pack POST returns a 403 instead.
62 const denied = await gitAccessGate(c, owner, repoName, required, "notfound");
67 const denied = await gitAccessGate(c, owner, repo, required, "notfound");
6368 if (denied) return denied;
6469
6570 return getInfoRefs(owner, repo, service);
6772
6873// GET /:owner/:repo.git/HEAD
6974git.get("/:owner/:repo.git/HEAD", async (c) => {
70 const { owner, "repo.git": repo } = c.req.param();
75 const { owner, repo } = gitParams(c);
7176 if (!(await repoExists(owner, repo))) {
7277 return c.text("Repository not found", 404);
7378 }
7479 // C2 — same read gate as upload-pack.
75 const denied = await gitAccessGate(
76 c,
77 owner,
78 repo.replace(/\.git$/, ""),
79 "read",
80 "notfound"
81 );
80 const denied = await gitAccessGate(c, owner, repo, "read", "notfound");
8281 if (denied) return denied;
83 const path = `repos/${owner}/${repo}.git/HEAD`;
84 const file = Bun.file(path);
82 // Resolve through getRepoPath — the old hardcoded relative "repos/…" path
83 // silently missed installs where GIT_REPOS_PATH is absolute (e.g. /data/repos).
84 const file = Bun.file(join(getRepoPath(owner, repo), "HEAD"));
8585 if (!(await file.exists())) return c.text("Not found", 404);
8686 return c.text(await file.text());
8787});
8888
8989// Upload pack (clone/fetch)
9090git.post("/:owner/:repo.git/git-upload-pack", async (c) => {
91 const { owner, "repo.git": repo } = c.req.param();
91 const { owner, repo } = gitParams(c);
9292 if (!(await repoExists(owner, repo))) {
9393 return c.text("Repository not found", 404);
9494 }
9595 // C2 — require read access before serving objects. Anonymous read of a
9696 // public repo passes; private repos require valid credentials.
97 const denied = await gitAccessGate(
98 c,
99 owner,
100 repo.replace(/\.git$/, ""),
101 "read",
102 "notfound"
103 );
97 const denied = await gitAccessGate(c, owner, repo, "read", "notfound");
10498 if (denied) return denied;
10599 // F1 — fire-and-forget clone tracking. Log on failure so traffic-stats
106100 // gaps are diagnosable (was a silent .catch(() => {}).)
118112
119113// Receive pack (push)
120114git.post("/:owner/:repo.git/git-receive-pack", async (c) => {
121 const { owner, "repo.git": repoRaw } = c.req.param();
122 const repo = (repoRaw || "").replace(/\.git$/, "");
123 if (!(await repoExists(owner, repoRaw))) {
115 const { owner, repo } = gitParams(c);
116 if (!(await repoExists(owner, repo))) {
124117 return c.text("Repository not found", 404);
125118 }
126119
241234 try {
242235 response = await serviceRpc(
243236 owner,
244 repoRaw,
237 repo,
245238 "git-receive-pack",
246239 bodyBuffer,
247240 hookEnv
399392 return refs;
400393}
401394
395export const __test = { gitParams, parseReceivePackRefs };
396
402397export default git;
403398