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

Migrate from the terminal: pick a repository and its branch, put a crew on it, watch the job #3412

Merged opened this

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

Merged

scout/forge:scout/3333-migrate-from-the-terminal-pick-a-reposit → main 4 commits fork 1 thread · 1 unresolved

  • Merged. scout/3333-migrate-from-the-terminal-pick-a-reposit was merged into main as 1982d2e.

Review

Sign in to review this change.
5 files changed +869 −0
README.md +13
@@ -270,6 +270,19 @@ ssh -p 2222 localhost # the terminal UI
270270 git clone ssh://localhost:2222/you/hello.git # git, same port
271271 ```
272272
273 **Migrate from the terminal.** Press `M` on the dashboard (or pick "Migrate a
274 repository" in the palette, `ctrl+k`). The list is your connected origins'
275 repositories, private ones marked; `/` filters it and `u` pastes a URL instead.
276 Enter on a repository lists its branches with their last commit, your own
277 newest first and marked "(suggested)", so Enter again takes it; a pasted URL
278 skips this step. The form asks for name, owner, visibility, agent and autonomy
279 (`tab` moves, `space` changes); `ctrl+d` migrates. The job is a checklist of
280 stages: `esc` hides it and never cancels, and a toast says when it lands, on the
281 new repository. It is the same `migrateRepo` as `/migrate` and
282 `POST /api/v1/migrate`: the chosen `branch` becomes the default branch and the
283 crew's brief says the app lives on it. The web form takes it too (it lists the
284 picked repository's branches newest first), as does the API's `branch` field.
285
273286 On a forge made for a client (one that was spawned, signed in by its claim
274287 link), the home is their apps, one plain sentence each and one more saying
275288 whether its agents are working, about to start, or waiting: no map, board or
src/tui/forge.ts +8
@@ -77,6 +77,7 @@ import { issuesScreen, newIssueScreen } from "./screens/issues.ts";
7777 import { limbsScreen } from "./screens/limbs.ts";
7878 import { newPullScreen } from "./screens/newpull.ts";
7979 import { newForgeScreen } from "./screens/newforge.ts";
80 import { migrateScreen } from "./screens/migrate.ts";
8081 import { newRepoScreen } from "./screens/newrepo.ts";
8182 import { repoScreen } from "./screens/repo.ts";
8283 import { signInScreen } from "./screens/signin.ts";
@@ -206,6 +207,13 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
206207 ctx.push(signInScreen(session, ctx, () => ctx.invalidate())),
207208 },
208209 );
210 if (session.signedIn)
211 commands.push({
212 id: "migrate",
213 title: "Migrate a repository",
214 hint: "bring one in from an origin or a URL, pick its branch, put a crew on it",
215 run: () => ctx.push(migrateScreen(session, ctx)),
216 });
209217 // A client's forge, as /forges/new makes it: any signed-in person's; the door decides the rest.
210218 if (session.signedIn)
211219 commands.push({
src/tui/screens/dashboard.ts +6
@@ -92,6 +92,7 @@ import { running } from "../../kernel/version.ts";
9292 import { deciderLine, deciderScreen } from "./decider.ts";
9393 import { issueScreen } from "./issue.ts";
9494 import { listPinned } from "../../actions/board-pins.ts";
95 import { migrateScreen } from "./migrate.ts";
9596 import { newRepoScreen } from "./newrepo.ts";
9697 import { pullScreen } from "./pull.ts";
9798 import { repoScreen } from "./repo.ts";
@@ -759,6 +760,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
759760 }),
760761 binding(["C"], "decider", { disabled: !f.user }),
761762 binding(["n"], "new repo", { disabled: !f.signedIn }),
763 binding(["M"], "migrate", { disabled: !f.signedIn }),
762764 binding(["A"], "account", { disabled: !f.signedIn }),
763765 binding(["s"], "sign in", { disabled: f.signedIn }),
764766 ...(focus === "repos" && f.user !== undefined && shown().some(mayArchive)
@@ -955,6 +957,10 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
955957 ctx.push(newRepoScreen(f, ctx));
956958 return true;
957959 }
960 if (e.key === "M" && f.signedIn) {
961 ctx.push(migrateScreen(f, ctx));
962 return true;
963 }
958964 if (e.key === "A" && f.user) {
959965 openOnWeb(f, ctx, "/settings", "Account settings");
960966 return true;
src/tui/screens/migrate.ts added +536
@@ -0,0 +1,536 @@
1 /**
2 * Migrate from the terminal: the web's `/migrate`, one step per screen.
3 *
4 * Pick a repository from your connected origins (or paste a URL), pick the
5 * branch the app lives on, say where it lands and who works on it, then watch
6 * the job. Nothing here is a second path: the last step is `migrateRepo`
7 * with `branch` and `crew`, and this file only puts its questions and its
8 * answer on a terminal.
9 */
10
11 import { listAllAgents } from "../../actions/crew.ts";
12 import {
13 migrateMessage,
14 migrateRepo,
15 type MigrateSource,
16 } from "../../actions/migrate.ts";
17 import { listRemoteRepos, listUserOrigins } from "../../actions/origins.ts";
18 import { repositoryLimitMessage } from "../../actions/repos.ts";
19 import { accessAllows, listOrgsForUser, orgAccess } from "../../core/orgs.ts";
20 import type { OriginRepoRow } from "../../core/origins.ts";
21 import type { User } from "../../core/users.ts";
22 import { subscribe, type MirrorJob, type MirrorStage } from "../../origins/jobs.ts";
23 import { providerForOrigin } from "../../origins/credential.ts";
24 import { cachedRemoteTips, pickWorkBranch } from "../../origins/work-branch.ts";
25 import { getOriginFor } from "../../core/origins.ts";
26 import type { Ctx, Screen } from "../app/app.ts";
27 import { binding } from "../app/keys.ts";
28 import { G } from "../term/screen.ts";
29 import { style } from "../term/style.ts";
30 import { truncate } from "../term/width.ts";
31 import { fill, len, split } from "../ui/layout.ts";
32 import { card, field, spinner } from "../ui/widgets.ts";
33 import type { Session } from "../session.ts";
34 import {
35 ago,
36 drawList,
37 Filter,
38 hitList,
39 ListState,
40 pickerScreen,
41 promptScreen,
42 runAsync,
43 } from "./common.ts";
44 import { repoScreen } from "./repo.ts";
45 import { local } from "./where.ts";
46
47 interface Row {
48 readonly originId: string;
49 readonly origin: string;
50 readonly repo: OriginRepoRow;
51 }
52
53 /** Where the repository comes from, and — for an origin — the branch chosen. */
54 interface Picked {
55 readonly source: MigrateSource;
56 readonly label: string;
57 readonly name: string;
58 readonly branch?: string;
59 }
60
61 const STAGES: readonly { stage: MirrorStage; label: string }[] = [
62 { stage: "creating", label: "Creating the repository" },
63 { stage: "fetching", label: "Cloning every branch and tag" },
64 { stage: "crew", label: "Putting the crew on it" },
65 ];
66
67 /** Step one: the repositories of every connected origin, or a pasted URL. */
68 export function migrateScreen(f: Session, ctx: Ctx): Screen {
69 const user = f.user;
70 const state = new ListState();
71 const filter = new Filter();
72 let rows: Row[] = [];
73 let loading = true;
74 // Origins that would not answer: said aloud, never read as "no repositories".
75 let unreachable: string[] = [];
76 let self: Screen;
77
78 const shown = (): Row[] =>
79 rows.filter((r) => filter.matches(r.repo.fullName));
80
81 const toForm = (picked: Picked): void => {
82 if (user !== undefined) ctx.push(migrateFormScreen(f, ctx, user, picked));
83 };
84
85 const choose = (row: Row): void => {
86 if (user === undefined) return;
87 runAsync(ctx, async () => {
88 const origin = getOriginFor(f.db, user.id, row.originId);
89 const provider =
90 origin === undefined ? undefined : providerForOrigin(f.app, origin);
91 const tips =
92 origin === undefined || provider === undefined || !provider.ok
93 ? undefined
94 : await cachedRemoteTips(origin.id, provider.value, row.repo.fullName, {
95 logins: [origin.login, user.username],
96 emails: [user.email],
97 });
98 if (tips === undefined || tips.length === 0) {
99 ctx.toast(
100 tips === undefined
101 ? "Could not reach the origin. Try again."
102 : `${row.repo.fullName} has no branches`,
103 "danger",
104 );
105 return;
106 }
107 const suggested = pickWorkBranch(tips, row.repo.defaultBranch);
108 // Newest first, the suggestion on top, so Enter takes it.
109 const ordered = [...tips]
110 .sort((a, b) => (Date.parse(b.date) || 0) - (Date.parse(a.date) || 0))
111 .sort(
112 (a, b) => Number(b.name === suggested) - Number(a.name === suggested),
113 );
114 ctx.push(
115 pickerScreen(ctx, self, {
116 title: `Branch of ${row.repo.fullName}`,
117 items: ordered.map((tip) => ({
118 id: tip.name,
119 label: tip.name === suggested ? `${tip.name} (suggested)` : tip.name,
120 hint: [
121 tip.subject,
122 tip.author,
123 tip.date === "" ? "" : ago(tip.date),
124 ]
125 .filter((p) => p !== "")
126 .join(" · "),
127 })),
128 onPick: ([branch]) => {
129 if (branch === undefined) return;
130 toForm({
131 source: { originId: row.originId, fullName: row.repo.fullName },
132 label: row.repo.fullName,
133 name: row.repo.fullName.split("/").pop() ?? "",
134 branch,
135 });
136 },
137 }),
138 );
139 });
140 };
141
142 const pasteUrl = (): void => {
143 ctx.push(
144 promptScreen(ctx, self, {
145 title: "Migrate from a URL",
146 label: "Repository URL",
147 placeholder: "https://github.com/owner/name",
148 hint: "Public, over https. Cloned anonymously.",
149 onSubmit: (url) => {
150 if (url === "") return "Paste an address first.";
151 const name =
152 (url.replace(/[?#].*$/, "").replace(/\/+$/, "").split("/").pop() ?? "")
153 .replace(/\.git$/i, "");
154 return Promise.resolve().then(() =>
155 toForm({ source: { url }, label: url, name }),
156 );
157 },
158 }),
159 );
160 };
161
162 self = {
163 title: "Migrate a repository",
164 get keys() {
165 return [
166 binding(["j", "k"], "move"),
167 binding(["enter"], "pick branch"),
168 binding(["/"], "filter"),
169 binding(["u"], "paste a URL"),
170 binding(["esc"], "back"),
171 ];
172 },
173 async load() {
174 if (user === undefined) {
175 loading = false;
176 return;
177 }
178 const out: Row[] = [];
179 const failed: string[] = [];
180 for (const origin of listUserOrigins(f.app, user)) {
181 const listed = await listRemoteRepos(f.app, user, origin.id);
182 if (!listed.ok) {
183 failed.push(origin.login);
184 continue;
185 }
186 for (const repo of listed.value)
187 out.push({ originId: origin.id, origin: origin.login, repo });
188 }
189 rows = out;
190 unreachable = failed;
191 loading = false;
192 ctx.invalidate();
193 },
194 render(area, s) {
195 const c = ctx.theme.c;
196 const [top, list] = split(area, "v", [len(2), fill(1)]);
197 ctx.setCursor(filter.render(s, { ...top!, h: 1 }, c, "filter repositories"));
198 const items = shown();
199 const down = `Could not reach ${unreachable.join(", ")}.`;
200 if (unreachable.length > 0 && items.length > 0)
201 s.text(
202 top!.x,
203 top!.y + 1,
204 truncate(`${down} The others are listed.`, top!.w),
205 style(c.danger, c.bg),
206 top!.x + top!.w,
207 );
208 if (items.length === 0) {
209 s.text(
210 list!.x,
211 list!.y,
212 truncate(
213 loading
214 ? "loading your origins…"
215 : rows.length === 0 && unreachable.length > 0
216 ? `${down} Try again, or press u to paste a URL.`
217 : rows.length === 0
218 ? "No connected origin has repositories. Press u to paste a URL, or connect one on the web under Settings → Origins."
219 : "Nothing matches.",
220 list!.w,
221 ),
222 style(c.mutedFg, c.bg),
223 list!.x + list!.w,
224 );
225 return;
226 }
227 drawList(
228 s,
229 list!,
230 c,
231 state,
232 items.map((r) => ({
233 title: r.repo.fullName,
234 secondary: r.repo.private ? "private" : "",
235 meta: [
236 r.origin,
237 r.repo.pushedAt === null ? "" : `pushed ${ago(r.repo.pushedAt)}`,
238 ]
239 .filter((p) => p !== "")
240 .join(" · "),
241 })),
242 );
243 hitList(ctx, list!, state, items.length, {
244 open: (i) => {
245 const row = items[i];
246 if (row !== undefined) choose(row);
247 },
248 });
249 },
250 key(e) {
251 if (filter.key(e)) {
252 state.clamp(shown().length);
253 return true;
254 }
255 if (e.key === "esc") {
256 ctx.pop();
257 return true;
258 }
259 if (e.key === "u") {
260 pasteUrl();
261 return true;
262 }
263 if (state.key(e, shown().length)) return true;
264 if (e.key === "enter") {
265 const row = shown()[state.index];
266 if (row !== undefined) choose(row);
267 return true;
268 }
269 return true;
270 },
271 };
272 return self;
273 }
274
275 type Focus = 0 | 1 | 2 | 3 | 4;
276
277 /** Step three: where it lands, and who works on it. */
278 function migrateFormScreen(
279 f: Session,
280 ctx: Ctx,
281 user: User,
282 picked: Picked,
283 ): Screen {
284 const writeOrgs = listOrgsForUser(f.db, user.id).filter((org) =>
285 accessAllows(orgAccess(f.db, org.id, user), "write"),
286 );
287 const owners = [user.username, ...writeOrgs.map((o) => o.username)];
288 const agents = listAllAgents(f.app, user);
289 // "" is nobody: just the repository.
290 const crew = ["", ...(agents.ok ? agents.value.map((a) => a.user.username) : [])];
291 let name = picked.name;
292 let ownerIndex = 0;
293 let visibility: "public" | "private" = "private";
294 let agentIndex = 0;
295 let autonomous = false;
296 let focus: Focus = 0;
297 let error: string | undefined;
298 let busy = false;
299
300 const submit = (): void => {
301 if (busy) return;
302 busy = true;
303 runAsync(ctx, async () => {
304 try {
305 const agent = crew[agentIndex] ?? "";
306 const started = await migrateRepo(f.app, user, {
307 source: picked.source,
308 owner: owners[ownerIndex] ?? user.username,
309 name,
310 visibility,
311 ...(picked.branch === undefined ? {} : { branch: picked.branch }),
312 ...(agent === "" ? {} : { crew: { agent, autonomous } }),
313 });
314 if (!started.ok) {
315 error = migrateMessage(
316 started.error,
317 repositoryLimitMessage(f.app, user),
318 );
319 ctx.toast(error, "danger");
320 ctx.invalidate();
321 return;
322 }
323 ctx.replace(jobScreen(f, ctx, started.value.job, picked.label));
324 } finally {
325 busy = false;
326 }
327 });
328 };
329
330 const cycle = (): void => {
331 if (focus === 1) ownerIndex = (ownerIndex + 1) % owners.length;
332 if (focus === 2) visibility = visibility === "private" ? "public" : "private";
333 if (focus === 3) agentIndex = (agentIndex + 1) % crew.length;
334 if (focus === 4) autonomous = !autonomous;
335 };
336
337 return {
338 title: "Migrate",
339 keys: [
340 binding(["tab"], "next field"),
341 binding(["space"], "change"),
342 binding(["ctrl+d"], "migrate"),
343 binding(["esc"], "back"),
344 ],
345 render(area, s) {
346 const c = ctx.theme.c;
347 const [top, rest] = split(area, "v", [len(5), fill(1)]);
348 const right = area.x + area.w;
349 s.text(
350 area.x,
351 area.y,
352 truncate(
353 `from ${picked.label}${picked.branch === undefined ? "" : ` · branch ${picked.branch}`}`,
354 area.w,
355 ),
356 style(c.mutedFg, c.bg),
357 right,
358 );
359 const cursor = field(s, { x: top!.x, y: top!.y + 1, w: top!.w, h: 3 }, c, {
360 label: `Name (under ${owners[ownerIndex] ?? "?"})`,
361 value: name,
362 focused: focus === 0,
363 ...(focus === 0 ? { cursor: [...name].length } : {}),
364 ...(error === undefined
365 ? { hint: "Blank keeps the source's name." }
366 : { error }),
367 });
368 ctx.setCursor(focus === 0 ? cursor : null);
369 const lines = [
370 `owner ${owners[ownerIndex] ?? "?"}`,
371 `visibility ${visibility}`,
372 `agent ${crew[agentIndex] === "" ? "nobody — just the repository" : crew[agentIndex]}`,
373 `autonomy ${autonomous ? "autonomous — green ships on its own" : "every green change waits for your look"}`,
374 ];
375 lines.forEach((line, i) =>
376 s.text(
377 rest!.x,
378 rest!.y + i,
379 truncate(line, rest!.w),
380 style(focus === i + 1 ? c.accent : c.mutedFg, c.bg),
381 rest!.x + rest!.w,
382 ),
383 );
384 },

watching is still unassigned if subscribe replays an already-finished job synchronously, so watching() throws here. Use a let and guard the call, or unsubscribe after subscribe returns.

385 paste(text) {
386 if (focus === 0) name += text.replace(/\s+/g, "");
387 return true;
388 },
389 key(e) {
390 if (e.key === "esc") {
391 ctx.pop();
392 return true;
393 }
394 if (e.ctrl && e.key === "d") {
395 submit();
396 return true;
397 }
398 if (e.key === "tab") {
399 focus = ((focus + 1) % 5) as Focus;
400 return true;
401 }
402 if (focus > 0 && (e.key === " " || e.key === "enter")) {
403 cycle();
404 return true;
405 }
406 if (e.key === "enter") {
407 submit();
408 return true;
409 }
410 if (focus === 0 && e.key === "backspace") {
411 name = [...name].slice(0, -1).join("");
412 error = undefined;
413 return true;
414 }
415 if (focus === 0 && !e.ctrl && !e.alt && [...e.key].length === 1) {
416 name += e.key;
417 error = undefined;
418 }
419 return true;
420 },
421 };
422 }
423
424 /**
425 * Step four: watching the job, as `mirror.ts` watches a mirror. Esc hides the
426 * card and never cancels; the watcher toasts when it lands either way, and
427 * the card itself replaces itself with the repository when it finishes.
428 */
429 function jobScreen(
430 f: Session,
431 ctx: Ctx,
432 started: MirrorJob,
433 label: string,
434 ): Screen {
435 let job = started;
436 // Not tied to this card: hiding it must not silence the outcome. It ends
437 // itself at done or failed, which every job reaches.
438 const watching = subscribe(job.id, (current) => {
439 if (current.stage === "done") {
440 watching();
441 ctx.toast(current.detail === "" ? "migrated" : current.detail, "success");
442 } else if (current.stage === "failed") {
443 watching();
444 ctx.toast(current.error ?? "the migration failed", "danger");
445 }
446 });
447
448 return {
449 title: `Migrating ${label}`,
450 keys: [binding(["esc"], "hide — it keeps running")],
451 load(signal) {
452 const off = subscribe(job.id, (current) => {
453 job = current;
454 ctx.invalidate();
455 if (current.stage === "done" && current.repo !== undefined)
456 ctx.replace(repoScreen(f, ctx, local(f, current.repo)));
457 });
458 const timer = setInterval(() => ctx.invalidate(), 120);
459 signal.addEventListener("abort", () => {
460 off();
461 clearInterval(timer);
462 });
463 return Promise.resolve();
464 },
465 render(area, s) {
466 const c = ctx.theme.c;
467 const w = Math.min(64, Math.max(24, area.w - 6));
468 const h = Math.min(area.h, STAGES.length + 4);
469 const inner = card(
470 s,
471 {
472 x: area.x + ((area.w - w) >> 1),
473 y: area.y + ((area.h - h) >> 1),
474 w,
475 h,
476 },
477 c,
478 {
479 title: `Migrating ${label}`,
480 footer: "esc hides this",
481 focused: true,
482 ...(job.stage === "failed" ? { tone: "danger" as const } : {}),
483 },
484 );
485 const right = inner.x + inner.w;
486 const at = STAGES.findIndex((x) => x.stage === job.stage);
487 const finished = job.stage === "done";
488 STAGES.forEach(({ label: text }, i) => {
489 const y = inner.y + i;
490 const past = finished || (at >= 0 && i < at);
491 const now = at === i;
492 let x = inner.x;
493 if (now) x = spinner(s, x, y, c, ctx.tick);
494 else
495 x = s.text(
496 x,
497 y,
498 past ? G.check : G.ring,
499 style(past ? c.success : c.faint, c.card),
500 right,
501 );
502 x = s.text(
503 x + 1,
504 y,
505 truncate(text, Math.max(0, right - x - 1)),
506 style(now ? c.fg : past ? c.mutedFg : c.faint, c.card),
507 right,
508 );
509 if (now && job.detail !== "")
510 s.text(
511 x + 2,
512 y,
513 truncate(job.detail, Math.max(0, right - x - 2)),
514 style(c.mutedFg, c.card),
515 right,
516 );
517 });
518 const y = inner.y + STAGES.length;
519 if (job.stage === "failed")
520 s.text(
521 inner.x,
522 y,
523 truncate(`${G.cross} ${job.error ?? "the migration failed"}`, inner.w),
524 style(c.danger, c.card),
525 right,
526 );
527 },
528 key(e) {
529 if (e.key === "esc" || e.key === "q") {
530 ctx.pop();
531 ctx.toast("migrating in the background", "default");
532 }
533 return true;
534 },
535 };
536 }
test/tui-migrate.test.ts added +306
@@ -0,0 +1,306 @@
1 /**
2 * Migrate from the terminal: pick a repository from a connected origin, pick
3 * the branch the app lives on, put a crew on it, and end on the repository
4 * with the crew's brief naming that branch. The origin is the fake forge
5 * from `migrate.test.ts`'s world; the clone is real smart HTTP.
6 */
7 import { afterAll, beforeAll, describe, 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 { createApp, type App } from "../src/app.ts";
12 import { createAgent } from "../src/actions/crew.ts";
13 import { connectOrigin } from "../src/actions/origins.ts";
14 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
15 import { listIssues } from "../src/core/issues.ts";
16 import { getRepo } from "../src/core/repos.ts";
17 import { createToken } from "../src/core/tokens.ts";
18 import { createUser, type User } from "../src/core/users.ts";
19 import { repoDir } from "../src/git/repos.ts";
20 import { runGitCapture } from "../src/git/env.ts";
21 import { serve, type Servers } from "../src/http/server.ts";
22 import { forgetProviders } from "../src/origins/providers/index.ts";
23 import { createForgeApp, forgeCommands, Session } from "../src/tui/forge.ts";
24 import { headlessTerminal } from "../src/tui/term/terminal.ts";
25 import { newFakeState, serveFakeForge, type FakeForge } from "./fake-origin.ts";
26 import { quietLogs } from "./helpers/globals.ts";
27 import { promote } from "./helpers/members.ts";
28 import { settleApp } from "./helpers/tui.ts";
29
30 quietLogs();
31
32 let dir: string;
33 let app: App;
34 let servers: Servers;
35 let fake: FakeForge;
36 let alice: User;
37 let tipOfWork = "";
38 let secretToken = "";
39 let base = "";
40
41 const identity = (user: User) => ({
42 sshUsername: user.username,
43 remoteAddress: "127.0.0.1",
44 fingerprint: "SHA256:test",
45 user,
46 });
47
48 async function until(check: () => boolean, ms = 20_000): Promise<void> {
49 const end = Date.now() + ms;
50 while (!check()) {
51 if (Date.now() > end) throw new Error("timed out waiting");
52 await new Promise((r) => setTimeout(r, 25));
53 }
54 }
55
56 beforeAll(async () => {
57 dir = await mkdtemp(join(tmpdir(), "forge-tui-migrate-"));
58 app = createApp({ dataDir: dir, port: 0, migrateFromPrivateHosts: true });
59 servers = await serve(app);
60 base = `http://127.0.0.1:${servers.port}`;
61 const person = async (username: string, adminIfFirst = false) => {
62 const made = await createUser(app.db, {
63 origin: "seed",
64 trusted: true,
65 username,
66 email: `${username}@forge.test`,
67 adminIfFirst,
68 });
69 if (!made.ok) throw new Error(String(made.error));
70 promote(app.db, made.value);
71 return made.value;
72 };
73 alice = await person("alice", true);
74 const octo = await person("octo");
75 const secret = createToken(app.db, octo.id, "upstream", "rw").token;
76 secretToken = secret;
77
78 // The "upstream": two branches, the app on `work`.
79 const made = await createRepo(app, octo, { name: "widgets", visibility: "private" });
80 if (!made.ok) throw new Error(made.error);
81 for (const [branch, path] of [
82 ["main", "README.md"],
83 ["work", "app.ts"],
84 ] as const) {
85 const done = await commitToBranch(app, octo, made.value, {
86 branch,
87 message: `on ${branch}`,
88 files: [{ path, content: `${branch}\n` }],
89 });
90 if (!done.ok) throw new Error(String(done.error));
91 }
92 const dirOf = repoDir(app.reposRoot, "octo", "widgets");
93 const sha = async (ref: string) =>
94 (await runGitCapture(["rev-parse", ref], { cwd: dirOf })).stdout.trim();
95 tipOfWork = await sha("work");
96
97 const state = newFakeState("forgejo", "octo", secret);
98 state.repos.push({
99 id: 5,
100 fullName: "octo/widgets",
101 description: "gears",
102 cloneUrl: `${base}/octo/widgets.git`,
103 htmlUrl: `${base}/octo/widgets`,
104 defaultBranch: "main",
105 private: true,
106 });
107 state.branches.set("octo/widgets", [
108 { name: "main", sha: await sha("main") },
109 { name: "work", sha: tipOfWork },
110 ]);
111 // `main` was touched last, by somebody else; `work` is octo's own.
112 state.commits.set("octo/widgets", [
113 { sha: await sha("main"), message: "on main", authorName: "Zed", login: "zed", date: "2026-10-19T10:00:00Z" },
114 { sha: tipOfWork, message: "on work", authorName: "Octo", login: "octo", date: "2026-10-01T10:00:00Z" },
115 ]);
116 // A public repository for the paste-a-URL path.
117 const pub = await createRepo(app, octo, { name: "pub", visibility: "public" });
118 if (!pub.ok) throw new Error(pub.error);
119 const pubDone = await commitToBranch(app, octo, pub.value, {
120 branch: "main",
121 message: "hello",
122 files: [{ path: "README.md", content: "hi\n" }],
123 });
124 if (!pubDone.ok) throw new Error(String(pubDone.error));
125 // Repositories whose branches are none, or cannot be read.
126 for (const [id, fullName] of [[7, "octo/empty"], [8, "octo/flaky"]] as const)
127 state.repos.push({ id, fullName, cloneUrl: `${base}/${fullName}.git`, defaultBranch: "main", private: false });
128 state.branches.set("octo/empty", []);
129 state.branches.set("octo/flaky", [{ name: "main", sha: "a".repeat(40) }]);
130 fake = serveFakeForge(state);
131 forgetProviders(dir);
132 const connected = await connectOrigin(app, alice, { host: fake.url, token: secret });
133 if (!connected.ok) throw new Error(connected.error);
134
135 const agent = await createAgent(app, alice, { username: "porter", model: "", brief: "Moves things in." });
136 if (!agent.ok) throw new Error(String(agent.error));
137 }, 30_000);
138
139 afterAll(async () => {
140 fake.stop();
141 servers.stop();
142 app.close();
143 forgetProviders(dir);
144 await rm(dir, { recursive: true, force: true });
145 });
146
147 describe("migrate from the terminal", () => {
148 test("pick a repository, the person's own newest branch is first, a crew goes on it, and it ends on the repository", async () => {
149 const terminal = headlessTerminal(120, 40);
150 const { app: tui } = createForgeApp(app, identity(alice), { terminal, exitHooks: false });
151 tui.start();
152 try {
153 await settleApp(tui);
154 terminal.feed("M");
155 await settleApp(tui);
156 expect(tui.screen.toText()).toContain("octo/widgets");
157 expect(tui.screen.toText()).toContain("private");
158
159 terminal.feed("/widgets\r"); // filter, then
160 terminal.feed("\r"); // the repository: its branches
161 await settleApp(tui);
162 const branches = tui.screen.toText();
163 expect(branches).toContain("Branch of octo/widgets");
164 // `main` is newer, but `work` is octo's own, so it is on top and suggested.
165 expect(branches.indexOf("work")).toBeLessThan(branches.indexOf("main"));
166 expect(branches).toContain("suggested");
167
168 terminal.feed("\r"); // take the suggestion
169 await settleApp(tui);
170 expect(tui.screen.toText()).toContain("branch work");
171
172 terminal.feed("\t\t\t "); // owner, visibility, agent → porter
173 await settleApp(tui);
174 expect(tui.screen.toText()).toContain("porter");
175 terminal.feed("\x04"); // ctrl+d: migrate
176 await until(() => listIssues(app.db, getRepo(app.db, "alice", "widgets")?.id ?? "", { kind: "issue", state: "open" }).length === 1);
177 await settleApp(tui);
178
179 const repo = getRepo(app.db, "alice", "widgets")!;
180 expect(repo.defaultBranch).toBe("work");
181 const head = await runGitCapture(["rev-parse", "work"], { cwd: repoDir(app.reposRoot, "alice", "widgets") });
182 expect(head.stdout.trim()).toBe(tipOfWork);
183 const brief = listIssues(app.db, repo.id, { kind: "issue", state: "open" })[0]!;
184 expect(brief.assigneeName).toBe("porter");
185 expect(brief.body).toContain("The app is on the branch `work`");
186 // Done lands on the repository.
187 await until(() => tui.screen.toText().includes("alice/widgets"));
188 } finally {
189 tui.stop?.();
190 }
191 }, 60_000);
192
193 /** A terminal for `user` (or nobody), with `body` run and the terminal always stopped. */
194 async function withTui(
195 user: User | undefined,
196 body: (h: { tui: ReturnType<typeof createForgeApp>["app"]; terminal: ReturnType<typeof headlessTerminal> }) => Promise<void>,
197 ): Promise<void> {
198 const terminal = headlessTerminal(120, 40);
199 const id = user === undefined ? { sshUsername: "git", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user } : identity(user);
200 const { app: tui } = createForgeApp(app, id, { terminal, exitHooks: false });
201 tui.start();
202 try {
203 await settleApp(tui);
204 await body({ tui, terminal });
205 } finally {
206 tui.stop?.();
207 }
208 }
209
210 test("u pastes a URL: an empty address is told so, the name comes from the URL, the branch step is skipped, and a blank name keeps the source's", () =>
211 withTui(alice, async ({ tui, terminal }) => {
212 terminal.feed("Mu");
213 await settleApp(tui);
214 terminal.feed("\r");
215 await settleApp(tui);
216 expect(tui.screen.toText()).toContain("Paste an address first.");
217 terminal.feed(`${base}/octo/pub.git\r`);
218 await settleApp(tui);
219 const form = tui.screen.toText();
220 expect(form).toContain(`from ${base}/octo/pub.git`);
221 expect(form).not.toContain("Branch of");
222 expect(form).not.toContain("· branch ");
223 expect(form).toContain("pub");
224 terminal.feed("\x7f\x7f\x7f"); // clear the name
225 terminal.feed("\x04");
226 await until(() => getRepo(app.db, "alice", "pub") !== undefined);
227 await until(() => tui.screen.toText().includes("alice/pub"));
228 }));
229
230 test("a refusal is shown on the form and nothing is made; a clone that fails shows on the card", () =>
231 withTui(alice, async ({ tui, terminal }) => {
232 terminal.feed("Mu");
233 await settleApp(tui);
234 terminal.feed(`${base}/octo/missing.git\r`);
235 await settleApp(tui);
236 terminal.feed("\x7f\x7f\x7f\x7f\x7f\x7f\x7f"); // "missing" → ""
237 terminal.feed("a b!");
238 terminal.feed("\x04");
239 await settleApp(tui);
240 expect(tui.screen.toText()).toContain("Names start with a letter or digit");
241 expect(getRepo(app.db, "alice", "a b!")).toBeUndefined();
242
243 // Fix the name: the clone itself cannot work, so the job fails.
244 terminal.feed("\x7f\x7f\x7f"); // "a b!" → ""
245 terminal.feed("gone");
246 terminal.feed("\x04");
247 await until(() => tui.screen.toText().includes("could not be read") || tui.screen.toText().includes("\u2717"));
248 await settleApp(tui);
249 const card = tui.screen.toText();
250 expect(card).toContain("Migrating");
251 expect(card).toContain("\u2717");
252 expect(getRepo(app.db, "alice", "gone")).toBeUndefined();
253 }));
254
255 test("signed out: M does nothing and the palette does not offer it", () =>
256 withTui(undefined, async ({ tui, terminal }) => {
257 terminal.feed("M");
258 await settleApp(tui);
259 expect(tui.screen.toText()).not.toContain("Migrate a repository");
260 const ids = forgeCommands(new Session(app, { sshUsername: "git", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user: undefined }))({} as never).map((c) => c.id);
261 expect(ids).not.toContain("migrate");
262 expect(forgeCommands(new Session(app, identity(alice)))({} as never).map((c) => c.id)).toContain("migrate");
263 }));
264
265 test("a repository with no branches, and an origin that cannot be read, are told in words", () =>
266 withTui(alice, async ({ tui, terminal }) => {
267 terminal.feed("M");
268 await settleApp(tui);
269 terminal.feed("/empty\r\r");
270 await settleApp(tui);
271 expect(tui.screen.toText()).toContain("octo/empty has no branches");
272
273 terminal.feed("/\x7f\x7f\x7f\x7f\x7f\x7f"); // clear the filter, then ask for flaky
274 terminal.feed("\x1b");
275 await settleApp(tui);
276 terminal.feed("/flaky\r");
277 fake.state.rateLimitFor = 50;
278 terminal.feed("\r");
279 await settleApp(tui);
280 fake.state.rateLimitFor = 0;
281 expect(tui.screen.toText()).toContain("Could not reach the origin. Try again.");
282 }));
283
284 test("an origin that does not answer is named, and the others are still listed", async () => {
285 const erin = await createUser(app.db, { origin: "seed", trusted: true, username: "erin", email: "[email protected]" });
286 if (!erin.ok) throw new Error(String(erin.error));
287 promote(app.db, erin.value);
288 const down = serveFakeForge(newFakeState("forgejo", "ghost", "tok-ghost"));
289 try {
290 for (const [host, token] of [[fake.url, secretToken], [down.url, "tok-ghost"]] as const) {
291 const connected = await connectOrigin(app, erin.value, { host, token });
292 if (!connected.ok) throw new Error(connected.error);
293 }
294 down.state.rateLimitFor = 1000;
295 await withTui(erin.value, async ({ tui, terminal }) => {
296 terminal.feed("M");
297 await settleApp(tui);
298 const text = tui.screen.toText();
299 expect(text).toContain("octo/widgets");
300 expect(text).toContain("Could not reach ghost. The others are listed.");
301 });
302 } finally {
303 down.stop();
304 }
305 });
306 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs