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

A test pins discarding a change in the local terminal: `d` asks first, and cancelling keeps the file byte for byte #7077

Merged opened this

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

Merged

scout/forge:scout/7066-a-test-pins-discarding-a-change-in-the-l → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/7066-a-test-pins-discarding-a-change-in-the-l was merged into main as 638e547.

Review

Sign in to review this change.
4 files changed +225 −3
src/git/local.ts +10 −2
@@ -441,10 +441,16 @@ export async function unstageAll(dir: string): Promise<LocalResult<void>> {
441441 export async function discardPath(
442442 dir: string,
443443 path: string,
444 opts: { untracked?: boolean } = {},
444 opts: { untracked?: boolean; unstagedOnly?: boolean } = {},
445445 ): Promise<LocalResult<void>> {
446446 if (opts.untracked === true)
447447 return done(await runLocal(["clean", "-fd", "--", path], { cwd: dir }));
448 // The unstaged half only: the working file goes back to the index, and

Good: restores from the index rather than HEAD, so the staged half is kept. The argument array and -- keep the path from being read as an option.

449 // whatever is staged stays staged.
450 if (opts.unstagedOnly === true)
451 return done(
452 await runLocal(["restore", "--worktree", "--", path], { cwd: dir }),
453 );
448454 const out = await runLocal(
449455 ["restore", "--staged", "--worktree", "--source=HEAD", "--", path],
450456 { cwd: dir },
@@ -864,7 +870,9 @@ export async function isAncestor(
864870 /** Is `oid` a commit object in the repository? One `git cat-file -e`. */
865871 export async function hasCommit(dir: string, oid: string): Promise<boolean> {
866872 if (!isSafeRef(oid)) return false;
867 const out = await runLocal(["cat-file", "-e", `${oid}^{commit}`], { cwd: dir });
873 const out = await runLocal(["cat-file", "-e", `${oid}^{commit}`], {
874 cwd: dir,
875 });
868876 return out.ok;
869877 }
870878
src/tui/local/work.ts +3
@@ -261,6 +261,9 @@ export function workScreen(local: Local, ctx: Ctx, repo: LocalRepo): Screen {
261261 () =>
262262 git.discardPath(repo.dir, entry.path, {
263263 ...(untracked ? { untracked: true } : {}),
264 ...(item.section === "unstaged"
265 ? { unstagedOnly: true }
266 : {}),
264267 }),
265268 reload,
266269 );
test/git-local.test.ts +24 −1
@@ -320,7 +320,12 @@ describe("commits, branches and the log", () => {
320320 // the root closes again — a lane left open past the commit it was waiting
321321 // for would draw a line of history that does not exist.
322322 expect(log.every((c) => c.graph.includes("\u25cf"))).toBe(true);
323 expect(log.map((c) => c.graph)).toEqual(["\u25cf\u256e", "\u2502\u25cf", "\u25cf\u2502", "\u25cf\u256f"]);
323 expect(log.map((c) => c.graph)).toEqual([
324 "\u25cf\u256e",
325 "\u2502\u25cf",
326 "\u25cf\u2502",
327 "\u25cf\u256f",
328 ]);
324329 });
325330
326331 test("the log filtered to one path only mentions that path", async () => {
@@ -544,6 +549,24 @@ describe("discarding", () => {
544549 expect(await Bun.file(join(dir, "junk.txt")).exists()).toBe(false);
545550 });
546551
552 test("unstagedOnly puts the file back to the index and leaves the staged half", async () => {
553 const dir = await newRepo(root, "discard-half");
554 await writeFile(join(dir, "a.txt"), "one\n");
555 await git.stageAll(dir);
556 await git.commit(dir, "base");
557 await writeFile(join(dir, "a.txt"), "two\n");
558 await git.stagePath(dir, "a.txt");
559 await writeFile(join(dir, "a.txt"), "three\n");
560 expect(
561 (await git.discardPath(dir, "a.txt", { unstagedOnly: true })).ok,
562 ).toBe(true);
563 expect(await Bun.file(join(dir, "a.txt")).text()).toBe("two\n");
564 const status = await git.status(dir);
565 expect(status.ok && status.value.staged.map((f) => f.path)).toEqual([
566 "a.txt",
567 ]);
568 });
569
547570 test("unstaging works before there is a HEAD to restore from", async () => {
548571 const dir = await newRepo(root, "first-file");
549572 await writeFile(join(dir, "a.txt"), "1\n");
test/tui-local-discard.test.ts added +188
@@ -0,0 +1,188 @@
1 /**
2 * The local screens, driven through the headless terminal.
3 *
4 * Everything here goes in as bytes and comes out as a grid: no screen is
5 * reached into, no private state is inspected. If a key does not produce the
6 * text a person would see, the test fails — which is the only property worth
7 * asserting about a terminal UI.
8 */
9
10 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
11 import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
12 import { tmpdir } from "node:os";
13 import { join } from "node:path";
14 import * as git from "../src/git/local.ts";
15 import type { App } from "../src/tui/app/app.ts";
16 import {
17 headlessTerminal,
18 type HeadlessTerminal,
19 } from "../src/tui/term/terminal.ts";
20 import { createLocalApp } from "../src/tui/local/app.ts";
21 import { localBusy, localIdle } from "../src/tui/local/common.ts";
22 import { gitClientEnv } from "./helpers/git-client.ts";
23
24 async function sh(args: string[], cwd: string): Promise<void> {
25 const proc = Bun.spawn(["git", ...args], {
26 cwd,
27 env: gitClientEnv({
28 GIT_AUTHOR_NAME: "Ada",
29 GIT_AUTHOR_EMAIL: "[email protected]",
30 GIT_COMMITTER_NAME: "Ada",
31 GIT_COMMITTER_EMAIL: "[email protected]",
32 }),
33 stdout: "pipe",
34 stderr: "pipe",
35 });
36 const [err, code] = await Promise.all([
37 new Response(proc.stderr).text(),
38 proc.exited,
39 ]);
40 if (code !== 0) throw new Error(`git ${args.join(" ")}: ${err}`);
41 }
42
43 const sleep = (ms: number): Promise<void> =>
44 new Promise((resolve) => setTimeout(resolve, ms));
45
46 /** Wait until nothing is loading, then paint. */
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 /** Type, then let the loop see it. Esc needs the decoder's idle backstop. */
60 async function type(
61 term: HeadlessTerminal,
62 app: App,
63 keys: string,
64 ): Promise<void> {
65 term.feed(keys);
66 if (keys.includes("\x1b")) await sleep(40);
67 await settle(app);
68 }
69
70 const IDENTITY = {
71 GIT_AUTHOR_NAME: "Ada",
72 GIT_AUTHOR_EMAIL: "[email protected]",
73 GIT_COMMITTER_NAME: "Ada",
74 GIT_COMMITTER_EMAIL: "[email protected]",
75 };
76
77 const A_CHANGED = "caf\u00e9 changed\r\nno trailing newline";
78
79 describe("discarding in the local terminal", () => {
80 let root = "";
81 let dir = "";
82 let term: HeadlessTerminal;
83 let app: App;
84 const ambient: Record<string, string | undefined> = {};
85
86 const read = (name: string): Promise<Uint8Array> =>
87 Bun.file(join(dir, name)).bytes();
88 const same = async (name: string, text: string): Promise<boolean> =>
89 Buffer.compare(await read(name), Buffer.from(text)) === 0;
90
91 beforeAll(async () => {
92 for (const [key, value] of Object.entries(IDENTITY)) {
93 ambient[key] = process.env[key];
94 process.env[key] = value;
95 }
96 root = await mkdtemp(join(tmpdir(), "forge-tui-discard-"));
97 dir = join(root, "forge.test", "ada", "workshop");
98 await mkdir(dir, { recursive: true });
99 await sh(["init", "-b", "main", "."], dir);
100 await sh(["config", "user.name", "Ada"], dir);
101 await sh(["config", "user.email", "[email protected]"], dir);
102 await sh(["config", "core.autocrlf", "false"], dir);
103 await writeFile(join(dir, "a.txt"), "a committed\n");
104 await writeFile(join(dir, "b.txt"), "b committed\n");
105 await sh(["add", "-A"], dir);
106 await sh(["commit", "-m", "base"], dir);
107
108 // a.txt: changed in the working tree only.
109 await writeFile(join(dir, "a.txt"), A_CHANGED);
110 // b.txt: half-staged — one edit in the index, another on top of it.
111 await writeFile(join(dir, "b.txt"), "b staged\n");
112 await sh(["add", "b.txt"], dir);
113 await writeFile(join(dir, "b.txt"), "b unstaged\n");
114
115 term = headlessTerminal(120, 40);
116 app = createLocalApp({ root, terminal: term, theme: "dark" }).app;
117 app.start();
118 await settle(app);
119 await type(term, app, "\r");
120 });
121
122 afterAll(async () => {
123 app.stop();
124 for (const [key, value] of Object.entries(ambient)) {
125 if (value === undefined) delete process.env[key];
126 else process.env[key] = value;
127 }
128 await rm(root, { recursive: true, force: true });
129 });
130
131 test("d asks first, naming the path, and escape keeps the file byte for byte", async () => {
132 expect(term.toText()).toContain("CHANGED");
133 await type(term, app, "j"); // staged b.txt -> changed a.txt
134 await type(term, app, "d");
135 const text = term.toText();
136 expect(text).toContain("Throw away changes to a.txt?");
137 expect(text).toContain("This cannot be undone.");
138 expect(await same("a.txt", A_CHANGED)).toBe(true);
139
140 await type(term, app, "\x1b");
141 expect(term.toText()).not.toContain("Throw away");
142 expect(await same("a.txt", A_CHANGED)).toBe(true);
143 });
144
145 test("the default choice is Cancel: enter alone discards nothing", async () => {
146 await type(term, app, "d");
147 await type(term, app, "\r");
148 expect(await same("a.txt", A_CHANGED)).toBe(true);
149 });
150
151 test("confirming restores the committed content and the row leaves CHANGED", async () => {
152 await type(term, app, "d");
153 await type(term, app, "\x1b[C\r"); // right: Discard, enter
154 expect(await same("a.txt", "a committed\n")).toBe(true);
155 const status = await git.status(dir);
156 expect(status.ok && status.value.unstaged.map((f) => f.path)).toEqual([
157 "b.txt",
158 ]);
159 expect(term.toText()).not.toContain("a.txt");
160 });
161
162 test("on a half-staged path, discard drops only the unstaged half", async () => {
163 // The cursor landed on the one remaining Changed row: b.txt.
164 await type(term, app, "d");
165 expect(term.toText()).toContain("Throw away changes to b.txt?");
166 await type(term, app, "\x1b[C\r");
167 expect(await same("b.txt", "b staged\n")).toBe(true);
168 const status = await git.status(dir);
169 expect(status.ok && status.value.staged.map((f) => f.path)).toEqual([
170 "b.txt",
171 ]);
172 expect(status.ok && status.value.unstaged.length).toBe(0);
173 expect(term.toText()).toContain("STAGED");
174 expect(term.toText()).not.toContain("CHANGED");
175 });
176
177 test("with no row under the cursor, d is disabled and does nothing", async () => {
178 await sh(["reset", "--hard", "HEAD"], dir);
179 await type(term, app, "a"); // reload the screen
180 const text = term.toText();
181 expect(text).toContain("Nothing to commit");
182 await type(term, app, "d");
183 expect(term.toText()).not.toContain("Throw away");
184 expect(term.toText()).not.toContain("Discard changes");
185 expect(await same("a.txt", "a committed\n")).toBe(true);
186 expect(await same("b.txt", "b committed\n")).toBe(true);
187 });
188 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs