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

A test pins deleting a branch in the local terminal: `d` refuses unmerged work, `D` says it will lose it, both ask first #7078

Merged opened this

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

Merged

scout/forge:scout/7067-a-test-pins-deleting-a-branch-in-the-loc → main 3 commits fork 2 threads · 2 unresolved

  • Merged. scout/7067-a-test-pins-deleting-a-branch-in-the-loc was merged into main as 2fb8174.

Review

Sign in to review this change.
1 file changed +184 −0
test/tui-local-branch-delete.test.ts added +184
@@ -0,0 +1,184 @@
1 /**
2 * Deleting a branch from the local terminal's branch list.
3 *
4 * `d` is git's safe delete and `D` its forced one; both go through a confirm,
5 * and the confirm for `D` says commits will be lost. Driven as a person would,
6 * over a real temporary repository: bytes in, grid out.
7 */
8
9 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
10 import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
11 import { tmpdir } from "node:os";
12 import { join } from "node:path";
13 import * as git from "../src/git/local.ts";
14 import type { App } from "../src/tui/app/app.ts";
15 import {
16 headlessTerminal,
17 type HeadlessTerminal,
18 } from "../src/tui/term/terminal.ts";
19 import { createLocalApp } from "../src/tui/local/app.ts";
20 import { localBusy, localIdle } from "../src/tui/local/common.ts";
21 import { gitClientEnv } from "./helpers/git-client.ts";
22
23 const IDENTITY = {
24 GIT_AUTHOR_NAME: "Ada",
25 GIT_AUTHOR_EMAIL: "[email protected]",
26 GIT_COMMITTER_NAME: "Ada",
27 GIT_COMMITTER_EMAIL: "[email protected]",
28 };
29
30 async function sh(args: string[], cwd: string): Promise<void> {
31 const proc = Bun.spawn(["git", ...args], {
32 cwd,
33 env: gitClientEnv(IDENTITY),
34 stdout: "pipe",
35 stderr: "pipe",
36 });
37 const [err, code] = await Promise.all([
38 new Response(proc.stderr).text(),
39 proc.exited,
40 ]);
41 if (code !== 0) throw new Error(`git ${args.join(" ")}: ${err}`);
42 }
43
44 const sleep = (ms: number): Promise<void> =>
45 new Promise((resolve) => setTimeout(resolve, ms));
46
47 async function settle(app: App): Promise<void> {
48 for (let i = 0; i < 400; i++) {
49 await localIdle();
50 if (!app.busy && !localBusy()) {
51 await sleep(4);
52 if (!app.busy && !localBusy()) break;
53 }
54 await sleep(4);
55 }
56 app.frame();
57 }
58
59 async function type(
60 term: HeadlessTerminal,
61 app: App,
62 keys: string,
63 ): Promise<void> {
64 term.feed(keys);
65 if (keys.includes("\x1b")) await sleep(40);
66 await settle(app);
67 }
68
69 async function names(dir: string): Promise<string[]> {
70 const result = await git.branches(dir);
71 return result.ok ? result.value.map((b) => b.name).sort() : [];
72 }
73
74 describe("deleting a branch in the local terminal", () => {
75 let root = "";
76 let dir = "";
77 let term: HeadlessTerminal;
78 let app: App;
79 const ambient: Record<string, string | undefined> = {};
80
81 /** Move the cursor to a branch by filtering to its name, then leave the filter. */
82 const select = async (name: string): Promise<void> => {
83 await type(term, app, "/");
84 await type(term, app, "\x7f".repeat(20));
85 await type(term, app, name);
86 await type(term, app, "\r");
87 };
88
89 beforeAll(async () => {
90 for (const [key, value] of Object.entries(IDENTITY)) {
91 ambient[key] = process.env[key];
92 process.env[key] = value;
93 }
94 root = await mkdtemp(join(tmpdir(), "forge-tui-branch-delete-"));
95 dir = join(root, "forge.test", "ada", "workshop");
96 await mkdir(dir, { recursive: true });
97 await sh(["init", "-b", "main", "."], dir);
98 await writeFile(join(dir, "poem.txt"), "one\n");
99 await sh(["add", "-A"], dir);
100 await sh(["commit", "-m", "the first commit"], dir);
101 // Merged: points at main. Unmerged: holds a commit main does not.
102 await sh(["branch", "stale"], dir);
103 await sh(["checkout", "-b", "wip"], dir);
104 await writeFile(join(dir, "poem.txt"), "one\ntwo\n");
105 await sh(["commit", "-am", "work only here"], dir);
106 await sh(["checkout", "main"], dir);
107
108 term = headlessTerminal(120, 40);
109 app = createLocalApp({ root, terminal: term, theme: "dark" }).app;
110 app.start();
111 await settle(app);
112 await type(term, app, "\r"); // the repo's working tree
113 await type(term, app, "b"); // its branches
114 });
115
116 afterAll(async () => {
117 app.stop();
118 for (const [key, value] of Object.entries(ambient)) {
119 if (value === undefined) delete process.env[key];
120 else process.env[key] = value;
121 }
122 await rm(root, { recursive: true, force: true });
123 });
124
125 test("`d` on a merged branch asks, and confirming removes it", async () => {
126 await select("stale");
127 await type(term, app, "d");
128 const text = term.toText();
129 expect(text).toContain("Delete branch");
130 expect(text).not.toContain("Force delete branch");
131 expect(text).toContain("Delete stale?");
132 expect(await names(dir)).toContain("stale");
133
134 // Cancel is the default; move to Delete.
135 await type(term, app, "\t");
136 await type(term, app, "\r");
137 expect(await names(dir)).not.toContain("stale");
138 expect(term.toText()).toContain("No branches");
139 });
140
141 test("`d` on an unmerged branch is refused in words, and it stays", async () => {
142 await select("wip");
143 await type(term, app, "d");
144 await type(term, app, "\t");
145 await type(term, app, "\r");
146 const text = term.toText();
147 expect(text).toMatch(/not fully merged/i);
148 expect(await names(dir)).toContain("wip");
149 });
150
151 test("`D` says commits will be lost; Escape keeps the branch", async () => {
152 await select("wip");
153 await type(term, app, "D");
154 const text = term.toText();
155 expect(text).toContain("Force delete branch");
156 expect(text).toMatch(/unreachable/);
157 expect(text).toContain("even if it is not merged");
158
159 await type(term, app, "\x1b");
160 expect(term.toText()).not.toContain("Force delete branch");
161 expect(await names(dir)).toContain("wip");
162 });
163
164 test("confirming `D` removes the unmerged branch", async () => {

This test relies on the previous one leaving the cursor on wip. Add await select("wip") first so it stands alone.

165 await select("wip");
166 await type(term, app, "D");
167 await type(term, app, "\t");
168 await type(term, app, "\r");
169 expect(await names(dir)).not.toContain("wip");
170 });
171
172 test("on the current branch, `d` and `D` are disabled", async () => {
173 await select("main");
174 for (const key of ["d", "D"]) {
175 await type(term, app, key);
outdated

This can't catch D: the force dialog's title is "Force delete branch", with a lower-case d, so not.toContain("Delete branch") passes even when it is open. I let D open on main in branches.ts and all 5 tests still passed. Use not.toMatch(/delete branch/i) and press Escape after each key.

176 const opened = /delete branch/i.test(term.toText());
177 // Close a dialog opened by mistake so it cannot hide the next key; with
178 // none open, Escape would leave the branch list, so it is not pressed.
179 if (opened) await type(term, app, "\x1b");
180 expect(opened).toBe(false);
181 }
182 expect(await names(dir)).toContain("main");
183 });
184 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs