From cabec944e674b90dcad5c5b1824b62cd6a58d33d Mon Sep 17 00:00:00 2001 From: azank Date: Tue, 13 Oct 2026 16:48:38 +0500 Subject: [PATCH 1/2] site: a GitHub URL with commit.city swapped in lands on the city Swapping the domain on a GitHub page is the site's front door, but it only worked on a profile or a repo's front page: /vercel/ai/pulls, /vercel/ai/tree/main and /vercel/next.js/commits/canary were 404s, and /orgs/vercel opened a repo city called "orgs/vercel". - /owner/repo/ and /owner/repo.git redirect to /owner/repo - /orgs/[/...] redirects to / - the dev server applies vercel.json's redirects too, and a test runs the routes through path-to-regexp, which matches them as Vercel does --- web/scripts/dev.mjs | 14 ++++++++----- web/test/routes.test.ts | 45 +++++++++++++++++++++++++++++++++++++++++ web/vercel.json | 5 +++++ 3 files changed, 59 insertions(+), 5 deletions(-) create mode 100644 web/test/routes.test.ts diff --git a/web/scripts/dev.mjs b/web/scripts/dev.mjs index d8c0d96..bd42c7f 100644 --- a/web/scripts/dev.mjs +++ b/web/scripts/dev.mjs @@ -20,7 +20,9 @@ const TYPES = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; ch '.svg': 'image/svg+xml', '.png': 'image/png', '.json': 'application/json', '.gz': 'application/gzip', '.txt': 'text/plain; charset=utf-8', '.ico': 'image/x-icon', '.xml': 'application/xml', '.webmanifest': 'application/manifest+json', '.woff2': 'font/woff2', '.webp': 'image/webp', '.mp4': 'video/mp4' }; -const rewrites = config.rewrites.map(r => { const keys = []; return { re: pathToRegexp(r.source, keys), keys, dest: r.destination }; }); +const compile = rules => (rules || []).map(r => { const keys = []; return { re: pathToRegexp(r.source, keys), keys, dest: r.destination }; }); +const redirects = compile(config.redirects), rewrites = compile(config.rewrites); +const fill = (r, m) => { let dest = r.dest; r.keys.forEach((k, i) => { dest = dest.replaceAll(`:${k.name}`, m[i + 1] ?? ''); }); return dest; }; const headerRules = (config.headers || []).map(h => ({ re: pathToRegexp(h.source), headers: h.headers })); const loaded = new Map(); @@ -63,7 +65,11 @@ async function serve(req, res) { res.writeHead(200, { 'Content-Type': TYPES[path.extname(f)] || 'application/octet-stream', 'Access-Control-Allow-Origin': '*' }); return fs.createReadStream(f).pipe(res); } - // filesystem first, as on Vercel + // redirects, then the filesystem, then rewrites, as on Vercel + for (const r of redirects) { + const m = r.re.exec(p); + if (m) { res.writeHead(307, { Location: fill(r, m) + url.search }); return res.end(); } + } const sf = staticFile(p); if (sf) { res.writeHead(200, { 'Content-Type': TYPES[path.extname(sf)] || 'application/octet-stream', ...extra }); @@ -75,9 +81,7 @@ async function serve(req, res) { for (const r of rewrites) { const m = r.re.exec(p); if (!m) continue; - let dest = r.dest; - r.keys.forEach((k, i) => { dest = dest.replaceAll(`:${k.name}`, m[i + 1]); }); - const du = new URL(dest, url); + const du = new URL(fill(r, m), url); for (const [k, v] of url.searchParams) if (!du.searchParams.has(k)) du.searchParams.set(k, v); url.pathname = du.pathname; url.search = du.search; fn = path.join(root, du.pathname + '.ts'); diff --git a/web/test/routes.test.ts b/web/test/routes.test.ts new file mode 100644 index 0000000..e191e5b --- /dev/null +++ b/web/test/routes.test.ts @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { test } from 'node:test'; +import { type Key, pathToRegexp } from 'path-to-regexp'; +import { REPO_ROOT } from './helpers'; + +// vercel.json's redirects, then its rewrites, applied the way Vercel and scripts/dev.mjs apply them +const config = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'web', 'vercel.json'), 'utf8')) as { + redirects: { source: string; destination: string }[]; rewrites: { source: string; destination: string }[]; +}; +const compile = (rules: { source: string; destination: string }[]) => rules.map(r => { const keys: Key[] = []; return { re: pathToRegexp(r.source, keys), keys, dest: r.destination }; }); +const redirects = compile(config.redirects), rewrites = compile(config.rewrites); +function route(p: string): string | null { + for (const [kind, rules] of [['redirect', redirects], ['rewrite', rewrites]] as const) { + for (const r of rules) { + const m = r.re.exec(p); + if (!m) continue; + let dest = r.dest; + r.keys.forEach((k, i) => { dest = dest.replaceAll(`:${k.name}`, m[i + 1] ?? ''); }); + return `${kind} ${dest}`; + } + } + return null; +} + +test('a GitHub URL with commit.city swapped in lands on the city', () => { + const cases: [string, string | null][] = [ + ['/torvalds', 'rewrite /api/page?t=torvalds'], + ['/vercel/next.js', 'rewrite /api/page?t=vercel/next.js'], + ['/vercel/ai/pulls', 'redirect /vercel/ai'], + ['/vercel/ai/tree/main/packages/ai', 'redirect /vercel/ai'], + ['/vercel/next.js/commits/canary', 'redirect /vercel/next.js'], + ['/vercel/ai.git', 'redirect /vercel/ai'], + ['/orgs/vercel', 'redirect /vercel'], + ['/orgs/vercel/repositories', 'redirect /vercel'], + // the site's own paths are left alone + ['/api/build/repo', null], + ['/s/f/inter.woff2', null], + ['/v/app.a63cdb5695.js', null], + ['/explore', null], + ['/about', null], + ]; + for (const [p, want] of cases) assert.equal(route(p), want, p); +}); diff --git a/web/vercel.json b/web/vercel.json index 05ad944..7e1f9df 100644 --- a/web/vercel.json +++ b/web/vercel.json @@ -8,6 +8,11 @@ "api/build/finish.ts": { "maxDuration": 120 }, "api/og.ts": { "maxDuration": 30, "includeFiles": "fonts/**" } }, + "redirects": [ + { "source": "/orgs/:org([A-Za-z0-9][A-Za-z0-9-]{0,38})/:rest*", "destination": "/:org", "permanent": false }, + { "source": "/:owner((?!(?:api|v|s|u|fonts|local-store|explore|events|readme|about|404|index)(?:/|$))[A-Za-z0-9][A-Za-z0-9-]{0,38})/:repo([A-Za-z0-9._-]{1,96})\\.git", "destination": "/:owner/:repo", "permanent": false }, + { "source": "/:owner((?!(?:api|v|s|u|fonts|local-store|explore|events|readme|about|404|index)(?:/|$))[A-Za-z0-9][A-Za-z0-9-]{0,38})/:repo([A-Za-z0-9._-]{1,100})/:rest+", "destination": "/:owner/:repo", "permanent": false } + ], "rewrites": [ { "source": "/api/auth/:op(login|callback|logout)", "destination": "/api/account?op=:op" }, { "source": "/api/:op(me|settings)", "destination": "/api/account?op=:op" }, From 07a7e91bf802d0b1f478a299453cd505aa089753 Mon Sep 17 00:00:00 2001 From: azank Date: Tue, 13 Oct 2026 17:00:14 +0500 Subject: [PATCH 2/2] site: a repo GitHub moved has one city, under its current name facebook/react is react/react now, and commit.city kept two cities of it: /facebook/react served one built 2026-10-11 (685 commits) while /react/react had one a day newer (689). A build was keyed by the name it was asked for, so every old link to a moved repo built a second city. - the page redirects (307) when the city under the asked-for name belongs to a repo that has since moved: /facebook/react -> /react/react - a build of a moved repo is keyed by the current name, checks the current owner's hidden setting, and leaves the old name a meta that redirects, so its later visitors are sent on rather than rebuilding - the page swaps the address bar to the current name when the build starts --- web/api/page.ts | 4 ++++ web/lib/city.ts | 18 +++++++++++++++--- web/public/s/city.js | 4 ++++ web/test/bundle.test.ts | 3 ++- web/test/page.test.ts | 32 ++++++++++++++++++++++++++++++++ 5 files changed, 57 insertions(+), 4 deletions(-) create mode 100644 web/test/page.test.ts diff --git a/web/api/page.ts b/web/api/page.ts index f7a50b4..aa2834d 100644 --- a/web/api/page.ts +++ b/web/api/page.ts @@ -22,6 +22,10 @@ export const GET = handle(async req => { const key = cityKey(target, all); const meta = await readMeta(key).catch(() => null); const name = target.kind === 'owner' ? target.login : `${target.owner}/${target.name}`; + // a repo GitHub moved (facebook/react is react/react now) opens under its current name, as on GitHub + if (meta && meta.target.toLowerCase() !== name.toLowerCase()) { + return new Response(null, { status: 307, headers: { Location: `/${meta.target}${all ? '?all=1' : ''}`, 'Cache-Control': 'public, s-maxage=300' } }); + } const shown = meta?.target ?? name; const path = `/${shown}${all ? '?all=1' : ''}`; const title = `${shown}${all ? ' (whole history)' : ''} · commit city`; diff --git a/web/lib/city.ts b/web/lib/city.ts index 7597062..c689471 100644 --- a/web/lib/city.ts +++ b/web/lib/city.ts @@ -65,6 +65,7 @@ interface Plan { p: Discovery['profile']; c: number | null; x: number; o: [number, number]; b?: [string, number][]; // the biggest repos skipped as too big to replay (counted in o), named on the city page g?: Discovery['github']; // a person's public commits as GitHub counts them, for the city's reconciliation note + a?: string; // the key this build was asked for, when GitHub has moved the repo since: its page redirects r: { n: string; f: boolean; c: number; h: string; sh?: string[]; gen?: number }[]; // gen: an older record still in use } @@ -115,9 +116,9 @@ export async function usableGen(name: string, head: string, headTime: number, wh export async function startBuild(target: Target, token: string, opts: { whole?: boolean } = {}) { const whole = !!opts.whole && target.kind === 'repo'; - const key = cityKey(target, whole); + let key = cityKey(target, whole); const owner = target.kind === 'owner' ? target.login : target.owner; - const settings = await readSettings(owner, true); + let settings = await readSettings(owner, true); if (settings.hidden) throw new HttpError(403, 'this city is hidden by its owner', { status: 'hidden' }); let d: Discovery; try { @@ -127,6 +128,16 @@ export async function startBuild(target: Target, token: string, opts: { whole?: if (e instanceof RateLimited) throw new HttpError(503, 'GitHub API quota is used up for now; sign in with GitHub to build with your own quota', { status: 'rate_limited', resetAt: e.resetAt }); throw e; } + // a repo GitHub moved (facebook/react is react/react now) is built under its current name, so there is one city + // per repo; finish() leaves the old name a meta that redirects there. Whoever owns the repo now may hide it. + let alias: string | undefined; + if (target.kind === 'repo' && d.target.toLowerCase() !== cityKey(target)) { + const [movedOwner, movedName] = d.target.split('/'); + alias = key; + key = cityKey({ kind: 'repo', owner: movedOwner, name: movedName }, whole); + settings = await readSettings(movedOwner, true); + if (settings.hidden) throw new HttpError(403, 'this city is hidden by its owner', { status: 'hidden' }); + } const excluded = new Set((settings.exclude ?? []).map(s => s.toLowerCase())); const repos = d.repos.filter(r => !excluded.has(r.name.toLowerCase())); // nothing left to replay because it is all too big (torvalds/linux, vercel/next.js): say so now, instead of @@ -141,7 +152,7 @@ export async function startBuild(target: Target, token: string, opts: { whole?: const plan: Plan = { k: key, kind: d.kind, mode: d.mode, s: d.start, n: d.now, p: d.profile, c: d.contributions, x: d.restricted, o: [d.omittedRepos, d.omittedCommits], ...(named.length ? { b: named.map(s => [s.name, s.commits] as [string, number]) } : {}), - ...(d.github ? { g: d.github } : {}), + ...(d.github ? { g: d.github } : {}), ...(alias ? { a: alias } : {}), r: repos.map((r, i) => ({ n: r.name, f: r.fork, c: r.commits, h: r.head, ...(r.shas ? { sh: r.shas } : {}), ...(gens[i] && gens[i] !== REPLAY_GEN ? { gen: gens[i] } : {}) })), }; @@ -204,6 +215,7 @@ export async function finishBuild(ticketToken: string, planToken: string): Promi const meta = summarize(payload, { v, key: p.k, target: t.t, kind: p.kind, url }); await putJSON(skyPath(p.k, v), skyline(payload)).catch(e => console.error('sky', e)); await putJSON(metaPath(p.k), meta, { overwrite: true, maxAge: 60 }); + if (p.a) await putJSON(metaPath(p.a), meta, { overwrite: true, maxAge: 60 }).catch(e => console.error('moved', e)); await addToGallery(meta).catch(e => console.error('gallery', e)); return meta; } diff --git a/web/public/s/city.js b/web/public/s/city.js index f76d22b..c7ed8f7 100644 --- a/web/public/s/city.js +++ b/web/public/s/city.js @@ -144,6 +144,10 @@ try { st = await call('/api/build/start', { t: CC.target, all: !!CC.all }); } catch (e) { return fail(e); } + // GitHub moved this repo: the city is built, and lives, under its current name + if (isRepo && st.target.toLowerCase() !== CC.target.toLowerCase() && history.replaceState) { + history.replaceState(null, '', `/${st.target}${CC.all ? '?all=1' : ''}${location.hash}`); + } const who = st.kind === 'org' ? `the ${st.profile.login} org` : st.kind === 'repo' ? st.target : st.profile.name || st.profile.login; const jobs = st.repos.map(r => ({ ...r, s: r.cached ? 'hit' : 'wait', note: r.cached ? 'cached' : `${fmt(r.commits)} commits` })); for (const s of st.skipped || []) jobs.push({ name: s.name, s: 'bad', note: s.reason, skipped: true, commits: s.commits }); diff --git a/web/test/bundle.test.ts b/web/test/bundle.test.ts index 7b8ec39..cddaa6e 100644 --- a/web/test/bundle.test.ts +++ b/web/test/bundle.test.ts @@ -128,7 +128,7 @@ test('finish step: records of another version are never drawn; meta and gallery const ticket = sign({ k: 'me', t: 'me', s: start, w: false, r: [['me/alpha', r.head(), 0], ['me/beta', 'b'.repeat(40), 0]] }, 600); const plan = sign({ k: 'me', kind: 'org', mode: 'all', s: start, n: T0 + 30 * DAY, p: { login: 'me', name: 'Me', avatar: '', url: '', createdAt: '' }, - c: null, x: 0, o: [1, 900], b: [['me/huge', 900]], g: { commits: 950, capped: false }, + c: null, x: 0, o: [1, 900], b: [['me/huge', 900]], g: { commits: 950, capped: false }, a: 'old-me', r: [{ n: 'me/alpha', f: false, c: 7, h: r.head() }, { n: 'me/beta', f: false, c: 5, h: 'b'.repeat(40) }], }, 600); const meta = await finishBuild(ticket, plan); @@ -143,6 +143,7 @@ test('finish step: records of another version are never drawn; meta and gallery assert.deepEqual([payload.totals.omitted_repos, payload.totals.omitted_commits], [2, 905]); assert.deepEqual(payload.totals.too_big, [{ repo: 'me/huge', commits: 900 }]); assert.deepEqual(payload.totals.github, { commits: 950 }); // GitHub's count rides along; capped only when true + assert.equal((await getJSON<{ v: number }>('meta/old-me.json'))?.v, meta.v); // the name the build was asked for points here }); test('a generation-1 record stays in use only where the merged-branch fix cannot change it', async () => { diff --git a/web/test/page.test.ts b/web/test/page.test.ts new file mode 100644 index 0000000..0eb741e --- /dev/null +++ b/web/test/page.test.ts @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { GET } from '../api/page'; +import { putJSON, setStore, type Store } from '../lib/store'; + +class MemStore implements Store { + blobs = new Map(); + async get(p: string) { return this.blobs.get(p) ?? null; } + async exists(p: string) { return this.blobs.has(p); } + async stat() { return null; } + async put(p: string, data: Buffer) { this.blobs.set(p, data); return `mem:/${p}`; } + url(p: string) { return `mem:/${p}`; } + async list() { return []; } + async del(p: string) { this.blobs.delete(p); } +} + +const meta = (key: string, target: string) => ({ v: 1, key, target, kind: 'repo', login: target, name: target, avatar: '', url: '', generated: '', + repos: 1, commits: 685, files: 1, lines: 1, window_start: '2025-10-11', who: { human: 685, bot: 0, agent: 0, agents: [] } }); + +test('a repo GitHub moved opens under its current name; others stay put', async () => { + setStore(new MemStore()); + await putJSON('meta/facebook/react.json', meta('facebook/react', 'react/react')); + await putJSON('meta/react/react.json', meta('react/react', 'react/react')); + const moved = await GET(new Request('https://commit.city/api/page?t=facebook/react')); + assert.equal(moved.status, 307); + assert.equal(moved.headers.get('location'), '/react/react'); + const whole = await GET(new Request('https://commit.city/api/page?t=facebook/react&all=1')); + assert.equal(whole.status, 200); // no whole-history city of it yet: the build page, not a redirect + const here = await GET(new Request('https://commit.city/api/page?t=React/React')); + assert.equal(here.status, 200); // a different case is the same name + assert.match(await here.text(), /react\/react · commit city<\/title>/); +});