Public: anyone can read this; the crew can change it.
A git clone or fetch of a moved repository's old HTTPS URL reaches the new one #10143
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10130-a-git-clone-or-fetch-of-a-moved-reposito → main 2 commits fork
3 files
changed +146
−6
docs/features/repo-transfer.md +10 −2
| @@ -31,8 +31,16 @@ alike (a write is never applied silently; the client re-sends it). Someone | ||
| 31 | 31 | who cannot read the repository still gets `404`. Making a new repository at |
| 32 | 32 | the old path takes the path back. |
| 33 | 33 | |
| 34 | Not yet: git remotes under the old path, runs and previews follow in the next | |
| 35 | step. | |
| 34 | Git over HTTPS follows too: `GET /<old-owner>/<name>.git/info/refs?service=git-upload-pack` | |
| 35 | answers `308` to the new owner for someone who may read the repository, so | |
| 36 | `git clone` and `git fetch` on an old remote reach the new one (git follows the | |
| 37 | redirect on its first request). A push to the old URL is never applied: it is | |
| 38 | refused with `403` and a sentence naming the new path and the `git remote | |
| 39 | set-url` to run. Someone who cannot read it gets what a missing path answers. | |
| 40 | Over SSH the old path is not redirected (SSH has no redirect): the remote must | |
| 41 | be updated to the new path. | |
| 42 | ||
| 43 | Not yet: runs and previews follow in the next step. | |
| 36 | 44 | |
| 37 | 45 | For 30 days after a move, the repository's page says where it came from: one |
| 38 | 46 | muted line, "Moved from alice/demo on 2026-11-09", under the head, for someone |
src/http/moved.ts +20 −4
| @@ -16,13 +16,20 @@ import { isSafeName } from "../git/guard.ts"; | ||
| 16 | 16 | const API = "/api/v1/repos/"; |
| 17 | 17 | |
| 18 | 18 | /** The owner/name a path names, its prefix and the rest; undefined when it names none. */ |
| 19 | function repoPath(pathname: string): { prefix: string; owner: string; name: string; rest: string } | undefined { | |
| 19 | function repoPath(pathname: string): { prefix: string; owner: string; name: string; git: boolean; rest: string } | undefined { | |
| 20 | 20 | const prefix = pathname.startsWith(API) ? API : "/"; |
| 21 | 21 | const parts = pathname.slice(prefix.length).split("/"); |
| 22 | 22 | if (parts.length < 2) return undefined; |
| 23 | const [owner, name] = parts as [string, string]; | |
| 23 | const [owner, raw] = parts as [string, string]; | |
| 24 | const git = prefix === "/" && raw.endsWith(".git"); | |
| 25 | const name = git ? raw.slice(0, -4) : raw; | |
| 24 | 26 | if (!isSafeName(owner) || !isSafeName(name)) return undefined; |
| 25 | return { prefix, owner, name, rest: parts.slice(2).join("/") }; | |
| 27 | return { prefix, owner, name, git, rest: parts.slice(2).join("/") }; | |
| 28 | } | |
| 29 | ||
| 30 | /** A push: the receive-pack advertisement or its POST. A push is refused, never carried over. */ | |
| 31 | function isPush(url: URL, rest: string): boolean { | |
| 32 | return rest === "git-receive-pack" || (rest === "info/refs" && url.searchParams.get("service") === "git-receive-pack"); | |
| 26 | 33 | } |
| 27 | 34 | |
| 28 | 35 | export function movedResponse(db: Database, url: URL, user: User | undefined): Response | undefined { |
| @@ -30,7 +37,16 @@ export function movedResponse(db: Database, url: URL, user: User | undefined): R | ||
| 30 | 37 | if (at === undefined) return undefined; |
| 31 | 38 | const repo = redirectFor(db, at.owner, at.name); |
| 32 | 39 | if (repo === undefined || !canRead(db, repo, user)) return undefined; |
| 40 | // Git's smart HTTP names the repository `name.git`; the new path keeps it. | |
| 41 | const suffix = at.git ? ".git" : ""; | |
| 42 | if (at.git && isPush(url, at.rest)) { | |
| 43 | const path = `${repo.ownerName}/${repo.name}`; | |
| 44 | return new Response( | |
| 45 | `This repository moved to ${path}. Nothing was pushed: set the remote with git remote set-url origin ${url.origin}/${path}.git and push again.\n`, | |
| 46 | { status: 403, headers: { "content-type": "text/plain; charset=utf-8" } }, | |
| 47 | ); | |
| 48 | } | |
| 33 | 49 | const rest = at.rest === "" && !url.pathname.endsWith("/") ? "" : `/${at.rest}`; |
| 34 | const location = `${at.prefix}${encodeURIComponent(repo.ownerName)}/${encodeURIComponent(repo.name)}${rest}${url.search}`; | |
| 50 | const location = `${at.prefix}${encodeURIComponent(repo.ownerName)}/${encodeURIComponent(repo.name)}${suffix}${rest}${url.search}`; | |
| 35 | 51 | return new Response(null, { status: 308, headers: { location } }); |
| 36 | 52 | } |
test/moved-git.test.ts added +116
| @@ -0,0 +1,116 @@ | ||
| 1 | /** A moved repository's old HTTPS git URL: clone and fetch reach the new one; a push is refused with the new path (#10130). */ | |
| 2 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createRepo } from "../src/actions/repos.ts"; | |
| 7 | import { createApp, type App } from "../src/app.ts"; | |
| 8 | import { transferRepo } from "../src/core/transfer.ts"; | |
| 9 | import { createUser, type User } from "../src/core/users.ts"; | |
| 10 | import { commitFiles } from "../src/git/write.ts"; | |
| 11 | import { repoDir } from "../src/git/repos.ts"; | |
| 12 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 13 | import { quietLogs } from "./helpers/globals.ts"; | |
| 14 | ||
| 15 | quietLogs(); | |
| 16 | ||
| 17 | const git = async (cwd: string, ...args: string[]) => { | |
| 18 | const p = Bun.spawn(["git", "-c", "protocol.version=2", ...args], { | |
| 19 | cwd, | |
| 20 | stdout: "pipe", | |
| 21 | stderr: "pipe", | |
| 22 | env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_SYSTEM: "/dev/null" }, | |
| 23 | }); | |
| 24 | const [out, errText] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text()]); | |
| 25 | return { code: await p.exited, out, err: errText }; | |
| 26 | }; | |
| 27 | ||
| 28 | describe("git through a moved repository's old URL", () => { | |
| 29 | let dataDir = ""; | |
| 30 | let app: App; | |
| 31 | let servers: Servers; | |
| 32 | const commit = (owner: string, name: string, message: string) => | |
| 33 | commitFiles(repoDir(app.reposRoot, owner, name), { | |
| 34 | branch: "main", | |
| 35 | message, | |
| 36 | files: [{ path: `${message}.txt`, content: message }], | |
| 37 | author: { name: "a", email: "[email protected]" }, | |
| 38 | }); | |
| 39 | ||
| 40 | beforeAll(async () => { | |
| 41 | dataDir = await mkdtemp(join(tmpdir(), "forge-moved-git-")); | |
| 42 | app = createApp({ dataDir, port: 0 }); | |
| 43 | servers = await serve(app); | |
| 44 | const mk = async (username: string): Promise<User> => { | |
| 45 | const u = await createUser(app.db, { | |
| 46 | origin: "seed", | |
| 47 | trusted: true, | |
| 48 | username, | |
| 49 | email: `${username}@example.com`, | |
| 50 | password: "a-long-moved-git-passphrase", | |
| 51 | adminIfFirst: username === "alice", | |
| 52 | }); | |
| 53 | if (!u.ok) throw new Error(JSON.stringify(u.error)); | |
| 54 | return u.value; | |
| 55 | }; | |
| 56 | const alice = await mk("alice"); | |
| 57 | const bob = await mk("bob"); | |
| 58 | await mk("eve"); | |
| 59 | for (const [name, visibility] of [["demo", "public"], ["secret", "private"]] as const) { | |
| 60 | const made = await createRepo(app, alice, { name, visibility }); | |
| 61 | if (!made.ok) throw new Error(JSON.stringify(made.error)); | |
| 62 | expect((await commit("alice", name, "first")).ok).toBe(true); | |
| 63 | const moved = await transferRepo(app.db, app.reposRoot, made.value, bob, alice.id); | |
| 64 | expect(moved.ok).toBe(true); | |
| 65 | } | |
| 66 | expect((await commit("bob", "demo", "second")).ok).toBe(true); | |
| 67 | }); | |
| 68 | ||
| 69 | afterAll(async () => { | |
| 70 | servers.stop(); | |
| 71 | app.close(); | |
| 72 | await rm(dataDir, { recursive: true, force: true }); | |
| 73 | }); | |
| 74 | ||
| 75 | test("the advertisement answers 308 to the same path under the new owner", async () => { | |
| 76 | const res = await fetch(`${servers.url}/alice/demo.git/info/refs?service=git-upload-pack`, { redirect: "manual" }); | |
| 77 | expect(res.status).toBe(308); | |
| 78 | expect(res.headers.get("location")).toBe("/bob/demo.git/info/refs?service=git-upload-pack"); | |
| 79 | }); | |
| 80 | ||
| 81 | test("real git clones and fetches through the old URL", async () => { | |
| 82 | const dir = await mkdtemp(join(tmpdir(), "forge-moved-git-wc-")); | |
| 83 | try { | |
| 84 | const cloned = await git(dir, "clone", `${servers.url}/alice/demo.git`, "wc"); | |
| 85 | expect(cloned.code).toBe(0); | |
| 86 | const wc = join(dir, "wc"); | |
| 87 | expect((await git(wc, "log", "--format=%s")).out.trim().split("\n")).toEqual(["second", "first"]); | |
| 88 | expect((await commit("bob", "demo", "third")).ok).toBe(true); | |
| 89 | expect((await git(wc, "fetch", "origin")).code).toBe(0); | |
| 90 | expect((await git(wc, "log", "--format=%s", "origin/main")).out.split("\n")[0]).toBe("third"); | |
| 91 | } finally { | |
| 92 | await rm(dir, { recursive: true, force: true }); | |
| 93 | } | |
| 94 | }); | |
| 95 | ||
| 96 | test("a stranger gets what a missing path answers, and a private repository stays hidden", async () => { | |
| 97 | const res = await fetch(`${servers.url}/alice/secret.git/info/refs?service=git-upload-pack`, { redirect: "manual" }); | |
| 98 | const missing = await fetch(`${servers.url}/alice/nothing.git/info/refs?service=git-upload-pack`, { redirect: "manual" }); | |
| 99 | expect(res.status).toBe(missing.status); | |
| 100 | expect(res.headers.get("location")).toBeNull(); | |
| 101 | const dir = await mkdtemp(join(tmpdir(), "forge-moved-git-st-")); | |
| 102 | try { | |
| 103 | expect((await git(dir, "clone", `${servers.url}/alice/secret.git`, "x")).code).not.toBe(0); | |
| 104 | } finally { | |
| 105 | await rm(dir, { recursive: true, force: true }); | |
| 106 | } | |
| 107 | }); | |
| 108 | ||
| 109 | test("a push to the old URL is refused with a sentence naming the new path", async () => { | |
| 110 | const adv = await fetch(`${servers.url}/alice/demo.git/info/refs?service=git-receive-pack`, { redirect: "manual" }); | |
| 111 | expect(adv.status).toBe(403); | |
| 112 | expect(await adv.text()).toContain("moved to bob/demo"); | |
| 113 | const post = await fetch(`${servers.url}/alice/demo.git/git-receive-pack`, { method: "POST", redirect: "manual" }); | |
| 114 | expect(post.status).toBe(403); | |
| 115 | }); | |
| 116 | }); |