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

Merged opened this

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

Merged

scout/forge:scout/8067-an-admin-changes-the-default-branch-from → main 2 commits fork 3 threads · 3 unresolved

  • Merged. scout/8067-an-admin-changes-the-default-branch-from was merged into main as 1884257.

Review

Sign in to review this change.
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
508508 - 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`.
509509 - 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.
510510 - 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.
511512 - 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).
512513 - 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.
513514 - 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";
7171 import { truncate, wrap } from "../term/width.ts";
7272 import { badge, card } from "../ui/widgets.ts";
7373 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";
7575 import { connectionsScreen, egressScreen } from "./egress.ts";
7676 import { eventsScreen } from "./events.ts";
7777 import { newPullScreen } from "./newpull.ts";
@@ -128,7 +128,7 @@ export function aboutScreen(
128128 ...movingRow(f, repo),
129129 ...outsidersRow(repo),
130130 ...seatRows(f, repo),
131 ...aboutFacts(f, src, facts, ref),
131 ...aboutFacts(f, src, facts, ref, repo),
132132 ...paceRows(f, repo),
133133 ...trailRows(f, repo),
134134 ...upstreamRows(f, repo),
@@ -198,6 +198,7 @@ export function aboutScreen(
198198 ]
199199 : []),
200200 ...(mayPerson() ? [binding(["w"], "write description")] : []),
201 ...(mayPerson() ? [binding(["b"], "default branch")] : []),
outdated

Siblings V/w use mayPerson(); b uses may(), so an agent admin is offered it. Match the siblings or document the difference.

201202 ...(mayOutsiders()
202203 ? [
203204 binding(
@@ -513,6 +514,57 @@ export function aboutScreen(
513514 );
514515 return true;
515516 }
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 }
516568 if (e.key === "t" && repo !== undefined && mayTrail()) {
517569 const shown = getProvenance(f.db, repo.id)?.shown === true;
518570 const done = showProvenance(f.app, f.user, repo, !shown);
@@ -738,6 +790,7 @@ function aboutFacts(
738790 src: RepoSource,
739791 facts: RepoFacts | undefined,
740792 ref: string,
793 current?: Repo,
741794 ): [string, string][] {
742795 const repo = localRepo(src);
743796 const counts: [string, string][] =
@@ -757,7 +810,8 @@ function aboutFacts(
757810 .join(" · "),
758811 ],
759812 ];
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 ?? "";
761815 // Whose work this descends from. A fork at an origin is not `forkParentId`
762816 // — that is a fork made *here* — so the badge above cannot say it and this
763817 // 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 trunk is the default branch.

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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs