Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 82 additions & 1 deletion .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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
18 changes: 15 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,9 +208,10 @@ rt git push # push to origin/<branch>, 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/<branch>
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
```
Expand All @@ -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
Expand Down
100 changes: 100 additions & 0 deletions e2e/glitter-repo.ts
Original file line number Diff line number Diff line change
@@ -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 });',
);
}
11 changes: 9 additions & 2 deletions e2e/interactive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ export interface TermwrightSession {
press(key: string): Promise<void>;
type(text: string): Promise<void>;
ctrl(char: string): Promise<void>;
/** Raw bytes to the pty, for key encodings `press`/`ctrl` cannot express (CSI-u chords, mouse reports). */
raw(bytes: number[]): Promise<void>;
waitForText(text: string, timeoutMs?: number): Promise<void>;
waitForIdle(idleMs?: number, timeoutMs?: number): Promise<void>;
stop(): Promise<void>;
Expand Down Expand Up @@ -217,6 +219,10 @@ export async function startInteractive(

let stopped = false;

async function sendRaw(bytes: number[]): Promise<void> {
await twSend(socketPath, "raw", { bytes_base64: Buffer.from(bytes).toString("base64") });
}

return {
async screen(): Promise<string> {
const result = await twSend(socketPath, "screen", { format: "text" });
Expand All @@ -234,13 +240,14 @@ export async function startInteractive(
async ctrl(char: string): Promise<void> {
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<void> {
// 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
Expand Down
Loading
Loading