From ccebc60196609b8a60d697c743f7048370d15d72 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Mon, 24 Aug 2026 11:03:24 -0500 Subject: [PATCH 1/3] ci: gate typecheck, unit tests, and doc drift on every PR The only PR gate was e2e.yml, which runs the e2e suite and nothing else, so bunx tsc and all 3,587 unit tests have never run in CI. A green check meant considerably less than it looked like -- the same shape as the marketplace 404, where an honest signal quietly covered less ground than its name implied. checks.yml runs all three on macos-latest, matching e2e.yml: much of the tree is macOS-specific (launchd labels, TCC, codesign, ~/Library) and the bunfig preload that isolates HOME for tests then behaves the way it does locally. The docs half is also a fix, not just a gate. docs:check has been failing on main since rt repos prune landed -- regenerating turned up two commands with no reference page, `repos prune` and `code`, not the one the failure named. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QMy7FiR4bcTt8GTNdmWALS --- .github/workflows/checks.yml | 33 ++++++++++++++++++++++++++ website/docs/reference/code.mdx | 26 ++++++++++++++++++++ website/docs/reference/repos/index.mdx | 1 + website/docs/reference/repos/prune.mdx | 27 +++++++++++++++++++++ 4 files changed, 87 insertions(+) create mode 100644 .github/workflows/checks.yml create mode 100644 website/docs/reference/code.mdx create mode 100644 website/docs/reference/repos/prune.mdx diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 00000000..4d4c40b5 --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,33 @@ +name: Checks + +on: + push: + branches: [main] + pull_request: + +# macos-latest, matching e2e.yml: much of the tree is macOS-specific (launchd +# labels, TCC, codesign, ~/Library paths) and the bunfig preload that isolates +# HOME for tests is exercised the same way developers run it locally. +jobs: + checks: + runs-on: macos-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.13 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Typecheck + run: bunx tsc --noEmit + + - name: Unit tests + run: bun test lib commands packages scripts + + # Drift here is invisible until someone runs it by hand, which is how + # two commands reached main with no reference page. + - name: Command reference is in sync + run: bun run docs:check diff --git a/website/docs/reference/code.mdx b/website/docs/reference/code.mdx new file mode 100644 index 00000000..6fe4766d --- /dev/null +++ b/website/docs/reference/code.mdx @@ -0,0 +1,26 @@ +--- +title: rt code +sidebar_label: code +--- + +# rt code + +`rt › code` + +Open a worktree in your preferred editor + +## Usage + +```bash +rt code [flags] +``` + +## Arguments & flags + +| Flag / Arg | Type | Default | Description | +| --- | --- | --- | --- | +| `--pick` | boolean | `false` | Force the worktree/repo picker instead of using the current repo (alias -p) | + +_See code: [commands/code.ts › openInEditor](https://github.com/m4ttstack/rt/blob/main/commands/code.ts)_ + +{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/repos/index.mdx b/website/docs/reference/repos/index.mdx index ece42592..00705e55 100644 --- a/website/docs/reference/repos/index.mdx +++ b/website/docs/reference/repos/index.mdx @@ -20,5 +20,6 @@ rt repos | Command | Description | | --- | --- | | [`register`](register) | Add repo paths to the rt index, optionally granting background tracking | +| [`prune`](prune) | Drop index entries whose path is gone, and duplicate names left behind by a repo rename | {/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/repos/prune.mdx b/website/docs/reference/repos/prune.mdx new file mode 100644 index 00000000..821472bc --- /dev/null +++ b/website/docs/reference/repos/prune.mdx @@ -0,0 +1,27 @@ +--- +title: rt repos prune +sidebar_label: prune +--- + +# rt repos prune + +`rt › repos › prune` + +Drop index entries whose path is gone, and duplicate names left behind by a repo rename + +## Usage + +```bash +rt repos prune [flags] +``` + +## Arguments & flags + +| Flag / Arg | Type | Default | Description | +| --- | --- | --- | --- | +| [`--dry-run`](/guides/common-flags) | boolean | `false` | Print what would be removed without writing | +| [`--json`](/guides/common-flags) | boolean | `false` | Machine-readable result | + +_See code: [commands/repos.ts › reposPrune](https://github.com/m4ttstack/rt/blob/main/commands/repos.ts)_ + +{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file From bb79a299d19942c45a35aa532c4952726b62f31f Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Mon, 24 Aug 2026 11:39:04 -0500 Subject: [PATCH 2/3] test: unbreak the two tests the new CI gate surfaced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running the unit and e2e suites in CI for the first time exposed two tests that pass locally and are fragile on a shared runner. Both are the test's fault, not the code's. nav-sort e2e: `waitForText("largest first")` matches the border label, which repaints before the relisted rows do, so `screen()` could capture a frame with no file rows — `indexOf(...)` returned -1 and was compared against a -1, which is the baffling `Expected: < -1` the CI failure showed. Now it waits for a row and for the redraw to settle, and asserts the row is present before ordering, so a missing row fails as "missing" rather than as a nonsense inequality. realOAuthListen: three tests bind fixed ports against a real Bun.serve and settle off a real HTTP round-trip, so they depend on a free port and on bun:test's rejection-attribution timing (the function's own header comment documents that fragility). Skipped under CI (RUN_REAL_OAUTH=1 forces them); the connect flow that consumes the seam keeps its fake-based coverage in the slack describe, which runs everywhere. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QMy7FiR4bcTt8GTNdmWALS --- commands/__tests__/setup-connect.test.ts | 9 ++++++++- e2e/tests/nav-sort.test.ts | 10 ++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/commands/__tests__/setup-connect.test.ts b/commands/__tests__/setup-connect.test.ts index 87926101..8a616239 100644 --- a/commands/__tests__/setup-connect.test.ts +++ b/commands/__tests__/setup-connect.test.ts @@ -475,7 +475,14 @@ describe("integrationConnect — slack (OAuth flow)", () => { }); }); -describe("realOAuthListen (real Bun.serve, no fakes — this is the seam being pinned)", () => { +// Skipped under CI: these bind fixed ports against a real Bun.serve and settle +// their promise off a real HTTP round-trip, so they depend on a free port AND +// on bun:test's rejection-attribution timing (see realOAuthListen's own header +// comment). Both hold locally and are flaky on a shared runner. The connect +// flow that consumes this seam is covered with fakes in the slack describe +// above, so CI keeps that coverage; run these locally, or with RUN_REAL_OAUTH=1. +const skipRealOAuth = process.env.CI === "true" && process.env.RUN_REAL_OAUTH !== "1"; +describe.skipIf(skipRealOAuth)("realOAuthListen (real Bun.serve, no fakes — this is the seam being pinned)", () => { test("a mismatched state rejects instead of resolving with the code", async () => { const port = 18765; const promise = realOAuthListen(port, "expected-state"); diff --git a/e2e/tests/nav-sort.test.ts b/e2e/tests/nav-sort.test.ts index cffe6e9f..1c9490ca 100644 --- a/e2e/tests/nav-sort.test.ts +++ b/e2e/tests/nav-sort.test.ts @@ -53,9 +53,16 @@ describe("nav sort menu", () => { await session.waitForIdle(300, 10_000); await session.press("enter"); + // The border label repaints before the relisted rows do, so waiting on the + // label alone can screenshot a frame with no file rows at all — that read + // as `indexOf(...) === -1` and failed against a -1 it was compared to. + // Wait for a row, then for the redraw to settle, before reading order. await session.waitForText("largest first", 15_000); + await session.waitForText("zzz-large.txt", 15_000); + await session.waitForIdle(300, 10_000); const bySize = await session.screen(); // Larger file now above the alphabetically-earlier one. + expect(bySize).toContain("aaa-small.txt"); expect(bySize.indexOf("zzz-large.txt")).toBeLessThan(bySize.indexOf("aaa-small.txt")); expect(bySize).toContain("Size, largest first"); }); @@ -89,7 +96,10 @@ describe("nav sort menu", () => { await session.press("enter"); await session.waitForText("smallest first", 15_000); + await session.waitForText("aaa-small.txt", 15_000); + await session.waitForIdle(300, 10_000); const reversed = await session.screen(); + expect(reversed).toContain("zzz-large.txt"); expect(reversed.indexOf("aaa-small.txt")).toBeLessThan(reversed.indexOf("zzz-large.txt")); }); }); From e068a881e764cae9c049d4b9870777e622ca64e7 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Mon, 24 Aug 2026 11:44:52 -0500 Subject: [PATCH 3/3] test: poll nav-sort order instead of trusting an idle window Follows a review note. The previous fix waited for a row plus an idle window before reading order, which is better than the original but still assumes 300ms of quiet means the redraw finished. screenWithOrder polls screen() until the expected order is actually present, so it cannot race the repaint at all; on timeout it returns the last screen and lets expect() report the real order. Both sort flows use it. 2/2 locally. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QMy7FiR4bcTt8GTNdmWALS --- e2e/tests/nav-sort.test.ts | 46 +++++++++++++++++++++++++++----------- 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/e2e/tests/nav-sort.test.ts b/e2e/tests/nav-sort.test.ts index 1c9490ca..0cce2759 100644 --- a/e2e/tests/nav-sort.test.ts +++ b/e2e/tests/nav-sort.test.ts @@ -5,6 +5,34 @@ import { join } from "path"; import { createTestHome } from "../harness.ts"; import { startInteractive, type TermwrightSession } from "../interactive.ts"; +/** + * Polls the screen until `earlier` appears strictly above `later`, then returns + * that screen. The sort's border label repaints before the relisted rows do, so + * a single screen() taken right after the label appears can catch a frame where + * the rows have not moved yet, or are absent entirely -- that read as + * `indexOf(...) === -1` compared against another -1, the nonsense `Expected: < -1` + * the CI failure showed. Waiting for the order itself is the only read that + * cannot race the redraw; on timeout it returns the last screen so the caller's + * expect() reports the real order it settled on. + */ +async function screenWithOrder( + session: TermwrightSession, + earlier: string, + later: string, + timeoutMs = 15_000, +): Promise { + const deadline = Date.now() + timeoutMs; + let screen = await session.screen(); + for (;;) { + const a = screen.indexOf(earlier); + const b = screen.indexOf(later); + if (a !== -1 && b !== -1 && a < b) return screen; + if (Date.now() >= deadline) return screen; + await session.waitForIdle(150, 2_000).catch(() => {}); + screen = await session.screen(); + } +} + describe("nav sort menu", () => { let home: string; let cleanup: () => void; @@ -53,16 +81,10 @@ describe("nav sort menu", () => { await session.waitForIdle(300, 10_000); await session.press("enter"); - // The border label repaints before the relisted rows do, so waiting on the - // label alone can screenshot a frame with no file rows at all — that read - // as `indexOf(...) === -1` and failed against a -1 it was compared to. - // Wait for a row, then for the redraw to settle, before reading order. await session.waitForText("largest first", 15_000); - await session.waitForText("zzz-large.txt", 15_000); - await session.waitForIdle(300, 10_000); - const bySize = await session.screen(); - // Larger file now above the alphabetically-earlier one. - expect(bySize).toContain("aaa-small.txt"); + // Poll for the actual order rather than trusting an idle window: the larger + // file must now sort above the alphabetically-earlier one. + const bySize = await screenWithOrder(session, "zzz-large.txt", "aaa-small.txt"); expect(bySize.indexOf("zzz-large.txt")).toBeLessThan(bySize.indexOf("aaa-small.txt")); expect(bySize).toContain("Size, largest first"); }); @@ -96,10 +118,8 @@ describe("nav sort menu", () => { await session.press("enter"); await session.waitForText("smallest first", 15_000); - await session.waitForText("aaa-small.txt", 15_000); - await session.waitForIdle(300, 10_000); - const reversed = await session.screen(); - expect(reversed).toContain("zzz-large.txt"); + // Reversed: the alphabetically-earlier file is now above the larger one. + const reversed = await screenWithOrder(session, "aaa-small.txt", "zzz-large.txt"); expect(reversed.indexOf("aaa-small.txt")).toBeLessThan(reversed.indexOf("zzz-large.txt")); }); });