diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 78cc966bc..2a54d08f9 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -9,6 +9,33 @@ env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true jobs: + # Which of this PR's files can affect the glitter board. Cheap ubuntu job so + # the expensive macos one below can be skipped outright. + changes: + runs-on: ubuntu-latest + outputs: + glitter: ${{ steps.filter.outputs.glitter }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - id: filter + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + # A push to main always runs it: main is what releases cut from, and + # a path filter that is wrong should fail there rather than ship. + if [ "${{ github.event_name }}" != "pull_request" ]; then + echo "glitter=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + if git diff --name-only "$BASE_SHA" "$HEAD_SHA" | grep -qE '^(ui/|lib/mission/|lib/ui/|commands/glitter\.ts|packages/git-core/|e2e/(pty/|glitter-repo\.ts|interactive\.ts|harness\.ts|setup\.ts)|\.github/workflows/e2e\.yml|package\.json)'; then + echo "glitter=true" >> "$GITHUB_OUTPUT" + else + echo "glitter=false" >> "$GITHUB_OUTPUT" + fi + e2e: runs-on: macos-latest timeout-minutes: 10 @@ -45,7 +72,7 @@ jobs: codesign --force --sign - --entitlements scripts/entitlements.plist dist/rt - name: Run e2e tests - run: bun test --preload ./e2e/setup.ts --timeout 60000 e2e/ + run: bun run test:e2e env: RT_BINARY: ${{ github.workspace }}/dist/rt @@ -56,3 +83,57 @@ jobs: name: e2e-debug path: e2e/artifacts/ retention-days: 7 + + # The glitter pty gate drives the real rt-ui, so it needs Go and a helper + # build the other e2e tests never touch. It is its own job so that cost + # lands only on PRs that can actually break the board -- and so a run that + # does not apply shows up as a skipped check rather than a silent pass. + # + # The trigger is every input the board is built from, NOT just ui/: the + # driver, the command, and git-core are TypeScript, and the two bugs this + # gate was written after (RT-221) were both TypeScript with no Go diff. + glitter-pty: + runs-on: macos-latest + timeout-minutes: 15 + needs: changes + if: needs.changes.outputs.glitter == 'true' + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + + - uses: actions/setup-go@v5 + with: + go-version-file: ui/go.mod + cache-dependency-path: ui/go.sum + + - name: Install dependencies + run: bun install + + - name: Cache Cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin + ~/.cargo/registry/index + ~/.cargo/registry/cache + key: cargo-termwright-${{ runner.os }} + restore-keys: cargo-termwright- + + - name: Install Termwright + run: | + if ! command -v termwright &>/dev/null; then + cargo install termwright + fi + + - name: Build rt binary + run: bun build --compile ./cli.ts --outfile dist/rt --define 'RT_VERSION="e2e-test"' + + - name: Codesign binary (ad-hoc with JIT entitlements) + run: | + codesign --remove-signature dist/rt + codesign --force --sign - --entitlements scripts/entitlements.plist dist/rt + + - name: Run the glitter pty gate + run: bun run test:pty + env: + RT_BINARY: ${{ github.workspace }}/dist/rt diff --git a/README.md b/README.md index 0d45afba1..1087e058b 100644 --- a/README.md +++ b/README.md @@ -208,9 +208,10 @@ rt git push # push to origin/, fixing a wrong upstream rt git push force # push with --force-with-lease, after a rebase or amend rt git upstream # fix the branch upstream to track origin/ rt git reset origin # sync with origin after a remote rebase -rt git reset soft # soft reset to HEAD (unstage files) -rt git reset hard # hard reset to HEAD (discard all changes) -rt git commit # interactive staging + commit with live diff preview +rt git reset soft # unstage everything (resets the index to HEAD, keeps your edits) +rt git reset hard # discard every working-tree change (resets to HEAD) +rt git undo # undo the last commit, keeping its changes in the working tree +rt git commit # interactive staging + commit (file list with +/- stats) rt git backup # back up the current branch to a backup ref rt git restore # restore from a backup branch ``` @@ -220,6 +221,17 @@ rt sync # rebase the current worktree onto master, then push rt sync all # sync every worktree in the repo ``` +### Mission control + +```bash +rt glitter # full-screen board: changes, diffs, history, branches, commit +``` + +A GitHub Desktop-style board in the terminal, with a real diff pane and mouse +support. Checkboxes are commit intent, not index state: the index is rebuilt +from your selections at commit time, so anything staged outside the board is +replaced by what the checkboxes say. Needs an interactive terminal. + ### Running things ```bash diff --git a/e2e/glitter-repo.ts b/e2e/glitter-repo.ts new file mode 100644 index 000000000..19c72d190 --- /dev/null +++ b/e2e/glitter-repo.ts @@ -0,0 +1,100 @@ +/** + * A sandbox git repo for the glitter pty gate: every working-tree shape the + * board renders differently (staged, unstaged, multi-hunk, untracked, + * deleted), built by argv-only git so no developer config leaks in. + */ +import { execFileSync } from "child_process"; +import { mkdtempSync, writeFileSync, rmSync, unlinkSync, mkdirSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; + +export interface GlitterRepo { + path: string; + git(...args: string[]): string; + /** Subject lines of the last `n` commits, newest first. */ + log(n?: number): string[]; + /** Paths git reports as staged (index differs from HEAD). */ + staged(): string[]; + cleanup(): void; +} + +// A commit needs an identity and the sandbox HOME has none, so every +// invocation carries one. -c keeps it out of any config file. +const IDENT = [ + "-c", "user.name=rt-test", + "-c", "user.email=test@rt.test", + "-c", "commit.gpgsign=false", + "-c", "init.defaultBranch=main", +]; + +export function createGlitterRepo(): GlitterRepo { + const path = mkdtempSync(join(tmpdir(), "rt-glitter-")); + + const git = (...args: string[]): string => + execFileSync("git", [...IDENT, ...args], { + cwd: path, + encoding: "utf8", + stdio: "pipe", + // A developer's own git env would otherwise reach into the sandbox. + env: { PATH: process.env.PATH ?? "", HOME: path, GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_SYSTEM: "/dev/null" }, + }).trim(); + + git("init", "-q"); + + mkdirSync(join(path, "src"), { recursive: true }); + writeFileSync(join(path, "config.json"), '{\n "name": "sandbox",\n "maxTokens": 2048\n}\n'); + writeFileSync(join(path, "src", "parser.ts"), parserV1()); + writeFileSync(join(path, "src", "legacy.ts"), "export const legacy = true;\n"); + git("add", "-A"); + git("commit", "-q", "-m", "seed the sandbox"); + + // Staged: the index differs from HEAD before glitter ever opens. + writeFileSync(join(path, "config.json"), '{\n "name": "sandbox",\n "maxTokens": 4096\n}\n'); + git("add", "config.json"); + + // Unstaged, and far enough apart to land in separate hunks. + writeFileSync(join(path, "src", "parser.ts"), parserV2()); + + // Deleted and untracked. + unlinkSync(join(path, "src", "legacy.ts")); + writeFileSync(join(path, "NOTES.md"), "# Notes\n\nScratch.\n"); + + return { + path, + git, + log: (n = 10) => git("log", `-${n}`, "--format=%s").split("\n").filter(Boolean), + staged: () => git("diff", "--cached", "--name-only").split("\n").filter(Boolean), + cleanup: () => rmSync(path, { recursive: true, force: true }), + }; +} + +function parserV1(): string { + return [ + "export interface Token {", + ' kind: "word" | "symbol";', + " value: string;", + "}", + "", + "export function tokenize(input: string): Token[] {", + " const tokens: Token[] = [];", + " let i = 0;", + " while (i < input.length) {", + " const ch = input[i]!;", + ' tokens.push({ kind: "symbol", value: ch });', + " i += 1;", + " }", + " return tokens;", + "}", + "", + ].join("\n"); +} + +function parserV2(): string { + return parserV1() + .replace(' kind: "word" | "symbol";', ' kind: "word" | "symbol" | "string";') + .replace(" let i = 0;", " let i = 0;\n let depth = 0;") + .replace( + ' tokens.push({ kind: "symbol", value: ch });', + ' if (ch === "(") depth += 1;\n tokens.push({ kind: "symbol", value: ch });', + ); +} diff --git a/e2e/interactive.ts b/e2e/interactive.ts index 48d460a1a..5945ad3d0 100644 --- a/e2e/interactive.ts +++ b/e2e/interactive.ts @@ -35,6 +35,8 @@ export interface TermwrightSession { press(key: string): Promise; type(text: string): Promise; ctrl(char: string): Promise; + /** Raw bytes to the pty, for key encodings `press`/`ctrl` cannot express (CSI-u chords, mouse reports). */ + raw(bytes: number[]): Promise; waitForText(text: string, timeoutMs?: number): Promise; waitForIdle(idleMs?: number, timeoutMs?: number): Promise; stop(): Promise; @@ -217,6 +219,10 @@ export async function startInteractive( let stopped = false; + async function sendRaw(bytes: number[]): Promise { + await twSend(socketPath, "raw", { bytes_base64: Buffer.from(bytes).toString("base64") }); + } + return { async screen(): Promise { const result = await twSend(socketPath, "screen", { format: "text" }); @@ -234,13 +240,14 @@ export async function startInteractive( async ctrl(char: string): Promise { const seq = CTRL_ARROW_SEQUENCES[char.toLowerCase()]; if (seq) { - const b64 = Buffer.from(seq).toString("base64"); - await twSend(socketPath, "raw", { bytes_base64: b64 }); + await sendRaw(seq); } else { await twSend(socketPath, "hotkey", { ctrl: true, ch: char }); } }, + raw: sendRaw, + async waitForText(text: string, timeoutMs = 5000): Promise { // The transport socket timeout must outlast the logical wait, or a wait // longer than the default 15s dies at the socket layer before the text diff --git a/e2e/pty/glitter.test.ts b/e2e/pty/glitter.test.ts new file mode 100644 index 000000000..2f4b0f6c9 --- /dev/null +++ b/e2e/pty/glitter.test.ts @@ -0,0 +1,137 @@ +/** + * The whole-binary gate for `rt glitter`: the real compiled rt spawning the + * real rt-ui over a pty, keys in, git state out. Nothing is faked on either + * side, so a break anywhere in the chain (intent emission, payload shape, + * driver handling, the commit-time index rebuild) fails here. + * + * Every assertion reads git, never the rendered screen. Screen text appears + * only in waits, where it is the one signal that a round trip finished -- + * asserting on it would make this a second, worse copy of the Go render + * tests. Each wait keys on a state transition the board only paints once the + * driver has come back from git, never on a fixed sleep. + */ +import { describe, test, expect, beforeAll, afterEach } from "bun:test"; +import { execFileSync } from "child_process"; +import { existsSync } from "fs"; +import { join } from "path"; +import { createTestHome } from "../harness.ts"; +import { startInteractive, type TermwrightSession } from "../interactive.ts"; +import { createGlitterRepo, type GlitterRepo } from "../glitter-repo.ts"; + +const REPO_ROOT = import.meta.dir.replace("/e2e/pty", ""); +const RT_UI_BIN = join(REPO_ROOT, "ui", "dist", "rt-ui"); +const PAINT_TIMEOUT = 15_000; + +beforeAll(() => { + // The board is half of the binary under test; a run against a stale or + // missing helper would gate nothing. + execFileSync("bun", ["run", "ui:build"], { cwd: REPO_ROOT, stdio: "pipe" }); + if (!existsSync(RT_UI_BIN)) throw new Error(`ui:build produced no binary at ${RT_UI_BIN}`); +}); + +let open: { session: TermwrightSession; repo: GlitterRepo; cleanupHome: () => void } | null = null; + +afterEach(async () => { + if (!open) return; + await open.session.stop(); + open.repo.cleanup(); + open.cleanupHome(); + open = null; +}); + +async function openBoard(): Promise<{ session: TermwrightSession; repo: GlitterRepo }> { + const repo = createGlitterRepo(); + const home = createTestHome(); + const session = await startInteractive({ + args: ["glitter"], + home: home.path, + cwd: repo.path, + cols: 130, + rows: 38, + env: { RT_UI_BIN }, + }); + open = { session, repo, cleanupHome: home.cleanup }; + // The sandbox has four changes and every one starts checked, so this text + // is also the assertion that the driver reached git and pushed a model. + await session.waitForText("Commit 4 files", PAINT_TIMEOUT); + return { session, repo }; +} + +/** + * Types a summary and commits with ctrl+enter, then waits for the board to + * repaint with nothing left to commit. Raw bytes rather than a hotkey helper + * because the board negotiates the Kitty keyboard protocol, which encodes + * ctrl+enter as a CSI-u sequence that a plain ctrl+ch cannot express. + */ +async function commit(session: TermwrightSession, summary: string, expectRemaining: number): Promise { + await session.press("c"); + await session.type(summary); + await session.raw([0x1b, 0x5b, 0x31, 0x33, 0x3b, 0x35, 0x75]); // CSI 13;5u + await session.waitForText(`Commit ${expectRemaining} files`, PAINT_TIMEOUT); +} + +/** Commit leaves focus in the summary field, where a letter is text, not a keybinding. */ +async function returnToList(session: TermwrightSession): Promise { + await session.press("Escape"); + await session.waitForIdle(300, PAINT_TIMEOUT); +} + +describe("rt glitter through a pty", () => { + test("opens on the working tree the sandbox actually has", async () => { + const { repo } = await openBoard(); + expect(repo.staged()).toEqual(["config.json"]); + expect(repo.log(1)).toEqual(["seed the sandbox"]); + }); + + test("space unchecks the cursor's file, and the commit then omits it", async () => { + const { session, repo } = await openBoard(); + + // The cursor opens on the first row, config.json (the list sorts + // case-insensitively by path). termwright has no "space" key name. + await session.raw([0x20]); + await session.waitForText("Commit 3 files", PAINT_TIMEOUT); + + await commit(session, "pty gate commit", 0); + + expect(repo.log(1)).toEqual(["pty gate commit"]); + + // The unchecked file stayed out of the commit AND stayed modified in the + // working tree: the index is rebuilt from the checkboxes, so an + // unchecked file is left alone, not reverted. + const committed = repo.git("show", "--name-only", "--format=", "HEAD").split("\n").filter(Boolean); + expect(committed.sort()).toEqual(["NOTES.md", "src/legacy.ts", "src/parser.ts"]); + expect(repo.git("status", "--porcelain", "config.json")).toContain("config.json"); + }); + + test("a commit-time index rebuild discards staging done outside the board", async () => { + const { session, repo } = await openBoard(); + + // Stage a path the board has unchecked. GHD's model says the checkbox + // wins: this must not reach the commit. + await session.raw([0x20]); // uncheck config.json + await session.waitForText("Commit 3 files", PAINT_TIMEOUT); + repo.git("add", "config.json"); + expect(repo.staged()).toContain("config.json"); + + await commit(session, "checkbox beats the index", 0); + + const committed = repo.git("show", "--name-only", "--format=", "HEAD").split("\n").filter(Boolean); + expect(committed).not.toContain("config.json"); + }); + + test("u undoes the last commit and leaves its changes in the tree", async () => { + const { session, repo } = await openBoard(); + + await commit(session, "commit to undo", 0); + expect(repo.log(1)).toEqual(["commit to undo"]); + + await returnToList(session); + await session.press("u"); + await session.waitForText("Commit 4 files", PAINT_TIMEOUT); + + expect(repo.log(1)).toEqual(["seed the sandbox"]); + const status = repo.git("status", "--porcelain"); + expect(status).toContain("config.json"); + expect(status).toContain("src/parser.ts"); + }); +}); diff --git a/lib/command-tree-def.ts b/lib/command-tree-def.ts index 72ae9f68d..6a53575e0 100644 --- a/lib/command-tree-def.ts +++ b/lib/command-tree-def.ts @@ -696,14 +696,14 @@ export const TREE: Record = { args: [], }, soft: { - description: "Soft reset to HEAD (unstage files)", + description: "Unstage everything, keeping your edits (to undo a commit, use rt git undo)", module: "./commands/git/reset.ts", fn: "softResetCommand", context: "worktree", args: [], }, hard: { - description: "Hard reset to HEAD (discard all changes)", + description: "Discard every working-tree change (to undo a commit, use rt git undo)", module: "./commands/git/reset.ts", fn: "hardResetCommand", context: "worktree", diff --git a/package.json b/package.json index 2cd3adf8a..ad0c15f97 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,9 @@ "scripts": { "test": "bun test lib commands packages scripts rt-tray/Tests/stub-rt", "test:watch": "bun test --watch lib commands packages scripts rt-tray/Tests/stub-rt", - "test:e2e": "bun test --preload ./e2e/setup.ts --timeout 60000 e2e/", - "test:all": "bun test lib commands packages scripts rt-tray/Tests/stub-rt && bun test --preload ./e2e/setup.ts --timeout 60000 e2e/", + "test:e2e": "bun test --preload ./e2e/setup.ts --timeout 60000 e2e/tests/", + "test:pty": "bun test --preload ./e2e/setup.ts --timeout 120000 e2e/pty/", + "test:all": "bun test lib commands packages scripts rt-tray/Tests/stub-rt && bun test --preload ./e2e/setup.ts --timeout 60000 e2e/tests/ && bun test --preload ./e2e/setup.ts --timeout 120000 e2e/pty/", "docs:gen": "bun scripts/gen-docs.ts", "docs:check": "bun scripts/check-docs.ts", "picker:check": "bun scripts/lib/picker-conformance.ts", diff --git a/ui/internal/testutil/ptyrun.go b/ui/internal/testutil/ptyrun.go index 2e7401807..a37acd647 100644 --- a/ui/internal/testutil/ptyrun.go +++ b/ui/internal/testutil/ptyrun.go @@ -83,10 +83,20 @@ func runPTY(t *testing.T, argv []string, stdinLines []string, keys []string, env } } }() + // A non-empty buffer is not a painted screen: terminal setup (alt screen, + // cursor hide, the Kitty keyboard push) reaches the pty before any cell is + // drawn. Replaying the buffer is the only way to tell the two apart, and + // conflating them is exactly how these tests flaked -- keys landed on a + // program that had not drawn yet, it quit, and the assertion then ran + // against a blank screen. painted := func() bool { mu.Lock() - defer mu.Unlock() - return ttyBuf.Len() > 0 + raw := ttyBuf.String() + mu.Unlock() + if raw == "" { + return false + } + return strings.TrimSpace(Screen(raw)) != "" } for _, l := range stdinLines { @@ -113,7 +123,7 @@ func runPTY(t *testing.T, argv []string, stdinLines []string, keys []string, env return false } } - deadline := time.Now().Add(3 * time.Second) + deadline := time.Now().Add(10 * time.Second) for time.Now().Before(deadline) && !painted() && !isExited() { time.Sleep(10 * time.Millisecond) } @@ -121,7 +131,7 @@ func runPTY(t *testing.T, argv []string, stdinLines []string, keys []string, env _ = stdinW.Close() _ = cmd.Process.Kill() <-exited - t.Fatal("rt-ui neither painted nor exited within 3s") + t.Fatal("rt-ui drew no cell and did not exit within 10s") } if closeStdin { stdinW.Close() diff --git a/website/docs/reference/git/reset/hard.mdx b/website/docs/reference/git/reset/hard.mdx index b87766676..7fe84b740 100644 --- a/website/docs/reference/git/reset/hard.mdx +++ b/website/docs/reference/git/reset/hard.mdx @@ -7,7 +7,7 @@ sidebar_label: hard `rt › git › reset › hard` -Hard reset to HEAD (discard all changes) +Discard every working-tree change (to undo a commit, use rt git undo) ## Usage diff --git a/website/docs/reference/git/reset/index.mdx b/website/docs/reference/git/reset/index.mdx index 8b12d2512..31af98eb1 100644 --- a/website/docs/reference/git/reset/index.mdx +++ b/website/docs/reference/git/reset/index.mdx @@ -20,7 +20,7 @@ rt git reset | Command | Description | | --- | --- | | [`origin`](origin) | Sync with origin/current-branch (after remote rebase) | -| [`soft`](soft) | Soft reset to HEAD (unstage files) | -| [`hard`](hard) | Hard reset to HEAD (discard all changes) | +| [`soft`](soft) | Unstage everything, keeping your edits (to undo a commit, use rt git undo) | +| [`hard`](hard) | Discard every working-tree change (to undo a commit, use rt git undo) | {/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/git/reset/soft.mdx b/website/docs/reference/git/reset/soft.mdx index 660252552..017299c85 100644 --- a/website/docs/reference/git/reset/soft.mdx +++ b/website/docs/reference/git/reset/soft.mdx @@ -7,7 +7,7 @@ sidebar_label: soft `rt › git › reset › soft` -Soft reset to HEAD (unstage files) +Unstage everything, keeping your edits (to undo a commit, use rt git undo) ## Usage