Public: anyone can read this; the crew can change it.
An admin changes the default branch from the terminal's About card with `b`, picking from the repository's branches #8302
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8067-an-admin-changes-the-default-branch-from → main 2 commits fork 3 threads · 3 unresolved
3 files
changed +250
−3
README.md +1
| @@ -508,6 +508,7 @@ This page is the front door. Everything else is a file of its own under | ||
| 508 | 508 | - Outsiders directing the crew: the terminal's About card (`I`) has a Crew row saying whether people who cannot write may direct the crew (the web page's words). A repository admin who is a person presses `d` to see the plan, then enter to confirm (esc changes nothing); it calls `setCrewOutsiders`, as MCP `outsiders` does. The key is `d`, not `o`, because `o` is the fork's upstream offer on the same card and `d` never clashes with it; a writer who is not an admin and an agent are not offered `d`. |
| 509 | 509 | - Visibility at the terminal: on the About card (`I`) a repository admin who is a person sees `V make private` on a public repository and `V make public` on a private one. `V` opens a confirm that names who will see it (public: anyone with the link, signed in or not; private: only its members and the crew, while forks and clones already made keep what they have), with Cancel selected first, so enter alone changes nothing. Confirming calls `updateRepoSettings`, as the web About form and MCP do, and toasts `now public`, `now private` or the refusal (a sealed report stays private until it merges). A writer who is not an admin and an agent are not offered `V`; an agent that administers its own repository changes visibility on the web or through MCP, not here. |
| 510 | 510 | - Description at the terminal: on the About card (`I`) a repository admin who is a person sees `w write description`. `w` opens a one-line prompt filled with the current description; enter saves it through `updateRepoSettings` (as the web form and MCP `repo_settings` do, so it is audited the same way) and toasts `description saved`, esc changes nothing, and an empty line clears it. Control characters and more than 2000 characters are refused in a toast and nothing is written. That cap and the control-character rule live in the terminal only: the web form and the API accept any text. A reader and an agent are not offered `w`, and pressing it does nothing. |
| 511 | - Default branch at the terminal: on the About card (`I`) a repository admin who is a person sees `b default branch`; a writer who is not an admin and an agent are not offered it, and pressing it does nothing. `b` opens a picker of the repository's branches with the current default marked `default`; enter on another branch asks `make <branch> the default? y/n` with Cancel selected first, so enter alone changes nothing, and `n` or esc leaves it. `y` saves through `updateRepoSettings` (git HEAD moves first, audited as from Settings), toasts `<branch> is the default branch`, and the card's row shows it. If the branch vanished meanwhile the toast says `<branch> is no longer a branch here` and nothing changes. | |
| 511 | 512 | - Prompt source at the terminal: `i` on the Deploys screen opens the app's Instagram prompt source, as Settings › Prompts does, and is offered only to the repository's admin who is a person (an agent, a reader or a guest sees no `i` and it does nothing). It lists the account, when it last ran and what it said, and when the token is refreshed again. `a` connects one (the token typed masked, then the hour in UTC; it is never drawn or logged), `f` fetches now and says what came back, `d` removes it after a `y/n` confirm. A second `f` while a fetch is running is ignored. Refusals are the page's words (`invalid_token`, `invalid_at`, `no_such_source`, `not_running`, and `already_connected` only when an account was connected on the web while the screen was open: the screen offers `a` only when there is none). |
| 512 | 513 | - Stop running as an app at the terminal: `X` on the Deploys screen, offered only to the repository's admin who is a person while it is an app (a writer who is not an admin, an agent, a reader or a guest sees no `X` and it does nothing). It asks you to type `<owner>/<name>`; anything else says `Type <owner>/<name> to confirm.` and changes nothing. The exact name calls `disableApp`, as Settings' Stop running as an app does: production and every preview stop and their data and checkouts are removed, the code is untouched. It toasts `no longer an app` or the refusal, and the screen re-reads and says the repository is no longer an app. |
| 513 | 514 | - Everyday account at the terminal: a forge admin with no choice made sees a muted line at the head of the dashboard asking, as the web home card does. `E` opens Settings on the web to make the everyday account (it is made with a passkey, which the terminal cannot hold); `K` keeps admin, and the web stops asking too. Once linked, the line names the everyday account. Anyone else, and an agent, sees neither key. |
src/tui/screens/about.ts +57 −3
| @@ -71,7 +71,7 @@ import { style } from "../term/style.ts"; | ||
| 71 | 71 | import { truncate, wrap } from "../term/width.ts"; |
| 72 | 72 | import { badge, card } from "../ui/widgets.ts"; |
| 73 | 73 | import type { Session } from "../session.ts"; |
| 74 | import { PIN_MARK, ago, confirmScreen, drawFacts, plural, promptScreen } from "./common.ts"; | |
| 74 | import { PIN_MARK, ago, confirmScreen, drawFacts, later, pickerScreen, plural, promptScreen, yesNo } from "./common.ts"; | |
| 75 | 75 | import { connectionsScreen, egressScreen } from "./egress.ts"; |
| 76 | 76 | import { eventsScreen } from "./events.ts"; |
| 77 | 77 | import { newPullScreen } from "./newpull.ts"; |
| @@ -128,7 +128,7 @@ export function aboutScreen( | ||
| 128 | 128 | ...movingRow(f, repo), |
| 129 | 129 | ...outsidersRow(repo), |
| 130 | 130 | ...seatRows(f, repo), |
| 131 | ...aboutFacts(f, src, facts, ref), | |
| 131 | ...aboutFacts(f, src, facts, ref, repo), | |
| 132 | 132 | ...paceRows(f, repo), |
| 133 | 133 | ...trailRows(f, repo), |
| 134 | 134 | ...upstreamRows(f, repo), |
| @@ -198,6 +198,7 @@ export function aboutScreen( | ||
| 198 | 198 | ] |
| 199 | 199 | : []), |
| 200 | 200 | ...(mayPerson() ? [binding(["w"], "write description")] : []), |
| 201 | ...(mayPerson() ? [binding(["b"], "default branch")] : []), | |
| 201 | 202 | ...(mayOutsiders() |
| 202 | 203 | ? [ |
| 203 | 204 | binding( |
| @@ -513,6 +514,57 @@ export function aboutScreen( | ||
| 513 | 514 | ); |
| 514 | 515 | return true; |
| 515 | 516 | } |
| 517 | if (e.key === "b" && repo !== undefined && mayPerson()) { | |
| 518 | const at = repo; | |
| 519 | void listRefs(repoDir(f.app.reposRoot, at.ownerName, at.name), "refs/heads/").then((refs) => { | |
| 520 | if (!refs.ok) return ctx.toast("the branches could not be read", "danger"); | |
| 521 | const items = refs.value.map((r) => r.name.replace(/^refs\/heads\//, "")).sort().map((name) => ({ | |
| 522 | id: name, | |
| 523 | label: name, | |
| 524 | ...(name === at.defaultBranch ? { hint: "default" } : {}), | |
| 525 | })); | |
| 526 | ctx.push( | |
| 527 | pickerScreen(ctx, screen, { | |
| 528 | title: "Default branch", | |
| 529 | items, | |
| 530 | onPick: ([branch]) => { | |
| 531 | if (branch === undefined || branch === at.defaultBranch) return; | |
| 532 | const choose = (choice: number): void => { | |
| 533 | if (choice !== 0) return; | |
| 534 | void updateRepoSettings(f.app, f.user, at, { defaultBranch: branch }).then((done) => { | |
| 535 | if (done.ok) { | |
| 536 | repo = getRepoById(f.db, done.value.id) ?? done.value; | |
| 537 | rows = factRows(); | |
| 538 | ctx.toast(`${branch} is the default branch`, "success"); | |
| 539 | } else | |
| 540 | ctx.toast( | |
| 541 | done.error === "unknown_branch" | |
| 542 | ? `${branch} is no longer a branch here` | |
| 543 | : "that could not be changed", | |
| 544 | "danger", | |
| 545 | ); | |
| 546 | ctx.invalidate(); | |
| 547 | }); | |
| 548 | }; | |
| 549 | void later(ctx, () => | |
| 550 | yesNo( | |
| 551 | ctx, | |
| 552 | confirmScreen(ctx, screen, { | |
| 553 | title: "Default branch", | |
| 554 | body: [`make ${branch} the default? y/n`], | |
| 555 | actions: [{ label: "Make default" }, { label: "Cancel", variant: "ghost" }], | |
| 556 | selected: 1, | |
| 557 | onChoose: choose, | |
| 558 | }), | |
| 559 | choose, | |
| 560 | ), | |
| 561 | ); | |
| 562 | }, | |
| 563 | }), | |
| 564 | ); | |
| 565 | }); | |
| 566 | return true; | |
| 567 | } | |
| 516 | 568 | if (e.key === "t" && repo !== undefined && mayTrail()) { |
| 517 | 569 | const shown = getProvenance(f.db, repo.id)?.shown === true; |
| 518 | 570 | const done = showProvenance(f.app, f.user, repo, !shown); |
| @@ -738,6 +790,7 @@ function aboutFacts( | ||
| 738 | 790 | src: RepoSource, |
| 739 | 791 | facts: RepoFacts | undefined, |
| 740 | 792 | ref: string, |
| 793 | current?: Repo, | |
| 741 | 794 | ): [string, string][] { |
| 742 | 795 | const repo = localRepo(src); |
| 743 | 796 | const counts: [string, string][] = |
| @@ -757,7 +810,8 @@ function aboutFacts( | ||
| 757 | 810 | .join(" · "), |
| 758 | 811 | ], |
| 759 | 812 | ]; |
| 760 | const branch = facts?.defaultBranch ?? repo?.defaultBranch ?? ""; | |
| 813 | // The card's own row first: `b` moves it under facts read before the card opened. | |
| 814 | const branch = current?.defaultBranch ?? facts?.defaultBranch ?? repo?.defaultBranch ?? ""; | |
| 761 | 815 | // Whose work this descends from. A fork at an origin is not `forkParentId` |
| 762 | 816 | // — that is a fork made *here* — so the badge above cannot say it and this |
| 763 | 817 | // row can. |
test/tui-about-default-branch.test.ts added +192
| @@ -0,0 +1,192 @@ | ||
| 1 | /** | |
| 2 | * The default branch at the terminal's door (#8067): on the About card an | |
| 3 | * admin presses `b`, picks one of the repository's branches, and `y` moves | |
| 4 | * git HEAD and the card's row through `updateRepoSettings`. `n` and esc change | |
| 5 | * nothing; a branch that vanished is a toast; a non-admin is not offered it. | |
| 6 | */ | |
| 7 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createBranch, deleteBranch } from "../src/actions/branches.ts"; | |
| 12 | import { createAgent } from "../src/actions/crew.ts"; | |
| 13 | import { commitToBranch } from "../src/actions/repos.ts"; | |
| 14 | import { createApp, type App } from "../src/app.ts"; | |
| 15 | import { addTeamMember, createOrg, createTeam } from "../src/core/orgs.ts"; | |
| 16 | import { createRepo, getRepoById, type Repo } from "../src/core/repos.ts"; | |
outdated
Merge with the createBranch import from the same module on line 11. |
||
| 17 | import { createUser, type User } from "../src/core/users.ts"; | |
| 18 | import { headBranch } from "../src/git/read.ts"; | |
| 19 | import { repoDir } from "../src/git/repos.ts"; | |
| 20 | import type { Ctx, Screen } from "../src/tui/app/app.ts"; | |
| 21 | import { aboutScreen } from "../src/tui/screens/about.ts"; | |
| 22 | import { Screen as Surface } from "../src/tui/term/screen.ts"; | |
| 23 | import { theme } from "../src/tui/ui/theme.ts"; | |
| 24 | import { localSource } from "../src/view/local.ts"; | |
| 25 | import type { Session } from "../src/tui/session.ts"; | |
| 26 | import { quietLogs } from "./helpers/globals.ts"; | |
| 27 | ||
| 28 | quietLogs(); | |
| 29 | ||
| 30 | let dataDir: string; | |
| 31 | let app: App; | |
| 32 | let founder: User; | |
| 33 | let writer: User; | |
| 34 | let bot: User; | |
| 35 | let botRepo: Repo; | |
| 36 | let repo: Repo; | |
| 37 | ||
| 38 | const ok = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => { | |
| 39 | if (!r.ok) throw new Error(JSON.stringify(r.error)); | |
| 40 | return r.value; | |
| 41 | }; | |
| 42 | async function until(what: string, done: () => boolean, ms = 5000): Promise<void> { | |
| 43 | const end = Date.now() + ms; | |
| 44 | while (!done()) { | |
| 45 | if (Date.now() > end) throw new Error(`timed out waiting for ${what}`); | |
| 46 | await Bun.sleep(10); | |
| 47 | } | |
| 48 | } | |
| 49 | const head = async () => { | |
| 50 | const h = await headBranch(repoDir(app.reposRoot, repo.ownerName, repo.name)); | |
| 51 | return h.ok ? h.value : ""; | |
| 52 | }; | |
| 53 | ||
| 54 | function card(user: User, target: Repo = repo) { | |
| 55 | const pushed: Screen[] = []; | |
| 56 | const toasts: string[] = []; | |
| 57 | const ctx = { | |
| 58 | theme: theme("dark"), | |
| 59 | invalidate() {}, | |
| 60 | toast: (t: string) => toasts.push(t), | |
| 61 | push: (s: Screen) => pushed.push(s), | |
| 62 | pop() {}, | |
| 63 | copy() {}, | |
| 64 | hit() {}, | |
| 65 | setCursor() {}, | |
| 66 | } as unknown as Ctx; | |
| 67 | const f = { app, db: app.db, user } as unknown as Session; | |
| 68 | const src = localSource(app, getRepoById(app.db, target.id)!, user); | |
| 69 | const screen = aboutScreen(f, ctx, { render() {} } as unknown as Screen, src, undefined, "main"); | |
| 70 | const label = (key: string) => screen.keys.find((b) => b.keys.includes(key))?.help; | |
| 71 | const press = (key: string) => screen.key!({ key } as never); | |
| 72 | const text = () => { | |
| 73 | const s = new Surface(120, 40); | |
| 74 | screen.render({ x: 0, y: 0, w: 120, h: 40 }, s); | |
| 75 | return s.toText(); | |
| 76 | }; | |
| 77 | return { pushed, toasts, label, press, text }; | |
| 78 | } | |
| 79 | const key = (s: Screen, k: string) => s.key!({ key: k } as never); | |
| 80 | ||
| 81 | beforeAll(async () => { | |
| 82 | dataDir = await mkdtemp(join(tmpdir(), "forge-tui-default-branch-")); | |
| 83 | app = createApp({ dataDir, port: 0 }); | |
| 84 | const make = async (username: string, adminIfFirst = false) => | |
| 85 | ok( | |
| 86 | await createUser(app.db, { | |
| 87 | origin: "seed", | |
| 88 | trusted: true, | |
| 89 | username, | |
| 90 | email: `${username}@forge.local`, | |
| 91 | password: "a-good-passphrase", | |
| 92 | adminIfFirst, | |
| 93 | }), | |
| 94 | ); | |
| 95 | const root = await make("founding", true); | |
| 96 | founder = await make("founder"); | |
| 97 | writer = await make("writer"); | |
| 98 | const org = ok(createOrg(app.db, "acme", founder.id)); | |
| 99 | repo = ok(await createRepo(app.db, app.reposRoot, { ownerId: org.id, name: "site", visibility: "public" })); | |
| 100 | addTeamMember(app.db, ok(createTeam(app.db, org.id, "Writers", "write")), writer.id); | |
| 101 | ok(await commitToBranch(app, founder, repo, { message: "seed", files: [{ path: "a.txt", content: "a\n" }] })); | |
| 102 | ok(await createBranch(app, founder, repo, { name: "trunk" })); | |
| 103 | ok(await createBranch(app, founder, repo, { name: "gone" })); | |
| 104 | bot = ok(await createAgent(app, root, { username: "bot" })).user; | |
| 105 | botRepo = ok(await createRepo(app.db, app.reposRoot, { ownerId: bot.id, name: "own", visibility: "public" })); | |
| 106 | }, 30_000); | |
| 107 | ||
| 108 | afterAll(async () => { | |
| 109 | app?.close(); | |
| 110 | await rm(dataDir, { recursive: true, force: true }); | |
| 111 | }); | |
| 112 | ||
| 113 | /** Press `b`, wait for the picker, move to `branch`, enter; returns the confirm. */ | |
| 114 | async function choose(c: ReturnType<typeof card>, branch: string): Promise<Screen> { | |
| 115 | c.press("b"); | |
| 116 | await until("the picker", () => c.pushed.length === 1); | |
| 117 | const picker = c.pushed[0]!; | |
| 118 | const s = new Surface(100, 30); | |
| 119 | picker.render({ x: 0, y: 0, w: 100, h: 30 }, s); | |
| 120 | const lines = s.toText().split("\n"); | |
| 121 | expect(lines.find((l) => l.includes("main"))).toContain("default"); | |
| 122 | // Branches are sorted: gone, main, trunk. | |
| 123 | const order = ["gone", "main", "trunk"]; | |
| 124 | for (let i = 0; i < order.indexOf(branch); i++) key(picker, "j"); | |
| 125 | key(picker, "enter"); | |
| 126 | await until("the confirm", () => c.pushed.length === 2); | |
| 127 | return c.pushed[1]!; | |
| 128 | } | |
| 129 | ||
| 130 | test("n and esc change nothing", async () => { | |
| 131 | const c = card(founder); | |
| 132 | const confirm = await choose(c, "trunk"); | |
| 133 | const s = new Surface(100, 30); | |
| 134 | confirm.render({ x: 0, y: 0, w: 100, h: 30 }, s); | |
| 135 | expect(s.toText()).toContain("make trunk the default? y/n"); | |
| 136 | key(confirm, "n"); | |
| 137 | await Bun.sleep(30); | |
| 138 | expect(await head()).toBe("main"); | |
| 139 | expect(getRepoById(app.db, repo.id)!.defaultBranch).toBe("main"); | |
| 140 | expect(c.text()).toContain("main"); | |
| 141 | // esc on the picker, too. | |
| 142 | const d = card(founder); | |
| 143 | d.press("b"); | |
| 144 | await until("the picker", () => d.pushed.length === 1); | |
| 145 | key(d.pushed[0]!, "esc"); | |
| 146 | await Bun.sleep(30); | |
| 147 | expect(d.pushed).toHaveLength(1); | |
| 148 | expect(await head()).toBe("main"); | |
| 149 | }); | |
| 150 | ||
| 151 | test("a branch that vanished is a toast and the card is unchanged", async () => { | |
| 152 | const c = card(founder); | |
| 153 | const confirm = await choose(c, "gone"); | |
| 154 | ok(await deleteBranch(app, founder, getRepoById(app.db, repo.id)!, "gone")); | |
| 155 | key(confirm, "y"); | |
| 156 | await until("the toast", () => c.toasts.length > 0); | |
| 157 | expect(c.toasts[0]).toContain("no longer a branch"); | |
outdated
Whitespace-only line; remove. Also assert the toast reads |
||
| 158 | expect(await head()).toBe("main"); | |
| 159 | expect(getRepoById(app.db, repo.id)!.defaultBranch).toBe("main"); | |
| 160 | }); | |
| 161 | ||
| 162 | test("an admin chooses trunk and y moves HEAD and the row", async () => { | |
| 163 | const c = card(founder); | |
| 164 | expect(c.label("b")).toBe("default branch"); | |
| 165 | const confirm = await choose(c, "trunk"); | |
| 166 | key(confirm, "y"); | |
| 167 | ||
| 168 | await until("the setting", () => getRepoById(app.db, repo.id)!.defaultBranch === "trunk"); | |
| 169 | expect(await head()).toBe("trunk"); | |
| 170 | await until("the toast", () => c.toasts.length > 0); | |
| 171 | expect(c.toasts).toEqual(["trunk is the default branch"]); | |
| 172 | expect(c.text()).toMatch(/default branch\s+trunk/); | |
| 173 | // The picker marks the new default, not the old one. | |
| 174 | const again = card(founder); | |
| 175 | again.press("b"); | |
| 176 | await until("the picker", () => again.pushed.length === 1); | |
| 177 | const s = new Surface(100, 30); | |
| 178 | again.pushed[0]!.render({ x: 0, y: 0, w: 100, h: 30 }, s); | |
| 179 | const lines = s.toText().split("\n"); | |
| 180 | expect(lines.find((l) => l.includes("trunk"))).toContain("default"); | |
| 181 | expect(lines.find((l) => l.includes("main"))).not.toContain("default"); | |
| 182 | }); | |
| 183 | ||
| 184 | test("a non-admin and an agent admin are not offered b, and pressing it does nothing", async () => { | |
| 185 | for (const [who, at] of [[writer, repo], [bot, botRepo]] as const) { | |
| 186 | const c = card(who, at); | |
| 187 | expect(c.label("b")).toBeUndefined(); | |
| 188 | c.press("b"); | |
| 189 | await Bun.sleep(30); | |
| 190 | expect(c.pushed).toHaveLength(0); | |
| 191 | } | |
| 192 | }); |
new above
offline
Siblings
V/wuse mayPerson();buses may(), so an agent admin is offered it. Match the siblings or document the difference.