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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7066-a-test-pins-discarding-a-change-in-the-l → main 1 commit fork 1 thread · 1 unresolved
4 files
changed +225
−3
src/git/local.ts +10 −2
| @@ -441,10 +441,16 @@ export async function unstageAll(dir: string): Promise<LocalResult<void>> { | ||
| 441 | 441 | export async function discardPath( |
| 442 | 442 | dir: string, |
| 443 | 443 | path: string, |
| 444 | opts: { untracked?: boolean } = {}, | |
| 444 | opts: { untracked?: boolean; unstagedOnly?: boolean } = {}, | |
| 445 | 445 | ): Promise<LocalResult<void>> { |
| 446 | 446 | if (opts.untracked === true) |
| 447 | 447 | return done(await runLocal(["clean", "-fd", "--", path], { cwd: dir })); |
| 448 | // The unstaged half only: the working file goes back to the index, and | |
| 449 | // whatever is staged stays staged. | |
| 450 | if (opts.unstagedOnly === true) | |
| 451 | return done( | |
| 452 | await runLocal(["restore", "--worktree", "--", path], { cwd: dir }), | |
| 453 | ); | |
| 448 | 454 | const out = await runLocal( |
| 449 | 455 | ["restore", "--staged", "--worktree", "--source=HEAD", "--", path], |
| 450 | 456 | { cwd: dir }, |
| @@ -864,7 +870,9 @@ export async function isAncestor( | ||
| 864 | 870 | /** Is `oid` a commit object in the repository? One `git cat-file -e`. */ |
| 865 | 871 | export async function hasCommit(dir: string, oid: string): Promise<boolean> { |
| 866 | 872 | 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 | }); | |
| 868 | 876 | return out.ok; |
| 869 | 877 | } |
| 870 | 878 |
src/tui/local/work.ts +3
| @@ -261,6 +261,9 @@ export function workScreen(local: Local, ctx: Ctx, repo: LocalRepo): Screen { | ||
| 261 | 261 | () => |
| 262 | 262 | git.discardPath(repo.dir, entry.path, { |
| 263 | 263 | ...(untracked ? { untracked: true } : {}), |
| 264 | ...(item.section === "unstaged" | |
| 265 | ? { unstagedOnly: true } | |
| 266 | : {}), | |
| 264 | 267 | }), |
| 265 | 268 | reload, |
| 266 | 269 | ); |
test/git-local.test.ts +24 −1
| @@ -320,7 +320,12 @@ describe("commits, branches and the log", () => { | ||
| 320 | 320 | // the root closes again — a lane left open past the commit it was waiting |
| 321 | 321 | // for would draw a line of history that does not exist. |
| 322 | 322 | 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 | ]); | |
| 324 | 329 | }); |
| 325 | 330 | |
| 326 | 331 | test("the log filtered to one path only mentions that path", async () => { |
| @@ -544,6 +549,24 @@ describe("discarding", () => { | ||
| 544 | 549 | expect(await Bun.file(join(dir, "junk.txt")).exists()).toBe(false); |
| 545 | 550 | }); |
| 546 | 551 | |
| 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 | ||
| 547 | 570 | test("unstaging works before there is a HEAD to restore from", async () => { |
| 548 | 571 | const dir = await newRepo(root, "first-file"); |
| 549 | 572 | 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 | }); |
new above
offline
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.