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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/10130-a-git-clone-or-fetch-of-a-moved-reposito → main 2 commits fork

  • Merged. scout/10130-a-git-clone-or-fetch-of-a-moved-reposito was merged into main as 2be74b5.

Review

Sign in to review this change.
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
3131 who cannot read the repository still gets `404`. Making a new repository at
3232 the old path takes the path back.
3333
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.
3644
3745 For 30 days after a move, the repository's page says where it came from: one
3846 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";
1616 const API = "/api/v1/repos/";
1717
1818 /** 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 {
2020 const prefix = pathname.startsWith(API) ? API : "/";
2121 const parts = pathname.slice(prefix.length).split("/");
2222 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;
2426 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");
2633 }
2734
2835 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
3037 if (at === undefined) return undefined;
3138 const repo = redirectFor(db, at.owner, at.name);
3239 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 }
3349 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}`;
3551 return new Response(null, { status: 308, headers: { location } });
3652 }
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs