Public: anyone can read this; the crew can change it.

A repository can be renamed, and its old address keeps working #12156

Merged opened this in answer to

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

Merged

scout/forge:scout/12154-a-repository-can-be-renamed-and-its-old → main 7 commits fork

  • Merged. scout/12154-a-repository-can-be-renamed-and-its-old was merged into main as 1f3280d.

Review

Sign in to review this change.
18 files changed +566 −42
docs/MCP.md +1 −1
@@ -183,7 +183,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
183183 | `releases` | — | Kept releases, newest first | 298 |
184184 | `repo` | `open`, `add`, `remove`, `close` | One repository you can read, as GET /api/v1/repos/:owner/:name | 383 |
185185 | `repo_labels` | — | Repo labels: list (default), create, update, remove (admin token) | 351 |
186 | `repo_settings` | — | Read, change or move (newOwner) a repo: PATCH /api/v1/repos/:owner/:name (admin); mirrorPacks mirrors the packs' upstreams | 444 |
186 | `repo_settings` | — | Read, change, rename (name) or move (newOwner) a repo: PATCH /api/v1/repos/:owner/:name (admin); mirrorPacks mirrors the packs' upstreams | 484 |
187187 | `reports` | `accept`, `dismiss` | Outsiders' reports; `id`+`action`: accept or dismiss | 355 |
188188 | `restore_drill` | — | Drill-restore the newest snapshot | 216 |
189189 | `returns` | — | What crew tokens bought in `days` (1–90, default 7): dollars, runs, landed, cost/change | 284 |
docs/features/repo-transfer.md +15
@@ -67,3 +67,18 @@ arriving by an old link. The terminal says the same on the About card (`I` on
6767 the repository's screen) as a "moved from" row. Both read the newest
6868 `repo_transfer` row through `recentMove` in `src/core/transfer.ts`; after 30
6969 days, or for a repository never moved, neither shows anything.
70
71 ## Renaming (#12154)
72
73 The same machinery, under the same owner. Web: Settings › Rename. API:
74 `PATCH /api/v1/repos/:owner/:name` with `{"name": "<new name>"}` (an admin-scope
75 token; the owner or a site admin; refused with `name_taken`, `same_name` or
76 `invalid_name`). MCP: `repo_settings` with `{ repo, name }`. Terminal: `:` then
77 "Rename this repository". The repository keeps its id, so its app (and the
78 app's address, which hangs off the app's slug, not the repository's name),
79 pulls, issues, follows, forks and tokens keep working; the bare directory is
80 renamed and each fork's borrowed-objects link rewritten. The old path is
81 recorded in `repo_redirect`: a web or API read answers `301` (never cached: `no-store`) to the new path,
82 a mutation `308`, git's fetch follows the redirect, and a push there is
83 refused with a sentence naming the new address. It lasts until another
84 repository takes the old name. A rename emits `repo.transferred`.
src/actions/transfer.ts +34 −2
@@ -11,7 +11,7 @@
1111 import type { App } from "../app.ts";
1212 import { getUserByName, type User } from "../core/users.ts";
1313 import { canAdmin, type Repo } from "../core/repos.ts";
14 import { transferRepo, type TransferError } from "../core/transfer.ts";
14 import { renameRepo, transferRepo, type TransferError } from "../core/transfer.ts";
1515 import { storageChanged } from "../core/storage.ts";
1616 import { err, ok, type Result } from "../kernel/result.ts";
1717 import { resolveOwner } from "./repos.ts";
@@ -19,8 +19,10 @@ import { resolveOwner } from "./repos.ts";
1919 export type TransferActionError = TransferError | "not_found" | "forbidden_owner" | "unknown_owner";
2020
2121 export const TRANSFER_SAID: Readonly<Record<Exclude<TransferActionError, "not_found">, string>> = {
22 name_taken: "The new owner already has a repository by that name.",
22 name_taken: "That owner already has a repository by that name.",
2323 same_owner: "The repository already belongs to that owner.",
24 same_name: "The repository already has that name.",
25 invalid_name: "A name is letters, digits, dots, dashes and underscores, and starts with a letter or digit.",
2426 move_failed: "The repository could not be moved; nothing changed.",
2527 forbidden_owner: "You cannot create repositories under that owner.",
2628 unknown_owner: "No account or organisation has that name.",
@@ -30,6 +32,8 @@ export const TRANSFER_SAID: Readonly<Record<Exclude<TransferActionError, "not_fo
3032 export const TRANSFER_STATUS: Readonly<Record<Exclude<TransferActionError, "not_found">, number>> = {
3133 name_taken: 409,
3234 same_owner: 409,
35 same_name: 409,
36 invalid_name: 422,
3337 move_failed: 500,
3438 forbidden_owner: 403,
3539 unknown_owner: 422,
@@ -61,3 +65,31 @@ export async function transferRepository(
6165 });
6266 return ok(moved.value);
6367 }
68
69 /**
70 * Rename a repository (#12154): an owner's or an admin's call, never an
71 * agent's. Its id, git directory contents, app, pulls, issues, follows, forks
72 * and tokens stay as they are; the old path answers with a redirect until
73 * another repository takes the name. The event is `repo.transferred`: a
74 * rename is a move to a new path, and its readers, feed and app say it so.
75 */
76 export async function renameRepository(
77 app: App,
78 actor: User | undefined,
79 repo: Repo,
80 newName: string,
81 ): Promise<Result<Repo, TransferActionError>> {
82 if (actor === undefined || actor.kind === "agent" || !canAdmin(app.db, repo, actor)) return err("not_found");
83 const moved = await renameRepo(app.db, app.reposRoot, repo, newName, actor.id);
84 if (!moved.ok) return moved;
85 app.bus.append({
86 kind: "repo.transferred",
87 repoId: repo.id,
88 actorId: actor.id,
89 payload: {
90 from: `${repo.ownerName}/${repo.name}`,
91 to: `${moved.value.ownerName}/${moved.value.name}`,
92 },
93 });
94 return ok(moved.value);
95 }
src/contract/openapi/routes/patchApiV1ReposOwnerName.ts +4
@@ -9,6 +9,7 @@ export const route: Route = {
99 auth: true,
1010 role: "member",
1111 body: {
12 name: "string",
1213 description: "string",
1314 visibility: "string",
1415 archived: "boolean",
@@ -22,6 +23,9 @@ export const route: Route = {
2223 plansNeedNod: "boolean",
2324 },
2425 refusals: {
26 invalid_name: "422: a name is letters, digits, dots, dashes and underscores",
27 name_taken: "409: that owner already has a repository by that name",
28 same_name: "409: the repository already has that name",
2529 bad_archived: "422: archived is true or false",
2630 bad_decisions_by: "422: decisionsBy is me, decider or decider_tell",
2731 bad_plans_need_nod: "422: plansNeedNod is true or false",
src/core/transfer.ts +39 −9
@@ -14,6 +14,7 @@ import type { Database } from "bun:sqlite";
1414 import { mkdir, readFile, rename, rmdir, writeFile } from "node:fs/promises";
1515 import { dirname, join } from "node:path";
1616 import { closeReader } from "../git/read.ts";
17 import { isSafeName } from "../git/guard.ts";
1718 import { repoDir, repoDirExists } from "../git/repos.ts";
1819 import { id } from "../kernel/id.ts";
1920 import { err, ok, type Result } from "../kernel/result.ts";
@@ -21,7 +22,7 @@ import { forgetListings } from "./listings.ts";
2122 import { getRepo, getRepoById, listForks, type Repo } from "./repos.ts";
2223 import type { User } from "./users.ts";
2324
24 export type TransferError = "name_taken" | "same_owner" | "move_failed";
25 export type TransferError = "name_taken" | "same_owner" | "same_name" | "invalid_name" | "move_failed";
2526
2627 /**
2728 * The old owner/name pairs, held in process memory per database so the
@@ -87,21 +88,50 @@ export function transferRepo(
8788 return turn;
8889 }
8990
91 /**
92 * Renaming a repository (#12154): the same move, under the same owner, to a
93 * new name. Its old path is left behind as a redirect exactly as a transfer
94 * leaves one, so web, API and git at the old address reach the new one.
95 */
96 export function renameRepo(
97 db: Database,
98 reposRoot: string,
99 repo: Repo,
100 newName: string,
101 movedBy: string,
102 ): Promise<Result<Repo, TransferError>> {
103 const turn = queue.then(() =>
104 moveNow(db, reposRoot, repo, { id: repo.ownerId, username: repo.ownerName }, movedBy, newName),
105 );
106 queue = turn.catch(() => undefined);
107 return turn;
108 }
109
90110 async function moveNow(
91111 db: Database,
92112 reposRoot: string,
93113 stale: Repo,
94 target: User,
114 target: Pick<User, "id" | "username">,
95115 movedBy: string,
116 newName?: string,
96117 ): Promise<Result<Repo, TransferError>> {
97118 // The row as it is now, not as the caller read it before waiting its turn.
98119 const repo = getRepoById(db, stale.id);
99120 if (repo === undefined) return err("move_failed");
100 if (target.id === repo.ownerId) return err("same_owner");
101 if (getRepo(db, target.username, repo.name) !== undefined) return err("name_taken");
121 const name = newName ?? repo.name;
122 if (newName === undefined && target.id === repo.ownerId) return err("same_owner");
123 if (newName !== undefined) {
124 if (!isSafeName(newName)) return err("invalid_name");
125 if (newName === repo.name) return err("same_name");
126 }
127 // A change of letter case only is the repository's own name, not a taken one.
128 const holder = getRepo(db, target.username, name);
129 if (holder !== undefined && holder.id !== repo.id) return err("name_taken");
102130 const from = repoDir(reposRoot, repo.ownerName, repo.name);
103 const to = repoDir(reposRoot, target.username, repo.name);
104 if (await repoDirExists(to)) return err("name_taken");
131 const to = repoDir(reposRoot, target.username, name);
132 if (name.toLowerCase() !== repo.name.toLowerCase() || target.id !== repo.ownerId) {
133 if (await repoDirExists(to)) return err("name_taken");
134 }
105135 const hadDir = await repoDirExists(from);
106136 if (hadDir) {
107137 closeReader(from);
@@ -113,13 +143,13 @@ async function moveNow(
113143 }
114144 }
115145 const fromPath = `${repo.ownerName}/${repo.name}`;
116 const toPath = `${target.username}/${repo.name}`;
146 const toPath = `${target.username}/${name}`;
117147 try {
118148 db.transaction(() => {
119 db.query("UPDATE repo SET owner_id = ?1 WHERE id = ?2").run(target.id, repo.id);
149 db.query("UPDATE repo SET owner_id = ?1, name = ?2 WHERE id = ?3").run(target.id, name, repo.id);
120150 // The new path is a real repository now; a redirect that pointed it
121151 // elsewhere would never be read, so it goes.
122 db.query("DELETE FROM repo_redirect WHERE owner_name = ?1 AND name = ?2").run(target.username, repo.name);
152 db.query("DELETE FROM repo_redirect WHERE owner_name = ?1 AND name = ?2").run(target.username, name);
123153 db.query(
124154 `INSERT INTO repo_redirect (owner_name, name, repo_id, moved_by) VALUES (?1, ?2, ?3, ?4)
125155 ON CONFLICT (owner_name, name) DO UPDATE SET repo_id = excluded.repo_id,
src/http/api.ts +19 −2
@@ -126,7 +126,7 @@ import {
126126 resolveOwner,
127127 updateRepoSettings,
128128 } from "../actions/repos.ts";
129 import { TRANSFER_SAID, TRANSFER_STATUS, transferRepository } from "../actions/transfer.ts";
129 import { TRANSFER_SAID, TRANSFER_STATUS, renameRepository, transferRepository } from "../actions/transfer.ts";
130130 import {
131131 createToken,
132132 HUMAN_CHECK_MESSAGE,
@@ -1696,7 +1696,24 @@ export function apiRoutes(router: Router<Handler>): void {
16961696 if (getProvenance(ctx.app.db, repo.id) === undefined)
16971697 return apiError("no_provenance", 422, FAMILY_WORDS.no_provenance);
16981698 }
1699 const updated = await updateRepoSettings(ctx.app, ctx.auth.user, repo, {
1699 // A new name (#12154): an owner's or admin's, given alone, so a refused
1700 // name or field can never leave the other half applied.
1701 let current = repo;
1702 if (input.name !== undefined) {
1703 if (Object.keys(input).some((k) => k !== "name"))
1704 return apiError("invalid", 422, "name renames the repository; give it alone");
1705 if (typeof input.name !== "string" || input.name.length === 0 || input.name.length > 100)
1706 return apiError("invalid_name", 422, TRANSFER_SAID.invalid_name);
1707 if (input.name !== repo.name) {
1708 const renamed = await renameRepository(ctx.app, ctx.auth.user, repo, input.name);
1709 if (!renamed.ok) {
1710 if (renamed.error === "not_found") return notFound();
1711 return apiError(renamed.error, TRANSFER_STATUS[renamed.error], TRANSFER_SAID[renamed.error]);
1712 }
1713 current = renamed.value;
1714 }
1715 }
1716 const updated = await updateRepoSettings(ctx.app, ctx.auth.user, current, {
17001717 ...(input.visibility === "public" || input.visibility === "private"
17011718 ? { visibility: input.visibility }
17021719 : {}),
src/http/mcp/tools/repo_settings.ts +21 −4
@@ -1,5 +1,5 @@
11 import { updateRepoSettings } from "../../../actions/repos.ts";
2 import { TRANSFER_SAID, transferRepository } from "../../../actions/transfer.ts";
2 import { TRANSFER_SAID, renameRepository, transferRepository } from "../../../actions/transfer.ts";
33 import { mirrorPacks, reportWords } from "../../../packs/mirror.ts";
44 import { rejectedMarkedSentence } from "../../../packs/marked.ts";
55 import { setPackRepos } from "../../../packs/set.ts";
@@ -8,9 +8,9 @@ import { VISIBILITY_WORDS } from "../../../core/arg_words.ts";
88
99 export const tool: Tool = {
1010 name: "repo_settings",
11 doc: "`{ packRepos: [\"packs/games\", …] }` (admin) sets the repositories /packs lists, as Settings › Packs does (public repos with a pack.json at the root; at most 20; an empty list lists none), and answers `{ repos, problems }` with the ones left out and why; it shows on /packs at once; a word that is not `owner/name` is `invalid`. `{ mirrorPacks: true }` (admin) brings each https source in a marked pack repo's pack.json in once as a read-only `packs/<app>` that follows its upstream, and answers `{ made, kept, failed, says }`; running it again makes nothing new. `{ repo, description?, visibility?, newOwner? }` (admin): `newOwner` moves the repository to that account or org as `POST /api/v1/repos/:owner/:name/transfer` does, with its codes and sentences (`name_taken`, `same_owner`, `move_failed`, `forbidden_owner`, `unknown_owner`) and answers `{ repo, description, visibility }` at the new path, and cannot be combined with the others; otherwise change a repository's description and visibility as `PATCH /api/v1/repos/:owner/:name` does, for the same callers (a repository admin); answers `{ repo, description, visibility }` (with none to change it only reads); a visibility other than public or private is `invalid`",
11 doc: "`{ packRepos: [\"packs/games\", …] }` (admin) sets the repositories /packs lists, as Settings › Packs does (public repos with a pack.json at the root; at most 20; an empty list lists none), and answers `{ repos, problems }` with the ones left out and why; it shows on /packs at once; a word that is not `owner/name` is `invalid`. `{ mirrorPacks: true }` (admin) brings each https source in a marked pack repo's pack.json in once as a read-only `packs/<app>` that follows its upstream, and answers `{ made, kept, failed, says }`; running it again makes nothing new. `{ repo, name }` (admin) renames the repository as `PATCH /api/v1/repos/:owner/:name` with `name` does: its pulls, issues, forks, app and tokens stay, its old web and git address redirect to the new one (a push there is refused naming it), with the codes `name_taken`, `same_name`, `invalid_name`, `move_failed`; give it alone; answers `{ repo, description, visibility }` at the new path. `{ repo, description?, visibility?, newOwner? }` (admin): `newOwner` moves the repository to that account or org as `POST /api/v1/repos/:owner/:name/transfer` does, with its codes and sentences (`name_taken`, `same_owner`, `move_failed`, `forbidden_owner`, `unknown_owner`) and answers `{ repo, description, visibility }` at the new path, and cannot be combined with the others; otherwise change a repository's description and visibility as `PATCH /api/v1/repos/:owner/:name` does, for the same callers (a repository admin); answers `{ repo, description, visibility }` (with none to change it only reads); a visibility other than public or private is `invalid`",
1212 description:
13 "Read, change or move (newOwner) a repo: PATCH /api/v1/repos/:owner/:name (admin); mirrorPacks mirrors the packs' upstreams.",
13 "Read, change, rename (name) or move (newOwner) a repo: PATCH /api/v1/repos/:owner/:name (admin); mirrorPacks mirrors the packs' upstreams.",
1414 inputSchema: {
1515 type: "object",
1616 properties: {
@@ -18,6 +18,7 @@ export const tool: Tool = {
1818 description: { type: "string", maxLength: 2000 },
1919 visibility: { type: "string", enum: ["public", "private"] },
2020 newOwner: { type: "string", maxLength: 64 },
21 name: { type: "string", maxLength: 100 },
2122 mirrorPacks: { type: "boolean" },
2223 packRepos: { type: "array", items: { type: "string", maxLength: 100 }, maxItems: 20 },
2324 },
@@ -39,7 +40,23 @@ export const tool: Tool = {
3940 }
4041 const repo = repoArg(ctx, user, args);
4142 if (isAnswer(repo)) return repo;
42 const { description, visibility, newOwner } = args;
43 const { description, visibility, newOwner, name } = args;
44 if (name !== undefined) {
45 if (typeof name !== "string" || name.length === 0 || name.length > 100)
46 return bad("invalid_name", TRANSFER_SAID.invalid_name);
47 if (description !== undefined || visibility !== undefined || newOwner !== undefined)
48 return bad("invalid", "name renames the repository; give it alone");
49 const renamed = await renameRepository(ctx.app, user, repo, name);
50 if (!renamed.ok)
51 return renamed.error === "not_found"
52 ? bad("not_found", "no such app or repository (owner/name)")
53 : bad(renamed.error, TRANSFER_SAID[renamed.error]);
54 return good({
55 repo: `${renamed.value.ownerName}/${renamed.value.name}`,
56 description: renamed.value.description,
57 visibility: renamed.value.visibility,
58 });
59 }
4360 if (newOwner !== undefined) {
4461 if (typeof newOwner !== "string" || newOwner.length === 0 || newOwner.length > 64)
4562 return bad("bad_new_owner", "newOwner is an account or organisation name");
src/http/moved.ts +11 −6
@@ -1,8 +1,9 @@
11 /**
2 * A moved repository's old paths (#569). A web or API request under an
3 * owner/name that a transfer left behind answers 308 to the same path under
4 * the repository's new owner — a GET and a mutation alike, so a write is never
5 * applied silently to the new place: the client re-sends it, knowing where.
2 * A moved or renamed repository's old paths (#569, #12154). A web or API read
3 * under an owner/name that a move left behind answers 301 (308 for a mutation)
4 * to the same path at the repository's new address, never cached: a mutation
5 * is never applied silently to the new place, the client re-sends it, knowing
6 * where.
67 * Only a caller who may read the repository learns where it went; anyone else
78 * keeps the 404 a missing path has always answered.
89 */
@@ -38,7 +39,7 @@ export function movedTarget(db: Database, url: URL): Repo | undefined {
3839 return at === undefined ? undefined : redirectFor(db, at.owner, at.name);
3940 }
4041
41 export function movedResponse(db: Database, url: URL, user: User | undefined): Response | undefined {
42 export function movedResponse(db: Database, url: URL, user: User | undefined, method = "GET"): Response | undefined {
4243 const at = repoPath(url.pathname);
4344 if (at === undefined) return undefined;
4445 const repo = redirectFor(db, at.owner, at.name);
@@ -54,5 +55,9 @@ export function movedResponse(db: Database, url: URL, user: User | undefined): R
5455 }
5556 const rest = at.rest === "" && !url.pathname.endsWith("/") ? "" : `/${at.rest}`;
5657 const location = `${at.prefix}${encodeURIComponent(repo.ownerName)}/${encodeURIComponent(repo.name)}${suffix}${rest}${url.search}`;
57 return new Response(null, { status: 308, headers: { location } });
58 // A read is moved for good (301; git follows it); a mutation is 308, so it
59 // is re-sent as it was, never turned into a GET.
60 const read = method === "GET" || method === "HEAD";
61 // Never cached: the redirect ends when another repository takes the old name.
62 return new Response(null, { status: read ? 301 : 308, headers: { location, "cache-control": "no-store" } });
5863 }
src/http/server.ts +3 −1
@@ -431,6 +431,7 @@ import {
431431 archiveSubmit,
432432 archiveConfirmed,
433433 transferSubmit,
434 renameSubmit,
434435 provenanceSubmit,
435436 mirrorStop,
436437 } from "../web/pages/settings.ts";
@@ -1603,6 +1604,7 @@ export function buildRouter(): Router<Handler> {
16031604 router.add("POST", "/:owner/:name/settings/wave", waveCut);
16041605 router.add("POST", "/:owner/:name/settings/archive", archiveSubmit);
16051606 router.add("POST", "/:owner/:name/archive", archiveConfirmed);
1607 router.add("POST", "/:owner/:name/settings/rename", renameSubmit);
16061608 router.add("POST", "/:owner/:name/settings/transfer", transferSubmit);
16071609 router.add("POST", "/:owner/:name/settings/provenance", provenanceSubmit);
16081610 router.add("POST", "/:owner/:name/settings/mirror/stop", mirrorStop);
@@ -3120,7 +3122,7 @@ async function answerGet(
31203122 if (adminRefusal !== undefined) return adminRefusal;
31213123
31223124 // A transferred repository's old path answers 308 to its new one (#569).
3123 const moved = movedResponse(app.db, url, auth.user);
3125 const moved = movedResponse(app.db, url, auth.user, request.method);
31243126 if (moved !== undefined) return moved;
31253127
31263128 // The two things standing between one runaway agent and everybody
src/tui/screens/transferrepo.ts +28 −1
@@ -5,13 +5,14 @@
55 * Only a person who administers the repository is offered it.
66 */
77
8 import { TRANSFER_SAID, transferRepository } from "../../actions/transfer.ts";
8 import { TRANSFER_SAID, renameRepository, transferRepository } from "../../actions/transfer.ts";
99 import { canAdmin, type Repo } from "../../core/repos.ts";
1010 import type { Ctx, Screen } from "../app/app.ts";
1111 import type { Command } from "../app/palette.ts";
1212 import type { Session } from "../session.ts";
1313 import { promptScreen } from "./common.ts";
1414
15 export const RENAME_REPO = "Rename this repository";
1516 export const TRANSFER_REPO = "Move this repository to another owner";
1617
1718 /** The prompt floats over whatever is already drawn; this is the empty backdrop it asks for. */
@@ -27,6 +28,32 @@ export function transferRepoCommands(f: Session, ctx: Ctx, repo: Repo | undefine
2728 if (!canAdmin(f.db, repo, f.user)) return [];
2829 const full = `${repo.ownerName}/${repo.name}`;
2930 return [
31 {
32 id: "rename-repository",
33 title: `${full} — ${RENAME_REPO}`,
34 hint: "the old address redirects to the new one",
35 run: () =>
36 ctx.push(
37 promptScreen(ctx, backdrop, {
38 title: RENAME_REPO,
39 label: `Renames ${full}; its old address keeps redirecting. New name:`,
40 placeholder: repo.name,
41 onSubmit: (typed) => {
42 const name = typed.trim();
43 if (name === "") return "Type the new name.";
44 return (async () => {
45 const renamed = await renameRepository(f.app, f.user, repo, name);
46 if (!renamed.ok) {
47 ctx.toast(renamed.error === "not_found" ? "no such repository" : TRANSFER_SAID[renamed.error], "danger");
48 return;
49 }
50 ctx.toast(`renamed ${full} to ${renamed.value.ownerName}/${renamed.value.name}`, "success");
51 ctx.leaveRepo(repo.id);
52 })();
53 },
54 }),
55 ),
56 },
3057 {
3158 id: "transfer-repository",
3259 title: `${full} — ${TRANSFER_REPO}`,
src/web/pages/settings.ts +40 −5
@@ -53,7 +53,7 @@ import {
5353 SCOPE_WORDS,
5454 } from "../../actions/app_env_words.ts";
5555 import { AUTONOMY_SAID, checkAutonomy, isDecisionsBy } from "../../core/repos.ts";
56 import { TRANSFER_SAID, TRANSFER_STATUS, transferRepository } from "../../actions/transfer.ts";
56 import { TRANSFER_SAID, TRANSFER_STATUS, renameRepository, transferRepository } from "../../actions/transfer.ts";
5757 import { updateRepoSettings } from "../../actions/repos.ts";
5858 import { appsRuntime } from "../../apps/runtime.ts";
5959 import { nameFrame, notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts";
@@ -238,7 +238,7 @@ export async function adminForm(
238238 export async function settingsPage(
239239 ctx: Ctx,
240240 /** A refused move: the owner as typed and the sentence to show beside it. */
241 refused?: { owner: string; said: string; status: number },
241 refused?: { owner: string; name?: string; said: string; status: number },
242242 ): Promise<Response> {
243243 nameFrame(ctx.request);
244244 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
@@ -350,21 +350,36 @@ export async function settingsPage(
350350 </div>
351351 </form>
352352 </section>`;
353 const rename = html`<form id="rename" method="post" action="${base}/settings/rename">
354 ${formField({
355 label: "New name",
356 labelHidden: true,
357 name: "name",
358 value: refused?.name ?? repo.name,
359 required: true,
360 maxlength: 100,
361 autocomplete: "off",
362 spellcheck: false,
363 mono: true,
364 ...(refused?.name !== undefined ? { error: refused.said } : {}),
365 })}
366 ${btn("Rename", { variant: "primary" })}
367 </form>`;
353368 const move = html`<section class="settings-block" id="move">
354369 <h2>Move to another owner</h2>
355 <p class="muted small">Hands the repository to someone else.</p>
370 ${rename}
356371 <form method="get" action="/-/confirm">
357372 <input type="hidden" name="to" value="${base}/settings/transfer" />
358373 ${formField({
359374 label: "New owner",
360375 name: "owner",
361 value: refused?.owner ?? "",
376 value: refused?.name === undefined ? (refused?.owner ?? "") : "",
362377 required: true,
363378 maxlength: 64,
364379 autocomplete: "off",
365380 spellcheck: false,
366381 mono: true,
367 ...(refused ? { error: refused.said } : {}),
382 ...(refused !== undefined && refused.name === undefined ? { error: refused.said } : {}),
368383 })}
369384 <div class="form-actions">${btn("Move", { variant: "danger" })}</div>
370385 </form>
@@ -549,6 +564,26 @@ export async function transferSubmit(ctx: Ctx): Promise<Response> {
549564 });
550565 }
551566
567 /**
568 * POST /:owner/:name/settings/rename (#12154) — give the repository a new
569 * name, through the action. A refusal is the settings page again with the
570 * sentence beside the field; success lands on the new address.
571 */
572 export async function renameSubmit(ctx: Ctx): Promise<Response> {
573 const gate = await adminForm(ctx);
574 if (gate instanceof Response) return gate;
575 const { repo, form } = gate;
576 const name = (field(form, "name") ?? "").trim();
577 const renamed = await renameRepository(ctx.app, ctx.auth.user, repo, name.slice(0, 100));
578 if (!renamed.ok) {
579 if (renamed.error === "not_found") return repoNotFound(ctx);
580 return settingsPage(ctx, { owner: "", name: name.slice(0, 100), said: TRANSFER_SAID[renamed.error], status: TRANSFER_STATUS[renamed.error] });
581 }
582 return flashTo(ctx, `${repoBase(renamed.value)}/settings`, {
583 message: `Renamed to ${renamed.value.ownerName}/${renamed.value.name}.`,
584 });
585 }
586
552587 /**
553588 * "Show where this came from" (#3293), drawn only for a repository that
554589 * has a trail to show: the line as it reads, and the one switch for it.
test/moved-git.test.ts +2 −2
@@ -72,9 +72,9 @@ describe("git through a moved repository's old URL", () => {
7272 await rm(dataDir, { recursive: true, force: true });
7373 });
7474
75 test("the advertisement answers 308 to the same path under the new owner", async () => {
75 test("the advertisement answers 301 to the same path under the new owner", async () => {
7676 const res = await fetch(`${servers.url}/alice/demo.git/info/refs?service=git-upload-pack`, { redirect: "manual" });
77 expect(res.status).toBe(308);
77 expect(res.status).toBe(301);
7878 expect(res.headers.get("location")).toBe("/bob/demo.git/info/refs?service=git-upload-pack");
7979 });
8080
test/repo-rename-doors.test.ts added +163
@@ -0,0 +1,163 @@
1 /**
2 * The doors to a rename (#12154) beside the API's: the Settings form, the MCP
3 * tool and the redirect's caching. Each refusal changes nothing.
4 */
5 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6 import { mkdtemp, rm } from "node:fs/promises";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { createApp, type App } from "../src/app.ts";
10 import { createRepo } from "../src/actions/repos.ts";
11 import { TRANSFER_SAID } from "../src/actions/transfer.ts";
12 import { getRepo } from "../src/core/repos.ts";
13 import { createToken } from "../src/core/tokens.ts";
14 import { createUser } from "../src/core/users.ts";
15 import { serve, type Servers } from "../src/http/server.ts";
16 import { quietLogs } from "./helpers/globals.ts";
17
18 quietLogs();
19
20 const PASSWORD = "a-long-rename-doors-passphrase";
21
22 describe("renaming through the web form and MCP", () => {
23 let dataDir = "";
24 let app: App;
25 let servers: Servers;
26 const cookies: Record<string, string> = {};
27 const tokens: Record<string, string> = {};
28 let id = 1;
29
30 const post = (path: string, who: string, fields: Record<string, string>, origin: string | false = servers.url) =>
31 fetch(`${servers.url}${path}`, {
32 method: "POST",
33 redirect: "manual",
34 headers: { cookie: cookies[who]!, ...(origin ? { origin } : {}) },
35 body: new URLSearchParams(fields),
36 });
37 const mcp = async (who: string, args: unknown) => {
38 const res = await fetch(`${servers.url}/mcp`, {
39 method: "POST",
40 headers: {
41 "content-type": "application/json",
42 accept: "application/json, text/event-stream",
43 authorization: `Bearer ${tokens[who]}`,
44 },
45 body: JSON.stringify({
46 jsonrpc: "2.0",
47 id: id++,
48 method: "tools/call",
49 params: { name: "repo_settings", arguments: args },
50 }),
51 });
52 const body = (await res.json()) as any;
53 return { text: body.result.content[0].text as string, isError: body.result.isError === true };
54 };
55
56 beforeAll(async () => {
57 dataDir = await mkdtemp(join(tmpdir(), "forge-rename-doors-"));
58 app = createApp({ dataDir, port: 0 });
59 servers = await serve(app);
60 for (const username of ["alice", "bob"]) {
61 const u = await createUser(app.db, {
62 origin: "seed",
63 trusted: true,
64 username,
65 email: `${username}@forge.local`,
66 password: PASSWORD,
67 adminIfFirst: username === "alice",
68 });
69 if (!u.ok) throw new Error(u.error);
70 tokens[username] = createToken(app.db, u.value.id, "admin", "admin").token;
71 const res = await fetch(`${servers.url}/signin`, {
72 method: "POST",
73 body: new URLSearchParams({ username, password: PASSWORD }),
74 redirect: "manual",
75 });
76 cookies[username] = (res.headers.get("set-cookie") ?? "").split(";")[0]!;
77 if (username === "alice") {
78 for (const name of ["web", "webtaken", "tool", "toolb", "patchy"]) {
79 const r = await createRepo(app, u.value, { name, visibility: "public" });
80 if (!r.ok) throw new Error(String(r.error));
81 }
82 }
83 }
84 });
85
86 afterAll(async () => {
87 await servers.stop();
88 app.db.close();
89 await rm(dataDir, { recursive: true, force: true });
90 });
91
92 test("web: a stranger gets 404 and a cross-origin post is refused; nothing is renamed", async () => {
93 expect((await post("/alice/web/settings/rename", "bob", { name: "x1" })).status).toBe(404);
94 expect((await post("/alice/web/settings/rename", "alice", { name: "x1" }, "https://evil.example")).status).toBe(403);
95 expect(getRepo(app.db, "alice", "web")).toBeDefined();
96 expect(getRepo(app.db, "alice", "x1")).toBeUndefined();
97 });
98
99 test("web: a refusal shows its sentence beside the field, with the typed name kept", async () => {
100 const taken = await post("/alice/web/settings/rename", "alice", { name: "webtaken" });
101 expect(taken.status).toBe(409);
102 const text = await taken.text();
103 expect(text).toContain(TRANSFER_SAID.name_taken);
104 expect(text).toContain('value="webtaken"');
105 const bad = await post("/alice/web/settings/rename", "alice", { name: "no/slash" });
106 expect(bad.status).toBe(422);
107 expect(await bad.text()).toContain("starts with a letter or digit");
108 expect(getRepo(app.db, "alice", "web")).toBeDefined();
109 });
110
111 test("web: a rename lands on Settings at the new address with a flash", async () => {
112 const res = await post("/alice/web/settings/rename", "alice", { name: "webbed" });
113 expect(res.status).toBe(303);
114 expect(res.headers.get("location")).toContain("/alice/webbed/settings");
115 expect(getRepo(app.db, "alice", "webbed")).toBeDefined();
116 // The flash rides a cookie the redirect sets; the browser sends it back.
117 const flash = (res.headers.get("set-cookie") ?? "").split(";")[0]!;
118 const page = await fetch(`${servers.url}/alice/webbed/settings`, {
119 headers: { cookie: `${cookies.alice!}; ${flash}` },
120 });
121 expect(await page.text()).toContain("Renamed to alice/webbed.");
122 });
123
124 test("the old address redirects and is never cached", async () => {
125 const res = await fetch(`${servers.url}/alice/web`, { redirect: "manual", headers: { cookie: cookies.alice! } });
126 expect(res.status).toBe(301);
127 expect(res.headers.get("location")).toBe("/alice/webbed");
128 expect(res.headers.get("cache-control")).toBe("no-store");
129 });
130
131 test("mcp: an admin renames; the old name no longer finds the repository", async () => {
132 const res = await mcp("alice", { repo: "alice/tool", name: "tooled" });
133 expect(res.isError).toBe(false);
134 expect(JSON.parse(res.text)).toMatchObject({ repo: "alice/tooled" });
135 expect(getRepo(app.db, "alice", "tooled")).toBeDefined();
136 expect(getRepo(app.db, "alice", "tool")).toBeUndefined();
137 });
138
139 test("mcp: name with another field is invalid and renames nothing", async () => {
140 const res = await mcp("alice", { repo: "alice/toolb", name: "toolc", description: "x" });
141 expect(res.isError).toBe(true);
142 expect(res.text).toContain("invalid");
143 expect(getRepo(app.db, "alice", "toolb")).toBeDefined();
144 expect(getRepo(app.db, "alice", "toolc")).toBeUndefined();
145 });
146
147 test("mcp: a non-admin is refused", async () => {
148 const res = await mcp("bob", { repo: "alice/toolb", name: "toolc" });
149 expect(res.isError).toBe(true);
150 expect(getRepo(app.db, "alice", "toolb")).toBeDefined();
151 });
152
153 test("api: name with another field is invalid and neither is applied", async () => {
154 const res = await fetch(`${servers.url}/api/v1/repos/alice/patchy`, {
155 method: "PATCH",
156 headers: { authorization: `Bearer ${tokens.alice}`, "content-type": "application/json" },
157 body: JSON.stringify({ name: "patched", description: "changed" }),
158 });
159 expect(res.status).toBe(422);
160 expect(getRepo(app.db, "alice", "patchy")?.description).not.toBe("changed");
161 expect(getRepo(app.db, "alice", "patched")).toBeUndefined();
162 });
163 });
test/repo-rename.test.ts added +163
@@ -0,0 +1,163 @@
1 /**
2 * Renaming a repository (#12154): everything keyed by its id keeps working;
3 * the old address redirects.
4 */
5 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6 import { mkdtemp, rm } from "node:fs/promises";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { createApp, type App } from "../src/app.ts";
10 import { openPull } from "../src/actions/pulls.ts";
11 import { openIssue } from "../src/actions/issues.ts";
12 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
13 import { renameRepository } from "../src/actions/transfer.ts";
14 import { createApp as createAppRow, getAppByRepo } from "../src/core/apps.ts";
15 import { forkRepo, getRepo, getRepoById, listForks, type Repo } from "../src/core/repos.ts";
16 import { transfersOf } from "../src/core/transfer.ts";
17 import { createToken } from "../src/core/tokens.ts";
18 import { createUser, type User } from "../src/core/users.ts";
19 import { runGit } from "../src/git/env.ts";
20 import { repoDir } from "../src/git/repos.ts";
21 import { serve, type Servers } from "../src/http/server.ts";
22 import { quietLogs } from "./helpers/globals.ts";
23
24 quietLogs();
25
26 describe("renaming a repository", () => {
27 let dataDir = "";
28 let app: App;
29 let servers: Servers;
30 let alice: User;
31 let bob: User;
32 let repo: Repo;
33 let fork: Repo;
34 let aliceRw = "";
35 let bobRw = "";
36 let pullNumber = 0;
37 let issueNumber = 0;
38
39 const call = (path: string, token: string, method = "GET", payload?: unknown) =>
40 fetch(`${servers.url}${path}`, {
41 method,
42 redirect: "manual",
43 headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
44 ...(payload === undefined ? {} : { body: JSON.stringify(payload) }),
45 });
46
47 beforeAll(async () => {
48 dataDir = await mkdtemp(join(tmpdir(), "forge-rename-"));
49 app = createApp({ dataDir, port: 0 });
50 servers = await serve(app);
51 const made = async (username: string, admin: boolean) => {
52 const u = await createUser(app.db, {
53 origin: "seed",
54 trusted: true,
55 username,
56 email: `${username}@forge.local`,
57 password: "a-long-rename-passphrase",
58 adminIfFirst: admin,
59 });
60 if (!u.ok) throw new Error(u.error);
61 return u.value;
62 };
63 alice = await made("alice", true);
64 bob = await made("bob", false);
65 aliceRw = createToken(app.db, alice.id, "admin", "admin").token;
66 bobRw = createToken(app.db, bob.id, "admin", "admin").token;
67 const r = await createRepo(app, alice, { name: "mitosis", visibility: "public" });
68 if (!r.ok) throw new Error(String(r.error));
69 repo = r.value;
70 await commitToBranch(app, alice, repo, { message: "seed", files: [{ path: "README.md", content: "# m\n" }] });
71 await commitToBranch(app, alice, repo, {
72 branch: "topic",
73 base: "main",
74 message: "topic",
75 files: [{ path: "T.md", content: "t\n" }],
76 });
77 const pull = await openPull(app, alice, repo, { title: "Topic", body: "", baseBranch: "main", headBranch: "topic" });
78 if (!pull.ok) throw new Error(String(pull.error));
79 pullNumber = pull.value.number;
80 const issue = openIssue(app, alice, repo, { title: "Bookmarked" });
81 if (!issue.ok) throw new Error(String(issue.error));
82 issueNumber = issue.value.number;
83 const row = createAppRow(app.db, { repoId: repo.id, slug: "mitosis", access: "public" });
84 if (!row.ok) throw new Error(row.error);
85 const forked = await forkRepo(app.db, app.reposRoot, repo, bob.id);
86 if (!forked.ok) throw new Error(String(forked.error));
87 fork = forked.value;
88 }, 30_000);
89
90 afterAll(async () => {
91 servers.stop();
92 app.close();
93 await rm(dataDir, { recursive: true, force: true });
94 });
95
96 test("refusals: a stranger, a bad name, the same name, a taken name — nothing changes", async () => {
97 const other = await createRepo(app, alice, { name: "taken", visibility: "public" });
98 if (!other.ok) throw new Error(String(other.error));
99 expect((await call("/api/v1/repos/alice/mitosis", bobRw, "PATCH", { name: "x" })).status).toBe(404);
100 for (const name of ["../x", "a b", "", 7, ".git"]) {
101 expect((await call("/api/v1/repos/alice/mitosis", aliceRw, "PATCH", { name })).status).toBe(422);
102 }
103 expect((await call("/api/v1/repos/alice/mitosis", aliceRw, "PATCH", { name: "taken" })).status).toBe(409);
104 expect(getRepo(app.db, "alice", "mitosis")?.id).toBe(repo.id);
105 expect((await renameRepository(app, bob, repo, "nope")).ok).toBe(false);
106 });
107
108 test("an owner renames it by the API; pull, issue, fork, app and git all still resolve", async () => {
109 const res = await call("/api/v1/repos/alice/mitosis", aliceRw, "PATCH", { name: "cell" });
110 expect(res.status).toBe(200);
111 expect(getRepoById(app.db, repo.id)?.name).toBe("cell");
112 expect(transfersOf(app.db, repo.id).map((t) => t.to)).toEqual(["alice/cell"]);
113
114 expect((await call(`/api/v1/repos/alice/cell/pulls/${pullNumber}`, aliceRw)).status).toBe(200);
115 expect((await call(`/api/v1/repos/alice/cell/issues/${issueNumber}`, aliceRw)).status).toBe(200);
116 expect(getAppByRepo(app.db, repo.id)?.slug).toBe("mitosis");
117 expect(listForks(app.db, repo.id).map((f) => f.id)).toEqual([fork.id]);
118
119 // The bare repository moved, and the fork's borrowed objects follow it.
120 const log = await runGit(["log", "--format=%s", "main"], { cwd: repoDir(app.reposRoot, "alice", "cell") });
121 expect(log.ok).toBe(true);
122 const inFork = await runGit(["log", "--format=%s", "main"], { cwd: repoDir(app.reposRoot, "bob", "mitosis") });
123 expect(inFork.ok && inFork.value.trim()).toBe("seed");
124 });
125
126 test("the old addresses redirect for good; a mutation is re-sent; a push is refused naming the new path", async () => {
127 const page = await call("/alice/mitosis", aliceRw);
128 expect(page.status).toBe(301);
129 expect(page.headers.get("location")).toBe("/alice/cell");
130 const api = await call(`/api/v1/repos/alice/mitosis/pulls/${pullNumber}?x=1`, aliceRw);
131 expect(api.status).toBe(301);
132 expect(api.headers.get("location")).toBe(`/api/v1/repos/alice/cell/pulls/${pullNumber}?x=1`);
133 const git = await fetch(`${servers.url}/alice/mitosis.git/info/refs?service=git-upload-pack`, { redirect: "manual" });
134 expect(git.status).toBe(301);
135 expect(git.headers.get("location")).toBe("/alice/cell.git/info/refs?service=git-upload-pack");
136 const post = await call("/api/v1/repos/alice/mitosis/issues", aliceRw, "POST", { title: "x" });
137 expect(post.status).toBe(308);
138 const push = await fetch(`${servers.url}/alice/mitosis.git/info/refs?service=git-receive-pack`, { redirect: "manual" });
139 expect(push.status).toBe(403);
140 expect(await push.text()).toContain("moved to alice/cell");
141 });
142
143 test("a second rename keeps the first old address; another repository taking the name ends it", async () => {
144 expect((await call("/api/v1/repos/alice/cell", aliceRw, "PATCH", { name: "organelle" })).status).toBe(200);
145 for (const old of ["mitosis", "cell"]) {
146 const res = await call(`/alice/${old}`, aliceRw);
147 expect(res.status).toBe(301);
148 expect(res.headers.get("location")).toBe("/alice/organelle");
149 }
150 const taker = await createRepo(app, alice, { name: "mitosis", visibility: "public" });
151 if (!taker.ok) throw new Error(String(taker.error));
152 expect((await call("/alice/mitosis", aliceRw)).status).toBe(200);
153 expect(getRepo(app.db, "alice", "organelle")?.id).toBe(repo.id);
154 });
155
156 test("a case-only rename is allowed", async () => {
157 const res = await call("/api/v1/repos/alice/organelle", aliceRw, "PATCH", { name: "Organelle" });
158 expect(res.status).toBe(200);
159 expect(getRepoById(app.db, repo.id)?.name).toBe("Organelle");
160 const log = await runGit(["log", "--format=%s", "main"], { cwd: repoDir(app.reposRoot, "alice", "Organelle") });
161 expect(log.ok).toBe(true);
162 });
163 });
test/repo-transfer-follow.test.ts +1 −1
@@ -225,7 +225,7 @@ describe("a moved repository's fork, preview, queued deploy and remotes follow i
225225 const token = mintRunToken(app.db, carol.id, "r1", { repoId: before.id, forkId: fork.id }, "2999-01-01T00:00:00Z").token;
226226 const headers = { authorization: `Bearer ${token}` };
227227 const old = await fetch(`${servers.url}/api/v1/repos/alice/lab`, { headers, redirect: "manual" });
228 expect(old.status).toBe(308);
228 expect(old.status).toBe(301);
229229 expect(old.headers.get("location")).toBe("/api/v1/repos/bob/lab");
230230 const there = await fetch(`${servers.url}/api/v1/repos/bob/lab`, { headers });
231231 expect(there.status).toBe(200);
test/repo-transfer-web.test.ts +1 −1
@@ -127,6 +127,6 @@ describe("moving a repository from Settings", () => {
127127 expect(res.status).toBe(303);
128128 expect(res.headers.get("location")).toContain("/lab/notes/settings");
129129 expect((await get("/lab/notes/settings", "alice")).status).toBe(200);
130 expect((await get("/alice/notes", "alice")).status).toBe(308);
130 expect((await get("/alice/notes", "alice")).status).toBe(301);
131131 });
132132 });
test/repo-transfer.test.ts +7 −7
@@ -139,18 +139,18 @@ describe("transferring a repository", () => {
139139 expect(JSON.parse(event!.payload)).toMatchObject({ from: "alice/notes", to: "lab/notes" });
140140 });
141141
142 test("the old page, API path, issue and pull answer 308 to the new ones", async () => {
142 test("the old page, API path, issue and pull answer 301 to the new ones", async () => {
143143 const page = await call("/alice/notes", aliceRw);
144 expect(page.status).toBe(308);
144 expect(page.status).toBe(301);
145145 expect(page.headers.get("location")).toBe("/lab/notes");
146146 const api = await call("/api/v1/repos/alice/notes?x=1", aliceRw);
147 expect(api.status).toBe(308);
147 expect(api.status).toBe(301);
148148 expect(api.headers.get("location")).toBe("/api/v1/repos/lab/notes?x=1");
149149 const issue = await call(`/alice/notes/issues/${issueNumber}`, aliceRw);
150 expect(issue.status).toBe(308);
150 expect(issue.status).toBe(301);
151151 expect(issue.headers.get("location")).toBe(`/lab/notes/issues/${issueNumber}`);
152152 const pull = await call(`/api/v1/repos/alice/notes/pulls/${pullNumber}`, aliceRw);
153 expect(pull.status).toBe(308);
153 expect(pull.status).toBe(301);
154154 expect(pull.headers.get("location")).toBe(`/api/v1/repos/lab/notes/pulls/${pullNumber}`);
155155 // The new paths answer.
156156 expect((await call(`/api/v1/repos/lab/notes/pulls/${pullNumber}`, aliceRw)).status).toBe(200);
@@ -177,7 +177,7 @@ describe("transferring a repository", () => {
177177 const hidden = await createRepo(app, alice, { name: "hidden", visibility: "private" });
178178 if (!hidden.ok) throw new Error(String(hidden.error));
179179 expect((await call("/api/v1/repos/alice/hidden/transfer", aliceRw, "POST", { newOwner: "lab" })).status).toBe(200);
180 expect((await call("/api/v1/repos/alice/hidden", aliceRw)).status).toBe(308);
180 expect((await call("/api/v1/repos/alice/hidden", aliceRw)).status).toBe(301);
181181 for (const path of ["/api/v1/repos/alice/hidden", "/alice/hidden"]) {
182182 const res = await call(path, bobRw);
183183 expect(res.status).toBe(404);
@@ -191,7 +191,7 @@ describe("transferring a repository", () => {
191191 expect((await call("/api/v1/repos/lab/notes/transfer", aliceRw, "POST", { newOwner: "lab2" })).status).toBe(200);
192192 for (const old of ["alice", "lab"]) {
193193 const res = await call(`/${old}/notes/issues/${issueNumber}`, aliceRw);
194 expect(res.status).toBe(308);
194 expect(res.status).toBe(301);
195195 expect(res.headers.get("location")).toBe(`/lab2/notes/issues/${issueNumber}`);
196196 }
197197 expect(transfersOf(app.db, repo.id).map((t) => t.to)).toEqual(["lab/notes", "lab2/notes"]);
test/tui-transfer-repo.test.ts +14
@@ -128,6 +128,19 @@ describe("move a repository in the terminal", () => {
128128 h.stop();
129129 });
130130
131 test("typing a new name in the rename prompt renames the repository", async () => {
132 const repo = await mkRepo(undefined, "before");
133 const h = open(alice, repo);
134 await h.feed(":", ..."Rename this");
135 await until(h, () => h.text().includes("the old address redirects"), "the palette row");
136 await h.feed("enter");
137 await h.feed(..."after", "enter");
138 await until(h, () => h.text().includes("renamed alice/before"), "the rename's receipt");
139 expect(getRepoById(app.db, repo.id)?.name).toBe("after");
140 expect(getRepo(app.db, "alice", "after")?.id).toBe(repo.id);
141 h.stop();
142 });
143
131144 test("a refusal is said in the API's words and the repository stays", async () => {
132145 await mkRepo("lab", "taken");
133146 const repo = await mkRepo(undefined, "taken");
@@ -155,6 +168,7 @@ describe("move a repository in the terminal", () => {
155168 }
156169 const h = open(alice, shop);
157170 expect(transferRepoCommands(h.session, h.ctx, shop).map((c) => c.title)).toEqual([
171 "acme/shop — Rename this repository",
158172 "acme/shop — Move this repository to another owner",
159173 ]);
160174 h.stop();

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs