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

On a fork, the terminal's forks screen takes the parent's updates with `t` after a confirm: fast-forward, clean merge, or the converge issue on a conflict, as MCP `take_updates` does #7495

Merged opened this

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

Merged

mason/forge:mason/7487-on-a-fork-the-terminal-s-forks-screen-ta → main 2 commits fork

  • Merged. mason/7487-on-a-fork-the-terminal-s-forks-screen-ta was merged into main as f7453c9.

Review

Sign in to review this change.
6 files changed +336 −69
docs/FOLLOWING.md +13 −3
@@ -214,9 +214,19 @@ with `follows owner/name` and MCP `following`'s standing: `in step`, or the
214214 state with `N ahead · M behind` (`parent frozen` behind a disconnected
215215 origin). When the crew has a converge in hand, a second line says what it is
216216 doing, in the words the following page uses; a fork that merges cleanly needs
217 no crew and has no such line. `enter` on either opens the parent. A parent the
218 viewer may not read is never named (`relationOf` hides it), and a repository
219 that follows nothing shows neither line.
217 no crew and has no such line. `enter` on the first opens the parent; on the
218 second, the converge issue. A parent the viewer may not read is never named
219 (`relationOf` hides it), and a repository that follows nothing shows neither
220 line.
221
222 On a fork behind a parent that is not frozen, a person who writes the fork is
223 offered `t`: a confirm says what will happen (`Fast-forward to owner/name: N
224 commits.`, a merge that keeps the fork's own commits, or the converge issue
225 when both sides changed the same files), and `y` calls `takeOrTell`, as the
226 following page and MCP `take_updates` do. The answer is the page's sentence
227 (`takeWords`); on a conflict the crew's line names the converge issue opened
228 or found, and `enter` opens it. A reader, an agent, a fork in step and a
229 repository that follows nothing are not offered `t`.
220230
221231 Forks with nothing ahead (or whose standing git cannot read) follow under
222232 `N forks with nothing new`. At most 60 forks are ranked per view; past that
src/actions/follow.ts +48
@@ -14,6 +14,7 @@ import type { User } from "../core/users.ts";
1414 import { err, ok, type Result } from "../kernel/result.ts";
1515 import { chainOfRepo, forgeLinkOf, FOLLOW_MODES, isFollowMode, setChainMode, severChain, type FollowMode } from "../spawn/chain.ts";
1616 import { FAMILY_WORDS } from "./family_words.ts";
17 import { FOLLOWS_NOTHING } from "./follow_words.ts";
1718 import { requireUser } from "./access.ts";
1819 import { takeFromParent, type Taken } from "../spawn/take.ts";
1920 import { takeUpdates, type ForkSync, type TakeUpdatesError } from "./forks.ts";
@@ -185,3 +186,50 @@ export function convergeWords(
185186 };
186187 }
187188 }
189 /**
190 * What taking updates says when there was nothing to take. "Up to date"
191 * only of a parent that is itself up to date: a frozen one may be far
192 * behind its own upstream, and the take only knows the frozen copy.
193 */
194 export function nothingNew(parent: Repo): string {
195 const name = `${parent.ownerName}/${parent.name}`;
196 return parent.detached === null
197 ? `Already up to date with ${name}.`
198 : `Nothing new from ${name}, which is frozen: its origin was disconnected, so upstream may have moved. Reconnect it.`;
199 }
200
201 /**
202 * What a take said, for every door that words it the same: taken (moved or
203 * merged), nothing new, refused, or a conflict with what the crew did about it.
204 */
205 export function takeWords(
206 took: TakeOrTell,
207 fork: Repo,
208 parent: Repo,
209 ): { tone?: "danger"; message: string } {
210 const parentName = `${parent.ownerName}/${parent.name}`;
211 if (took.ok)
212 return {
213 message: !took.value.updated
214 ? nothingNew(parent)
215 : took.value.merged
216 ? `Took ${parentName}'s updates: merged into ${fork.defaultBranch} at ${took.value.to.slice(0, 7)}.`
217 : `Took ${parentName}'s updates: ${fork.defaultBranch} is at ${took.value.to.slice(0, 7)}.`,
218 };
219 const e = took.error;
220 if (typeof e === "string")
221 return {
222 tone: "danger",
223 message: e === "not_a_fork" ? FOLLOWS_NOTHING : "You need write access to take updates here.",
224 };
225 // A conflict is a tell for the crew (`takeOrTell`).
226 if (e.kind === "conflict") {
227 const files = `${e.files.length === 1 ? "One file needs" : `${e.files.length} files need`} a decision (${e.files.join(", ")})`;
228 const words = "converge" in e ? convergeWords(e.converge, parentName) : { message: "" };
229 return { tone: "danger", message: `${files}. ${words.message}` };
230 }
231 return {
232 tone: "danger",
233 message: e.kind === "moved" ? "Something moved while this was read; try again." : `Git could not take the updates: ${e.message}`,
234 };
235 }
src/tui/screens/forks.ts +107 −21
@@ -4,6 +4,9 @@
44 * Forks that are ahead come first, with the one that has the most to offer on
55 * top; those with nothing new follow under their count. A fork's own parent
66 * leads, read as MCP `following` reads it (`relationOf`, `convergeState`).
7 * On a fork behind its parent, a person who writes it takes the parent's
8 * updates with `t` after a confirm — `takeOrTell`, as MCP `take_updates` —
9 * and a conflict's converge issue is the line Enter then opens.
710 */
811
912 import {
@@ -15,9 +18,9 @@ import {
1518 type ForkRanking,
1619 type ForkRelation,
1720 } from "../../actions/forks.ts";
18 import { convergeWords } from "../../actions/follow.ts";
21 import { convergeWords, takeOrTell, takeWords } from "../../actions/follow.ts";
1922 import { convergeState, type Converge } from "../../crew/converge.ts";
20 import { canRead, forkTree, listForks, type ForkNode, type Repo } from "../../core/repos.ts";
23 import { canRead, canWrite, forkTree, listForks, type ForkNode, type Repo } from "../../core/repos.ts";
2124 import type { Ctx, Screen } from "../app/app.ts";
2225 import { binding } from "../app/keys.ts";
2326 import { style } from "../term/style.ts";
@@ -25,15 +28,25 @@ import { truncate } from "../term/width.ts";
2528 import { fill, len, split } from "../ui/layout.ts";
2629 import { emptyState } from "../ui/widgets.ts";
2730 import type { Session } from "../session.ts";
31 import { issueScreen } from "./issue.ts";
2832 import { local } from "./where.ts";
2933 import { repoScreen } from "./workspace.ts";
30 import { ListState, drawList, plural, type ListRow } from "./common.ts";
34 import { ListState, confirmScreen, drawList, plural, runAsync, type ListRow } from "./common.ts";
35
36 /** One line of the screen; `issue` is a converge issue on the fork, which Enter opens. */
37 export interface ForkRow {
38 repo: Repo;
39 name: string;
40 words: string;
41 heading: string;
42 issue?: number;
43 }
3144
3245 /** The rows the screen draws, in order: pure so a test reads them without a terminal. */
3346 export function forkRows(
3447 ranked: ForkRanking,
3548 tree: readonly ForkNode[] = [],
36 ): { repo: Repo; name: string; words: string; heading: string }[] {
49 ): ForkRow[] {
3750 const below = new Map(tree.map((n) => [n.repo.id, n.forks]));
3851 const row = (f: ForkRanking["offering"][number], heading: string) => [
3952 {
@@ -64,7 +77,7 @@ export function forkRows(
6477 export function followingRows(
6578 relation: ForkRelation | undefined,
6679 converge: Converge | undefined,
67 ): { repo: Repo; name: string; words: string; heading: string }[] {
80 ): ForkRow[] {
6881 if (relation === undefined) return [];
6982 const { parent } = relation;
7083 const name = `${parent.ownerName}/${parent.name}`;
@@ -81,31 +94,98 @@ export function followingRows(
8194 { repo: parent, name: `follows ${name}`, words, heading: "" },
8295 ...(converge === undefined
8396 ? []
84 : [{ repo: parent, name: ` ${convergeWords(converge, name).message}`, words: "", heading: "" }]),
97 : [
98 {
99 repo: parent,
100 name: ` ${convergeWords(converge, name).message}`,
101 words: "",
102 heading: "",
103 ...("issue" in converge ? { issue: converge.issue.number } : {}),
104 },
105 ]),
85106 ];
86107 }
87108
109 /**
110 * What `t` would do, or undefined when it is not offered: only on a fork
111 * behind a parent that is not frozen, and only to a person who writes it.
112 * Agents take updates through MCP `take_updates`, not a keypress.
113 */
114 export function takeOffer(
115 relation: ForkRelation | undefined,
116 writer: boolean,
117 person: boolean,
118 ): string | undefined {
119 if (relation === undefined || !writer || !person) return undefined;
120 if (relation.behind === 0 || relation.parent.detached !== null) return undefined;
121 const { parent, behind, ahead } = relation;
122 const name = `${parent.ownerName}/${parent.name}`;
123 const commits = plural(behind, "commit");
124 if (ahead === 0) return `Fast-forward to ${name}: ${commits}.`;
125 if (relation.clean) return `Merge ${commits} from ${name}; your ${plural(ahead, "commit")} stay.`;
126 return `Both sides changed ${relation.conflicts.join(", ")}: the crew is told on a converge issue.`;
127 }
128
88129 export function forksScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
89130 let ranked: ForkRanking | undefined;
90131 let tree: ForkNode[] = [];
91 let following: ReturnType<typeof followingRows> = [];
132 let following: ForkRow[] = [];
133 let relation: ForkRelation | undefined;
134 let offer: string | undefined;
92135 const state = new ListState();
93136 const rows = () => [...following, ...(ranked === undefined ? [] : forkRows(ranked, tree))];
94 return {
137 const read = async (): Promise<void> => {
138 const forks = listForks(f.db, repo.id).filter((r) => canRead(f.db, r, f.user));
139 ranked = await rankedForksOf(f.app, repo, forks);
140 tree = forkTree(f.db, repo.id, f.user);
141 relation = await relationOf(f.app, f.user, repo);
142 offer = takeOffer(relation, canWrite(f.db, repo, f.user), f.user?.kind === "user");
143 const converge =
144 relation !== undefined && relation.state === "diverged" && !relation.clean
145 ? convergeState(f.app, repo, relation.parent)
146 : undefined;
147 following = followingRows(relation, converge);
148 ctx.invalidate();
149 };
150 const take = (): void => {
151 const parent = relation?.parent;
152 if (offer === undefined || parent === undefined) return;
153 ctx.push(
154 confirmScreen(ctx, self, {
155 title: "Take updates",
156 body: [offer],
157 actions: [{ label: "Take updates" }, { label: "Cancel", variant: "ghost" }],
158 yesNo: true,
159 onChoose: (choice) => {
160 if (choice !== 0) return;
161 runAsync(ctx, async () => {
162 const took = await takeOrTell(f.app, f.user, repo);
163 const words = takeWords(took, repo, parent);
164 await read();
165 // The converge issue just opened or found is the line Enter opens.
166 const e = took.ok ? undefined : took.error;
167 if (typeof e === "object" && e.kind === "conflict" && "converge" in e && relation !== undefined) {
168 following = followingRows(relation, e.converge);
169 state.index = following.length - 1;
170 }
171 ctx.toast(words.message, words.tone === "danger" ? "danger" : "default");
172 ctx.invalidate();
173 });
174 },
175 }),
176 );
177 };
178 const self: Screen = {
95179 title: "Forks",
96 keys: [binding(["j", "k"], "move"), binding(["enter"], "open"), binding(["esc"], "close")],
97 async load() {
98 const forks = listForks(f.db, repo.id).filter((r) => canRead(f.db, r, f.user));
99 ranked = await rankedForksOf(f.app, repo, forks);
100 tree = forkTree(f.db, repo.id, f.user);
101 const relation = await relationOf(f.app, f.user, repo);
102 const converge =
103 relation !== undefined && relation.state === "diverged" && !relation.clean
104 ? convergeState(f.app, repo, relation.parent)
105 : undefined;
106 following = followingRows(relation, converge);
107 ctx.invalidate();
180 get keys() {
181 return [
182 binding(["j", "k"], "move"),
183 binding(["enter"], "open"),
184 ...(offer === undefined ? [] : [binding(["t"], "take updates")]),
185 binding(["esc"], "close"),
186 ];
108187 },
188 load: () => read(),
109189 render(area, s) {
110190 const c = ctx.theme.c;
111191 const [head, body] = split(area, "v", [len(1), fill(1)]);
@@ -144,7 +224,12 @@ export function forksScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
144224 if (state.key(e, list.length)) return true;
145225 if (e.key === "enter") {
146226 const row = list[state.index];
147 if (row !== undefined) ctx.push(repoScreen(f, ctx, local(f, row.repo)));
227 if (row?.issue !== undefined) ctx.push(issueScreen(f, ctx, local(f, repo), row.issue));
228 else if (row !== undefined) ctx.push(repoScreen(f, ctx, local(f, row.repo)));
229 return true;
230 }
231 if (e.key === "t" && offer !== undefined) {
232 take();
148233 return true;
149234 }
150235 if (e.key === "esc" || e.key === "q") {
@@ -154,4 +239,5 @@ export function forksScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
154239 return false;
155240 },
156241 };
242 return self;
157243 }
src/web/pages/following.ts +2 −44
@@ -37,7 +37,7 @@ import {
3737 } from "../../core/parent_releases.ts";
3838 import { running } from "../../kernel/version.ts";
3939 import { loadRepo, notFoundPage, repoBase, signInFirst } from "../chrome.ts";
40 import { convergeWords, takeOrTell } from "../../actions/follow.ts";
40 import { convergeWords, takeOrTell, takeWords } from "../../actions/follow.ts";
4141 import { FOLLOWS_NOTHING, PROPOSE_NEEDS_WRITE } from "../../actions/follow_words.ts";
4242 import { openProposal, proposeUpstream } from "../../actions/upstream.ts";
4343 import { field, readForm } from "../form.ts";
@@ -352,18 +352,6 @@ export function followingCard(
352352 );
353353 }
354354
355 /**
356 * What taking updates says when there was nothing to take. "Up to date"
357 * only of a parent that is itself up to date: a frozen one may be far
358 * behind its own upstream, and the take only knows the frozen copy.
359 */
360 export function nothingNew(parent: Repo): string {
361 const name = `${parent.ownerName}/${parent.name}`;
362 return parent.detached === null
363 ? `Already up to date with ${name}.`
364 : `Nothing new from ${name}, which is frozen: its origin was disconnected, so upstream may have moved. Reconnect it.`;
365 }
366
367355 /**
368356 * POST /:owner/:name/following { do: take | converge }
369357 * One form, two verbs: take what git can take, or hand the rest to the crew.
@@ -421,35 +409,5 @@ export async function followingSubmit(ctx: Ctx): Promise<Response> {
421409 }
422410
423411 const took = await takeOrTell(ctx.app, ctx.auth.user, repo);
424 if (!took.ok) {
425 const e = took.error;
426 if (typeof e === "string")
427 return flashOrJson(ctx, base, {
428 tone: "danger",
429 message:
430 e === "not_a_fork"
431 ? FOLLOWS_NOTHING
432 : "You need write access to take updates here.",
433 });
434 // A conflict is a tell for the crew (`actions/follow.ts`).
435 if (e.kind === "conflict") {
436 const files = `${e.files.length === 1 ? "One file needs" : `${e.files.length} files need`} a decision (${e.files.join(", ")})`;
437 const words = "converge" in e ? convergeWords(e.converge, parentName) : { message: "" };
438 return flashOrJson(ctx, base, { tone: "danger", message: `${files}. ${words.message}` });
439 }
440 return flashOrJson(ctx, base, {
441 tone: "danger",
442 message:
443 e.kind === "moved"
444 ? "Something moved while this was read; try again."
445 : `Git could not take the updates: ${e.message}`,
446 });
447 }
448 return flashOrJson(ctx, base, {
449 message: !took.value.updated
450 ? nothingNew(parent)
451 : took.value.merged
452 ? `Took ${parentName}'s updates: merged into ${repo.defaultBranch} at ${took.value.to.slice(0, 7)}.`
453 : `Took ${parentName}'s updates: ${repo.defaultBranch} is at ${took.value.to.slice(0, 7)}.`,
454 });
412 return flashOrJson(ctx, base, takeWords(took, repo, parent));
455413 }
src/web/pages/repo.ts +1 −1
@@ -31,8 +31,8 @@ import {
3131 followingCard,
3232 followingOf,
3333 lineageCard,
34 nothingNew,
3534 } from "./following.ts";
35 import { nothingNew } from "../../actions/follow.ts";
3636 import { frozenBlock } from "./frozen.ts";
3737 import { pinRepo, syncRepoNow, unpinRepo } from "../../actions/origins.ts";
3838 import {
test/tui-forks-take.test.ts added +165
@@ -0,0 +1,165 @@
1 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2 import { mkdtemp, rm } from "node:fs/promises";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { commitToBranch, createRepo, forkRepo } from "../src/actions/repos.ts";
6 import { forkRelation, type ForkRelation } from "../src/core/following.ts";
7 import { getRepo, type Repo } from "../src/core/repos.ts";
8 import { createUser, type User } from "../src/core/users.ts";
9 import { convergeTitle } from "../src/crew/converge.ts";
10 import { createIssue } from "../src/core/issues.ts";
11 import { createApp, type App } from "../src/app.ts";
12 import { repoDir } from "../src/git/repos.ts";
13 import { createForgeApp } from "../src/tui/forge.ts";
14 import { forksScreen, takeOffer } from "../src/tui/screens/forks.ts";
15 import { headlessTerminal } from "../src/tui/term/terminal.ts";
16 import { settleApp } from "./helpers/tui.ts";
17 import { promote } from "./helpers/members.ts";
18
19 const must = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => {
20 if (!r.ok) throw new Error(JSON.stringify(r.error));
21 return r.value;
22 };
23
24 describe("takeOffer", () => {
25 const parent = { ownerName: "alice", name: "app", detached: null } as unknown as Repo;
26 const at = (behind: number, ahead: number, clean = true, conflicts: string[] = []) =>
27 ({ parent, state: "diverged", behind, ahead, clean, conflicts }) as unknown as ForkRelation;
28
29 test("says a fast-forward, a merge, or the converge issue", () => {
30 expect(takeOffer(at(3, 0), true, true)).toBe("Fast-forward to alice/app: 3 commits.");
31 expect(takeOffer(at(1, 2), true, true)).toBe("Merge 1 commit from alice/app; your 2 commits stay.");
32 expect(takeOffer(at(1, 1, false, ["a.txt"]), true, true)).toContain("converge issue");
33 });
34
35 test("not behind, no parent, a frozen parent, a reader or an agent: no offer", () => {
36 expect(takeOffer(at(0, 2), true, true)).toBeUndefined();
37 expect(takeOffer(undefined, true, true)).toBeUndefined();
38 const frozen = { ...at(2, 0), parent: { ...parent, detached: { detachedAt: null } } } as ForkRelation;
39 expect(takeOffer(frozen, true, true)).toBeUndefined();
40 expect(takeOffer(at(2, 0), false, true)).toBeUndefined();
41 expect(takeOffer(at(2, 0), true, false)).toBeUndefined();
42 });
43 });
44
45 describe("the terminal's forks screen takes the parent's updates with t", () => {
46 let dataDir: string;
47 let app: App;
48 let upstream: Repo;
49 let converge = 0;
50 const users: Record<string, User> = {};
51
52 const screenOf = async (who: string, owner: string, ...keys: string[]) => {
53 const terminal = headlessTerminal(120, 30);
54 const { app: tui, session } = createForgeApp(
55 app,
56 { sshUsername: who, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user: users[who]! },
57 { terminal, exitHooks: false },
58 );
59 tui.start();
60 tui.ctx.push(forksScreen(session, tui.ctx, getRepo(app.db, owner, "app")!));
61 await settleApp(tui);
62 const frames: string[] = [tui.screen.toText()];
63 for (const k of keys) {
64 terminal.feed(k);
65 await settleApp(tui);
66 frames.push(tui.screen.toText());
67 }
68 tui.stop?.();
69 return frames;
70 };
71 const behind = async (owner: string) =>
72 (await forkRelation(app, getRepo(app.db, owner, "app")!, upstream))!.behind;
73 const git = async (cwd: string, ...args: string[]) => {
74 const p = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" });
75 const out = await new Response(p.stdout).text();
76 expect(await p.exited).toBe(0);
77 return out.trim();
78 };
79
80 beforeAll(async () => {
81 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-forks-take-"));
82 app = createApp({ dataDir, port: 0 });
83 for (const name of ["alice", "bob", "carol", "dan"]) {
84 users[name] = must(
85 await createUser(app.db, {
86 origin: "seed",
87 trusted: true,
88 username: name,
89 email: `${name}@f.dev`,
90 password: "a-good-passphrase",
91 adminIfFirst: name === "alice",
92 }),
93 );
94 promote(app.db, users[name]!);
95 }
96 upstream = must(await createRepo(app, users["alice"]!, { name: "app", visibility: "public" }));
97 must(await commitToBranch(app, users["alice"]!, upstream, { message: "one", files: [{ path: "a.txt", content: "a\n" }] }));
98 must(await forkRepo(app, users["bob"]!, upstream));
99 const dan = must(await forkRepo(app, users["dan"]!, upstream));
100 must(
101 await commitToBranch(app, users["dan"]!, dan, {
102 message: "dan shared",
103 files: [{ path: "shared.txt", content: "dan\n" }],
104 }),
105 );
106 const before = await git(repoDir(app.reposRoot, "bob", "app"), "rev-parse", "refs/heads/main");
107 must(
108 await commitToBranch(app, users["alice"]!, upstream, {
109 message: "two",
110 files: [
111 { path: "b.txt", content: "b\n" },
112 { path: "shared.txt", content: "alice\n" },
113 ],
114 }),
115 );
116 // Bob's fork follows by itself; put it back where it was so it is behind only.
117 for (let i = 0; i < 100 && (await behind("bob")) !== 0; i++) await Bun.sleep(50);
118 await git(repoDir(app.reposRoot, "bob", "app"), "update-ref", "refs/heads/main", before);
119 expect(await behind("bob")).toBe(1);
120 converge = must(createIssue(app.db, { repoId: dan.id, authorId: users["dan"]!.id, title: convergeTitle(upstream) })).number;
121 }, 60_000);
122
123 afterAll(async () => {
124 app.close();
125 await rm(dataDir, { recursive: true, force: true });
126 });
127
128 test("a reader of the fork is not offered t, and pressing it moves nothing", async () => {
129 const [first, after] = await screenOf("carol", "bob", "t");
130 expect(first).toContain("1 behind");
131 expect(first).not.toContain("take updates");
132 expect(after).not.toContain("Fast-forward");
133 expect(await behind("bob")).toBe(1);
134 });
135
136 test("a repository that follows nothing is not offered t", async () => {
137 const [first] = await screenOf("alice", "alice");
138 expect(first).not.toContain("take updates");
139 });
140
141 test("a cancelled confirm moves nothing", async () => {
142 const [first, asked, cancelled] = await screenOf("bob", "bob", "t", "n");
143 expect(first).toContain("take updates");
144 expect(asked).toContain("Fast-forward to alice/app: 1 commit.");
145 expect(cancelled).not.toContain("Fast-forward");
146 expect(await behind("bob")).toBe(1);
147 });
148
149 test("t and a confirm fast-forward the fork, said in the following page's words", async () => {
150 const frames = await screenOf("bob", "bob", "t", "y");
151 expect(await behind("bob")).toBe(0);
152 expect(frames.at(-1)).toContain("Took alice/app's updates: main is at");
153 expect(frames.at(-1)).toContain("in step");
154 expect(frames.at(-1)).not.toContain("take updates");
155 });
156
157 test("on a conflict the converge issue is said, and Enter opens it", async () => {
158 const frames = await screenOf("dan", "dan", "t", "y", "\r");
159 expect(frames[1]).toContain("Both sides changed shared.txt");
160 expect(frames[2]).toContain("One file needs a decision (shared.txt)");
161 expect(frames[2]).toContain(`converging on #${converge}`);
162 expect(frames[3]).toContain(convergeTitle(upstream));
163 expect(await behind("dan")).toBe(1);
164 });
165 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs