Public: anyone can read this; the crew can change it.
A test pins writing a commit in the local terminal: an empty subject is refused, and a subject past 50 columns is marked #7083
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7069-a-test-pins-writing-a-commit-in-the-loca → main 1 commit fork 1 thread · 1 unresolved
2 files
changed +171
−2
src/tui/local/commit.ts +13 −2
| @@ -135,8 +135,19 @@ export function commitScreen(ctx: Ctx, dir: string, o: CommitOpts): Screen { | ||
| 135 | 135 | ctx.setCursor(focus === "subject" ? cursor : bodyCursor); |
| 136 | 136 | }, |
| 137 | 137 | paste(chunk) { |
| 138 | if (focus === "subject") subject += chunk.replace(/\s+/g, " "); | |
| 139 | else body.paste(chunk); | |
| 138 | if (focus === "body") { | |
| 139 | body.paste(chunk); | |
| 140 | return true; | |
| 141 | } | |
| 142 | // A pasted message is a subject and a body: the first line stays in | |
| 143 | // the field, the rest moves to the body, where the cursor follows. | |
| 144 | const [first = "", ...rest] = chunk.replace(/\r\n?/g, "\n").split("\n"); | |
| 145 | subject += first.replace(/\s+/g, " "); | |
| 146 | const more = rest.join("\n").replace(/^\n+/, ""); | |
| 147 | if (more.trim() !== "") { | |
| 148 | body.paste(more); | |
| 149 | focus = "body"; | |
| 150 | } | |
| 140 | 151 | return true; |
| 141 | 152 | }, |
| 142 | 153 | key(e) { |
test/tui-local-commit.test.ts added +158
| @@ -0,0 +1,158 @@ | ||
| 1 | /** | |
| 2 | * The commit screen's message rules, driven through `createLocalApp` over a | |
| 3 | * real temporary repository: an empty subject is refused, a long one is | |
| 4 | * marked but still commits, and a pasted message splits into subject + body. | |
| 5 | */ | |
| 6 | ||
| 7 | import { afterEach, beforeEach, describe, expect, test } from "bun:test"; | |
| 8 | import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import * as git from "../src/git/local.ts"; | |
| 12 | import type { App } from "../src/tui/app/app.ts"; | |
| 13 | import { createLocalApp } from "../src/tui/local/app.ts"; | |
| 14 | import { localBusy, localIdle } from "../src/tui/local/common.ts"; | |
| 15 | import { | |
| 16 | headlessTerminal, | |
| 17 | type HeadlessTerminal, | |
| 18 | } from "../src/tui/term/terminal.ts"; | |
| 19 | import { gitClientEnv } from "./helpers/git-client.ts"; | |
| 20 | ||
| 21 | const IDENTITY = { | |
| 22 | GIT_AUTHOR_NAME: "Ada", | |
| 23 | GIT_AUTHOR_EMAIL: "[email protected]", | |
| 24 | GIT_COMMITTER_NAME: "Ada", | |
| 25 | GIT_COMMITTER_EMAIL: "[email protected]", | |
| 26 | }; | |
| 27 | ||
| 28 | async function sh(args: string[], cwd: string): Promise<void> { | |
| 29 | const proc = Bun.spawn(["git", ...args], { | |
| 30 | cwd, | |
| 31 | env: gitClientEnv(IDENTITY), | |
| 32 | stdout: "pipe", | |
| 33 | stderr: "pipe", | |
| 34 | }); | |
| 35 | const [err, code] = await Promise.all([ | |
| 36 | new Response(proc.stderr).text(), | |
| 37 | proc.exited, | |
| 38 | ]); | |
| 39 | if (code !== 0) throw new Error(`git ${args.join(" ")}: ${err}`); | |
| 40 | } | |
| 41 | ||
| 42 | const sleep = (ms: number): Promise<void> => | |
| 43 | new Promise((resolve) => setTimeout(resolve, ms)); | |
| 44 | ||
| 45 | async function settle(app: App): Promise<void> { | |
| 46 | for (let i = 0; i < 400; i++) { | |
| 47 | await localIdle(); | |
| 48 | if (!app.busy && !localBusy()) { | |
| 49 | await sleep(4); | |
| 50 | if (!app.busy && !localBusy()) break; | |
| 51 | } | |
| 52 | await sleep(4); | |
| 53 | } | |
| 54 | app.frame(); | |
| 55 | } | |
| 56 | ||
| 57 | async function type( | |
| 58 | term: HeadlessTerminal, | |
| 59 | app: App, | |
| 60 | keys: string, | |
| 61 | ): Promise<void> { | |
| 62 | term.feed(keys); | |
| 63 | if (keys.includes("\x1b")) await sleep(40); | |
| 64 | await settle(app); | |
| 65 | } | |
| 66 | ||
| 67 | describe("the local commit screen", () => { | |
| 68 | let root = ""; | |
| 69 | let dir = ""; | |
| 70 | let term: HeadlessTerminal; | |
| 71 | let app: App; | |
| 72 | const ambient: Record<string, string | undefined> = {}; | |
| 73 | ||
| 74 | beforeEach(async () => { | |
| 75 | // The app inherits this process's identity on purpose; pin it to Ada. | |
| 76 | for (const [key, value] of Object.entries(IDENTITY)) { | |
| 77 | ambient[key] = process.env[key]; | |
| 78 | process.env[key] = value; | |
| 79 | } | |
| 80 | root = await mkdtemp(join(tmpdir(), "forge-tui-commit-")); | |
| 81 | dir = join(root, "forge.test", "ada", "workshop"); | |
| 82 | await mkdir(dir, { recursive: true }); | |
| 83 | await sh(["init", "-b", "main", "."], dir); | |
| 84 | await sh(["config", "user.name", "Ada"], dir); | |
| 85 | await sh(["config", "user.email", "[email protected]"], dir); | |
| 86 | await writeFile(join(dir, "poem.txt"), "one\n"); | |
| 87 | await sh(["add", "-A"], dir); | |
| 88 | await sh(["commit", "-m", "the first commit"], dir); | |
| 89 | await writeFile(join(dir, "poem.txt"), "one\ntwo\n"); | |
| 90 | await sh(["add", "-A"], dir); | |
| 91 | ||
| 92 | term = headlessTerminal(120, 40); | |
| 93 | app = createLocalApp({ root, terminal: term, theme: "dark" }).app; | |
| 94 | app.start(); | |
| 95 | await settle(app); | |
| 96 | await type(term, app, "\r"); // open the working tree | |
| 97 | await type(term, app, "c"); // open the commit screen | |
| 98 | expect(term.toText()).toContain("1 file"); | |
| 99 | }); | |
| 100 | ||
| 101 | afterEach(async () => { | |
| 102 | app.stop(); | |
| 103 | for (const [key, value] of Object.entries(ambient)) { | |
| 104 | if (value === undefined) delete process.env[key]; | |
| 105 | else process.env[key] = value; | |
| 106 | } | |
| 107 | await rm(root, { recursive: true, force: true }); | |
| 108 | }); | |
| 109 | ||
| 110 | async function head(): Promise<string> { | |
| 111 | const m = await git.headMessage(dir); | |
| 112 | return m.ok ? m.value : ""; | |
| 113 | } | |
| 114 | ||
| 115 | /** The colour of the subject field's left bar, found via its hint line. */ | |
| 116 | function barColour(): number { | |
| 117 | const lines = term.toText().split("\n"); | |
| 118 | const hint = lines.findIndex((l) => /\d+ (over|left)/.test(l)); | |
| 119 | expect(hint).toBeGreaterThan(0); | |
| 120 | const x = lines[hint]!.search(/\d/); | |
| 121 | return term.screen.styleAt(x, hint - 1).fg as number; | |
| 122 | } | |
| 123 | ||
| 124 | for (const subject of ["", " "]) { | |
| 125 | test(`ctrl+d with subject ${JSON.stringify(subject)} is refused`, async () => { | |
| 126 | await type(term, app, subject); | |
| 127 | await type(term, app, "\x04"); | |
| 128 | expect(term.toText()).toContain("a subject is required"); | |
| 129 | expect(await head()).toBe("the first commit"); | |
| 130 | // Still on the commit screen, nothing committed. | |
| 131 | expect(term.toText()).toContain("Commit"); | |
| 132 | }); | |
| 133 | } | |
| 134 | ||
| 135 | test("a subject past the limit is marked, by display width, and commits", async () => { | |
| 136 | // 25 wide characters are 50 columns: at the limit, not over it. | |
| 137 | await type(term, app, "世".repeat(25)); | |
| 138 | expect(term.toText()).toContain("0 left"); | |
| 139 | const ok = barColour(); | |
| 140 | ||
| 141 | // Two columns more is 25 chars + 1 = 52 columns, though only 26 chars. | |
| 142 | await type(term, app, "世"); | |
| 143 | expect(term.toText()).toContain("2 over the 50-column subject line"); | |
| 144 | expect(barColour()).not.toBe(ok); | |
| 145 | ||
| 146 | await type(term, app, "\x04"); | |
| 147 | expect(await head()).toBe("世".repeat(26)); | |
| 148 | }); | |
| 149 | ||
| 150 | test("a pasted first line is the subject and the rest the body", async () => { | |
| 151 | term.feed("\x1b[200~fix the thing\n\nbecause it was broken\nand slow\x1b[201~"); | |
| 152 | await settle(app); | |
| 153 | await type(term, app, "\x04"); | |
| 154 | expect(await head()).toBe( | |
| 155 | "fix the thing\n\nbecause it was broken\nand slow", | |
| 156 | ); | |
| 157 | }); | |
| 158 | }); |
new above
offline
Fine: a first line followed only by blank lines stays in the subject and focus doesn't move. That matches the description.