diff --git a/CHANGELOG.md b/CHANGELOG.md index b530b30..766edf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.0] + +### Changed + +- **BREAKING: Ctrl-C now stops the server you started, like `npm run dev`.** This + reverses 0.4.0's "Ctrl-C always detaches." The behaviour is *hybrid*, keyed on whether + your terminal started the server: + - Bare `perchd` / `perchd ` / `perchd switch` **start** the server, so Ctrl-C + stops it — and so does closing the terminal window (SIGHUP). No orphans. + - `perchd attach` **attaches** to a server that's meant to persist, so Ctrl-C there only + detaches and leaves it running. + The attach banner states which mode you're in (`^C stops` vs `^C detaches`). + `perchd stop` still terminates the active server from anywhere. +- Internally, `runViewport` now reports `{ interrupted, signal }` and never signals the + server itself; the stop-vs-detach policy lives in `attachViewport` (graceful `stopGroup` + on SIGINT, best-effort group `SIGTERM` on SIGHUP since the terminal is already gone). + +- **Bare `perchd` shows the worktree picker again, with the current worktree + pre-selected.** In 0.4.0, bare `perchd` inside a worktree silently ran *that* worktree + and never showed the menu — which hid perchd's whole "switch without `cd`-ing" point. + Now it always shows the picker with cwd's worktree floated to the top and pre-selected: + press Enter to run where you stand (the drop-in), or arrow to another worktree to + switch. Naming a target (`perchd `) still skips the menu. A non-interactive + bare `perchd` (piped / CI) now fails with a clear "name a target" message instead of a + raw TTY crash. + +### Added + +- **`perchd -v` / `perchd --version`** — previously threw `Unknown option`. Prints the + installed version (read from `package.json`, so it's correct under both the published + binary and `pnpm dev`). + ## [0.4.0] ### Changed diff --git a/CLAUDE.md b/CLAUDE.md index 57cfeb8..dc8da7d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,24 +87,41 @@ Flow for a switch: `cli.ts` → `core/context.ts` (git worktrees + common dir) in `test/fixtures/` (`mini-server`, and `multiproc-server` which mimics vite→esbuild for process-group teardown). -## The viewport model (0.4.0) +## The viewport model (0.4.0), with hybrid Ctrl-C (0.5.0) **One server, detachable viewport** — tmux-for-dev-servers. Every server runs detached in the background and writes to a log file. "Foreground" is not a kind of server; it is a **viewport**: `tail -f` on that log plus a poller watching the state file (`core/viewport.ts`). -- Bare `perchd` is the `npm run dev` drop-in: switch to cwd's worktree, then attach. - Outside any worktree it shows the picker (`containingWorktree` decides). +- Bare `perchd` shows the worktree **picker with cwd's worktree pre-selected** + (`bareTarget` → `pick`, `pickerChoices` floats the pre-select to the front and clack + gets `initialValue`): Enter re-runs where you stand (the `npm run dev` drop-in), + arrowing to another is the switch — surfacing the other worktrees is the point (no + `cd`). Naming a target (`perchd `) skips the menu. The picker needs a TTY; a + non-interactive bare `perchd` fails with a clear "name a target" message, not a raw + `uv_tty_init` crash. - `-d` / `--detach` flips **any** switch to background. One uniform, explicit axis. -- **Ctrl-C detaches; it does not kill.** `perchd stop` is the only way to terminate. - Ending a viewport must never signal the server — that invariant *is* the product. +- **Hybrid Ctrl-C (`attachViewport`'s `stopOnInterrupt`):** a viewport that *started* + the server (bare `perchd` / `switch`) **stops** it on Ctrl-C, and on window-close + (SIGHUP) too — that is the `npm run dev` drop-in promise. A viewport that *attached* + to an already-running server (`perchd attach`) only **detaches** on Ctrl-C; killing + something you attached to peek at would be a footgun. **The banner must state which, + per mode** ("^C stops" vs "^C detaches") — that is what keeps the overload legible + rather than surprising. `perchd stop` always terminates regardless. +- **`runViewport` never signals the server itself.** It reports `{interrupted, signal}`; + the stop-vs-detach *policy* lives in `attachViewport` (graceful `stopGroup` on SIGINT; + best-effort synchronous `SIGTERM` to the group on SIGHUP, since the terminal is gone + and there is no time to wait on an escalating teardown). Keeping the signal-to-reason + mapping pure is what makes it unit-testable. - `cli.ts` is the **only** place attachment is decided. `runSwitch` never attaches; keeping it that way avoids a `switch ↔ attach` import cycle. - `perchd dev` is a deprecated alias for bare `perchd`. -A viewport ends for exactly four reasons — `detached`, `perch-moved` (another terminal -switched), `stopped`, `server-exited` (clears the record if the pid is still ours). +A viewport ends for exactly five reasons — `interrupted` (Ctrl-C/SIGHUP; the signal is +carried so the policy can pick stop vs. detach), `detached` (the tail process died on +its own), `perch-moved` (another terminal switched), `stopped`, `server-exited` (clears +the record if the pid is still ours). **The `graceMs` window is load-bearing, don't remove it.** `runSwitch` stops the old server and clears the state record *before* starting the new one, so for a few hundred diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3e10d25..a6e68c3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -60,13 +60,16 @@ processes. That keeps them trivially testable. ## The core model perchd keeps **one** dev server alive, always running in the background. A terminal -"in the foreground" is just a **detachable viewport** onto that server's log stream — -so `Ctrl-C` detaches the viewport and leaves the server running; only `perchd stop` -terminates it. The foreground/background choice is one explicit flag (`-d`) over one -lifecycle, never two separate commands. - -Keep that shape when adding features. If a change makes "am I attached?" and "is the -server alive?" the same question again, it's the wrong change. +"in the foreground" is just a **viewport** onto that server's log stream. The +foreground/background choice is one explicit flag (`-d`) over one lifecycle, never two +separate commands. + +Ctrl-C is *hybrid* (`attachViewport`'s `stopOnInterrupt`): if this viewport **started** +the server (bare `perchd` / `switch`), Ctrl-C — and closing the window — stop it, the +`npm run dev` drop-in promise. If it **attached** to an already-running server +(`perchd attach`), Ctrl-C only detaches. The banner states which, per mode. Keep that +distinction crisp when adding features: whether Ctrl-C stops depends on *who started the +server*, not on how it looks. ## Scope diff --git a/README.md b/README.md index 03a2fc0..1c9a16d 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@

perchd is npm run dev, pointed at any worktree —
- and you can change which one without killing anything.
+ switch which branch is live without the port-collision dance.

@@ -48,49 +48,43 @@ branch you're looking at. No `cd`. No remembering the command. No port roulette. ## You already know the command -You don't have to learn perchd to use perchd. Your dev command is `npm run dev` (or -`pnpm dev`, or `make dev`). Trade it for **`perchd`** and you get the same thing — -foreground, logs streaming — except it runs **any worktree**, always on the **same -port**: +You don't have to learn perchd to use perchd. Run **`perchd`** and you get a menu of +your worktrees with the one you're standing in **pre-selected** — press Enter and it's +your `npm run dev`: foreground, logs streaming, on the native port. Arrow to a different +worktree and that's the switch. Or name one directly and skip the menu: ```sh -perchd # the worktree you're standing in — your `npm run dev`, basically -perchd feature/auth # a different version, same terminal, same URL +perchd # menu of worktrees, current one pre-selected → Enter runs it +perchd feature/auth # skip the menu — run that worktree by name perchd main # the main tree, without leaving your branch ``` -Same muscle memory. Swap one word, and now every branch your agents touched is one -command away. +Same muscle memory, all the way down to Ctrl-C: it stops the server, exactly like +`npm run dev`. The menu is the point, not overhead — it's how you switch which branch +you're previewing without ever `cd`-ing between worktrees. -(Each of those attaches to your terminal, so you'd ^C to detach before -running the next — which, as the next section explains, doesn't stop anything.) +## The switcher underneath -## Detach and switch. Attach and watch. - -Here's the part that isn't `npm run dev`. - -The server **always lives in the background**. What you call "foreground" is just a -*viewport* — your terminal, attached to the log stream. So: +That one live server actually runs in the **background**, and your terminal is just a +*viewport* onto its log stream — so you can move it, leave it running, or watch it from +somewhere else: ```sh -perchd # attach: live logs in your terminal, like npm run dev -^C # detach: you stop watching. the server KEEPS RUNNING. -perchd fix/payments # move the perch to another branch -perchd stop # actually terminate it +perchd # pick a worktree → attach, live logs, like npm run dev +^C # → stops it, just like npm run dev +perchd feature/auth -d # switch to another branch, but DON'T attach — keep your prompt +perchd attach # come watch that one; here ^C only detaches, it keeps running +perchd stop # terminate whatever's active, from anywhere ``` -> **Ctrl-C detaches. It does not kill.** -> That one rule is what makes switching safe — and it's the opposite reflex from -> `npm run dev`. To really stop the server, say `perchd stop`. - -And when you'd rather not tie up a prompt at all, `-d` switches without attaching: - -```sh -perchd feature/auth -d # switch, print the URL, hand your prompt back -perchd attach # ...come watch it whenever you like -``` +> **Ctrl-C stops the server you started — like `npm run dev`.** +> The one exception is `perchd attach`: you're peeking at a server that's meant to +> persist, so there Ctrl-C only *detaches* and leaves it running (the banner tells you +> which mode you're in). Either way, `perchd stop` is the definitive kill. -One server. One port. `-d` is the only knob, and it means the same thing everywhere. +So there are two ways to keep a server alive while you move on: start it detached with +`-d`, or `attach` to it and Ctrl-C back out. Closing the terminal window on a server you +started stops it too — no orphans. ## Before / after @@ -155,9 +149,9 @@ otherwise a clean built-in prompt takes over. ```sh cd any/worktree/of/your/repo -perchd # run this worktree, attached (your `npm run dev`) -^C # detach — it keeps running -perchd feature/auth -d # switch to another branch, don't attach +perchd # menu (current worktree pre-selected) → Enter runs it +^C # stop it — just like npm run dev +perchd feature/auth -d # switch to another branch by name, keep it in the background perchd status # table: worktree, runner, port, which is ACTIVE perchd stop # stop the active server ``` @@ -166,7 +160,8 @@ perchd stop # stop the active server | Command | What it does | | --- | --- | -| `perchd [branch\|path]` | Switch the active dev server **and attach** (drop-in for `npm run dev`). Interactive picker when there's no obvious target. | +| `perchd` | Menu of worktrees, current one pre-selected — Enter runs it (drop-in), or pick another to switch. Then attaches. | +| `perchd ` | Skip the menu: switch the active dev server to that worktree **and attach**. | | `perchd … -d` / `--detach` | Switch but stay in the background — print the URL and return the prompt. | | `perchd switch [branch\|path]` | Explicit synonym for `perchd [branch\|path]`. Note it now **attaches** by default; add `-d` for the old background behavior. | | `perchd attach [branch]` | Attach to the active server (or switch to `branch`, then attach). | @@ -218,12 +213,14 @@ hint and will be removed in a future major. | `perchd dev feature/auth` | `perchd feature/auth` | | `perchd` (background switch) | `perchd -d` | | `perchd switch feature/auth` (background) | `perchd feature/auth -d` | -| Ctrl-C **stopped** the server | Ctrl-C **detaches**; use `perchd stop` | +| Ctrl-C stopped the server | Ctrl-C still stops it — same as before | `perchd switch` still exists as an explicit synonym, but like every switch it now **attaches** by default — pass `-d` to get the old background behavior. -The last row is the one to internalize: Ctrl-C no longer kills the dev server. +> If you used `0.4.0` specifically: that release briefly made Ctrl-C *detach* instead of +> stop. `0.5.0` reverts to the intuitive `npm run dev` behavior — Ctrl-C stops the server +> you started. Only `perchd attach` detaches on Ctrl-C now. ## Supported frameworks @@ -281,14 +278,19 @@ Then `perchd cd feature/auth` drops you right where your agent has been working. ## FAQ **Do I have to learn a new tool?** -No. `perchd` *is* your `npm run dev` — same foreground, same logs — it just aims at any -worktree (or the main tree) on the same port. Swap one word today; discover the -switching whenever you feel like it. - -**Why doesn't Ctrl-C stop the server?** -Because the server isn't *yours* — it's the perch, and your terminal is just looking at -it. Detaching lets you close the terminal, switch branches, or attach from somewhere -else without a restart. `perchd stop` is the kill switch. +Barely. Run `perchd`, press Enter on the pre-selected current worktree, and it's your +`npm run dev` — same foreground, same logs, same port. The only new thing is that the +menu lets you arrow to another worktree instead of `cd`-ing there first. + +**Does Ctrl-C stop the server, like `npm run dev`?** +Yes — when you started it (`perchd` / `perchd `), Ctrl-C stops it, and so does +closing the terminal. The one exception is `perchd attach`: there you're looking at a +server that's meant to keep running, so Ctrl-C just detaches and leaves it alive. The +banner tells you which mode you're in, and `perchd stop` always terminates. + +**How do I keep a server running while I move on?** +Two ways: start it detached with `perchd -d` (never attaches), or `perchd attach` +to a running one and Ctrl-C back out. **Can't it just run all eight servers at once?** No. You have two eyes. It runs the one you're looking at. That's not a limitation — diff --git a/package.json b/package.json index a0e2397..bb4712f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "perchd", - "version": "0.4.0", + "version": "0.5.0", "description": "npm run dev, pointed at any git worktree — one dev server, one port, switch which branch it points at", "type": "module", "bin": { diff --git a/src/cli.ts b/src/cli.ts index ee5bf72..c4349e9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,3 +1,4 @@ +import { readFileSync } from "node:fs"; import { cac } from "cac"; import pc from "picocolors"; import { loadContext } from "./core/context.js"; @@ -18,6 +19,13 @@ import { runDoctor } from "./commands/doctor.js"; import { runConfig } from "./commands/config.js"; import { runWatch } from "./commands/watch.js"; +// Read our own version from package.json. `../package.json` resolves the same +// from the entry whether it runs as src/cli.ts (tsx) or the bundled dist/cli.js +// (published tarball ships package.json at the root, one level above dist/). +const version: string = JSON.parse( + readFileSync(new URL("../package.json", import.meta.url), "utf8"), +).version; + const cli = cac("perchd"); const cwd = process.cwd(); let passthrough: string[] = []; @@ -37,15 +45,17 @@ function fail(e: unknown): never { } /** - * Bare `perchd`: the worktree you're standing in is the obvious target. - * Only when cwd is outside every worktree do we ask. + * Bare `perchd`: show the worktree menu, with the one you're standing in + * pre-selected. Enter re-runs where you stand (the `npm run dev` drop-in); + * arrowing to another worktree is the switch — perchd's whole point is not + * having to `cd` between them. The cwd worktree, else the active one, is the + * default landing. */ async function bareTarget(): Promise { const ctx = await loadContext(cwd); - const here = containingWorktree(ctx.worktrees, cwd); - if (here) return here.path; const active = readState(ctx.commonDir).active; - return pick(ctx.worktrees, active?.worktreePath ?? null); + const preselect = containingWorktree(ctx.worktrees, cwd)?.path ?? active?.worktreePath ?? null; + return pick(ctx.worktrees, active?.worktreePath ?? null, preselect); } /** @@ -68,7 +78,12 @@ async function switchThenMaybeAttach(target: string | undefined, flags: any): Pr }); if (!active || flags.detach) return; const ctx = await loadContext(cwd); - process.exit(await attachViewport(active, ctx.commonDir, { fromStart: true })); + // We started the server → Ctrl-C stops it, like `npm run dev`. + process.exit(await attachViewport(active, ctx.commonDir, { + fromStart: true, + stopOnInterrupt: true, + stopTimeoutMs: ctx.config.stop_timeout * 1000, + })); } type Cmd = ReturnType; @@ -135,6 +150,7 @@ cli.command("config", "print resolved config + detected runner per worktree") cli.command("watch", "watch for worktree deletion and auto-stop the active server") .action(async () => { try { await runWatch(cwd); } catch (e) { fail(e); } }); +cli.version(version); // adds `-v, --version` cli.help(); // Split argv at the first standalone `--`: everything after is verbatim diff --git a/src/commands/attach.ts b/src/commands/attach.ts index 727137d..f18b1c3 100644 --- a/src/commands/attach.ts +++ b/src/commands/attach.ts @@ -13,11 +13,14 @@ import { runSwitch } from "./switch.js"; export async function runAttach(cwd: string, target?: string): Promise { const ctx = await loadContext(cwd); + // `attach` never stops on Ctrl-C: you're peeking at a server meant to persist. + const stopTimeoutMs = ctx.config.stop_timeout * 1000; + if (target) { const active = await runSwitch({ target, quiet: true, nowIso: new Date().toISOString(), cwd }); if (!active) return 1; // Freshly started: replay the log from the top so the startup banner shows. - return attachViewport(active, ctx.commonDir, { fromStart: true }); + return attachViewport(active, ctx.commonDir, { fromStart: true, stopOnInterrupt: false, stopTimeoutMs }); } const active = readState(ctx.commonDir).active; @@ -30,5 +33,5 @@ export async function runAttach(cwd: string, target?: string): Promise { return 1; } // Re-attaching to a server that has been up a while: show recent lines only. - return attachViewport(active, ctx.commonDir, { fromStart: false }); + return attachViewport(active, ctx.commonDir, { fromStart: false, stopOnInterrupt: false, stopTimeoutMs }); } diff --git a/src/core/viewport.ts b/src/core/viewport.ts index 309be35..5a43363 100644 --- a/src/core/viewport.ts +++ b/src/core/viewport.ts @@ -1,9 +1,11 @@ import { spawn, type ChildProcess } from "node:child_process"; import pc from "picocolors"; import { clearActive, isPidAlive, readState, type ActiveServer } from "./state.js"; +import { stopGroup } from "./process.js"; export type ViewportExit = - | { reason: "detached" } + | { reason: "interrupted"; signal: "SIGINT" | "SIGHUP" } + | { reason: "detached" } // the tail process ended on its own (log removed, etc.) | { reason: "stopped" } | { reason: "perch-moved"; to: string | null } | { reason: "server-exited" }; @@ -24,6 +26,8 @@ export interface ViewportDeps { now?: () => number; /** Register a SIGINT handler; returns an unregister function. */ onSigint?: (handler: () => void) => () => void; + /** Register a SIGHUP handler (terminal closed); returns an unregister function. */ + onSighup?: (handler: () => void) => () => void; } /** `tail -f` the log. `-n +1` replays from the top; otherwise show the recent tail. */ @@ -48,10 +52,16 @@ export async function runViewport(active: ActiveServer, deps: ViewportDeps): Pro settle = (e) => { if (!settled) { settled = true; resolve(e); } }; }); - const onInt = () => settle({ reason: "detached" }); - const unregister = deps.onSigint + // runViewport itself never signals the server — it only reports that the user + // asked to leave and by which signal. attachViewport decides stop vs. detach. + const onInt = () => settle({ reason: "interrupted", signal: "SIGINT" }); + const onHup = () => settle({ reason: "interrupted", signal: "SIGHUP" }); + const unSigint = deps.onSigint ? deps.onSigint(onInt) : (process.on("SIGINT", onInt), () => { process.off("SIGINT", onInt); }); + const unSighup = deps.onSighup + ? deps.onSighup(onHup) + : (process.on("SIGHUP", onHup), () => { process.off("SIGHUP", onHup); }); // If the tail dies (log rotated, file removed), treat it as a detach. tail.once("exit", () => settle({ reason: "detached" })); @@ -80,30 +90,73 @@ export async function runViewport(active: ActiveServer, deps: ViewportDeps): Pro const exit = await done; clearInterval(timer); - unregister(); + unSigint(); + unSighup(); if (!tail.killed) { try { tail.kill("SIGTERM"); } catch { /* already gone */ } } return exit; } const name = (a: ActiveServer) => a.branch ?? a.worktreePath; +export interface AttachOptions { + fromStart: boolean; + /** + * True when this viewport *started* the server (bare `perchd` / `switch`): + * Ctrl-C stops it, matching `npm run dev`. False when attaching to a server + * that was already running (`perchd attach`): Ctrl-C only detaches, because + * killing something you attached to peek at would be a footgun. + */ + stopOnInterrupt: boolean; + stopTimeoutMs?: number; +} + /** Print the banner + discovery hint, run the viewport, report why it ended. */ export async function attachViewport( active: ActiveServer, commonDir: string, - opts: { fromStart: boolean }, + opts: AttachOptions, + run: typeof runViewport = runViewport, ): Promise { console.log(pc.green(`▶ ${name(active)} → ${active.url}`)); - console.log(pc.dim(" ^C detaches (the server keeps running) · `perchd ` moves the perch · `-d` skips attaching")); + console.log(pc.dim( + opts.stopOnInterrupt + ? " ^C stops · `perchd ` moves the perch · `-d` starts in the background" + : " ^C detaches (the server keeps running) · `perchd stop` to terminate", + )); - const exit = await runViewport(active, { + const exit = await run(active, { readActive: () => readState(commonDir).active, isAlive: isPidAlive, startTail: defaultTail, fromStart: opts.fromStart, }); + // Clear the state record only if it is still ours (a later switch may own it now). + const clearIfOurs = () => { + const cur = readState(commonDir).active; + if (cur && cur.pid === active.pid) clearActive(commonDir); + }; + switch (exit.reason) { + case "interrupted": { + if (!opts.stopOnInterrupt) { + console.log(pc.dim(`\n▸ detached — ${name(active)} still running at ${active.url}. \`perchd stop\` to terminate.`)); + return 0; + } + // We own the server: Ctrl-C / window-close stops it, like `npm run dev`. + if (exit.signal === "SIGHUP") { + // The terminal is gone — no reader for a message, and no time to wait on + // an escalating teardown. Best-effort SIGTERM to the whole group; a + // well-behaved dev server exits on it. + try { process.kill(-active.pgid, "SIGTERM"); } catch { /* already gone */ } + clearIfOurs(); + return 0; + } + await stopGroup(active.pgid, opts.stopTimeoutMs ?? 8000); + clearIfOurs(); + console.log(pc.dim(`\n▸ stopped ${name(active)}.`)); + return 0; + } case "detached": console.log(pc.dim(`\n▸ detached — ${name(active)} still running at ${active.url}. \`perchd stop\` to terminate.`)); return 0; @@ -113,12 +166,9 @@ export async function attachViewport( case "stopped": console.log(pc.dim(`\n▸ ${name(active)} stopped.`)); return 0; - case "server-exited": { - // The server died on its own; clear the record if it is still ours. - const cur = readState(commonDir).active; - if (cur && cur.pid === active.pid) clearActive(commonDir); + case "server-exited": + clearIfOurs(); console.log(pc.yellow(`\n▸ ${name(active)} exited.`)); return 1; - } } } diff --git a/src/ui/picker.ts b/src/ui/picker.ts index 551ae07..97f81b2 100644 --- a/src/ui/picker.ts +++ b/src/ui/picker.ts @@ -4,8 +4,12 @@ import type { Worktree } from "../core/git.js"; export interface Choice { value: string; label: string; } -export function pickerChoices(worktrees: Worktree[], activePath: string | null): Choice[] { - return worktrees +export function pickerChoices( + worktrees: Worktree[], + activePath: string | null, + preselectPath: string | null = null, +): Choice[] { + const choices = worktrees .filter((w) => !w.bare) .map((w) => { const name = w.branch ?? `(${w.head.slice(0, 7)})`; @@ -13,14 +17,28 @@ export function pickerChoices(worktrees: Worktree[], activePath: string | null): const locked = w.locked ? " [locked]" : ""; return { value: w.path, label: `${name}${locked}${active}` }; }); + + // Float the pre-selected worktree (usually cwd's) to the front so the cursor + // lands on it: `perchd` then Enter re-runs where you stand (drop-in), while + // arrowing to another worktree is the switch. Works for both backends — fzf's + // default cursor is the first line; clack also gets an explicit initialValue. + if (preselectPath) { + const i = choices.findIndex((c) => c.value === preselectPath); + if (i > 0) choices.unshift(choices.splice(i, 1)[0]); + } + return choices; } function hasFzf(): boolean { try { execaSync("fzf", ["--version"]); return true; } catch { return false; } } -export async function pick(worktrees: Worktree[], activePath: string | null): Promise { - const choices = pickerChoices(worktrees, activePath); +export async function pick( + worktrees: Worktree[], + activePath: string | null, + preselectPath: string | null = null, +): Promise { + const choices = pickerChoices(worktrees, activePath, preselectPath); if (choices.length === 0) return null; if (hasFzf()) { @@ -30,9 +48,16 @@ export async function pick(worktrees: Worktree[], activePath: string | null): Pr return res.stdout.split("\t").pop()!.trim(); } + // clack needs an interactive terminal. Without one (piped, CI, a hook), fail + // with a clear message instead of a raw `uv_tty_init` crash — name a target. + if (!process.stdin.isTTY) { + throw new Error("no interactive terminal for the worktree picker — name a target, e.g. `perchd main`"); + } + const sel = await p.select({ - message: "Switch to worktree", + message: "Pick a worktree to run", options: choices.map((c) => ({ value: c.value, label: c.label })), + initialValue: preselectPath ?? undefined, }); return p.isCancel(sel) ? null : (sel as string); } diff --git a/test/attach.test.ts b/test/attach.test.ts index b6318e9..dc70c67 100644 --- a/test/attach.test.ts +++ b/test/attach.test.ts @@ -49,7 +49,7 @@ describe("runAttach", () => { expect(attachViewport).toHaveBeenCalledWith( expect.objectContaining({ logPath: "/l.log" }), "/common", - { fromStart: false }, + { fromStart: false, stopOnInterrupt: false, stopTimeoutMs: 8000 }, ); }); @@ -60,7 +60,7 @@ describe("runAttach", () => { expect(attachViewport).toHaveBeenCalledWith( expect.objectContaining({ branch: "feature/auth" }), "/common", - { fromStart: true }, + { fromStart: true, stopOnInterrupt: false, stopTimeoutMs: 8000 }, ); }); diff --git a/test/integration/attach-flow.test.ts b/test/integration/attach-flow.test.ts index ae9ecf1..e6bfb37 100644 --- a/test/integration/attach-flow.test.ts +++ b/test/integration/attach-flow.test.ts @@ -35,7 +35,7 @@ describe("attach flow", () => { let wtPath: string; afterAll(async () => { if (wtPath) rmSync(wtPath, { recursive: true, force: true }); }); - it("detaching the viewport leaves the server running; stop then kills it", async () => { + it("runViewport never signals the server itself (interrupt only reports intent)", async () => { wtPath = await setupRepo(); const active = await runSwitch({ target: wtPath, cmd: "PORT=3021 node server.js", port: 3021, @@ -56,9 +56,10 @@ describe("attach flow", () => { pollMs: 50, onSigint: (h) => { setTimeout(h, 100); return () => {}; }, }); - expect(exit).toEqual({ reason: "detached" }); + expect(exit).toEqual({ reason: "interrupted", signal: "SIGINT" }); - // THE POINT OF THE WHOLE REDESIGN: the server survived the detach. + // runViewport reports intent but never signals the server — the stop/detach + // decision lives in attachViewport. So here the server is still alive. expect(isPidAlive(active!.pid)).toBe(true); expect(await waitForPort(3021, 2000)).toBe(true); diff --git a/test/integration/ctrlc-policy.test.ts b/test/integration/ctrlc-policy.test.ts new file mode 100644 index 0000000..a02b54e --- /dev/null +++ b/test/integration/ctrlc-policy.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, afterAll } from "vitest"; +import { execa } from "execa"; +import { mkdtempSync, rmSync, cpSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { runSwitch } from "../../src/commands/switch.js"; +import { attachViewport, type ViewportExit } from "../../src/core/viewport.js"; +import { readState, isPidAlive } from "../../src/core/state.js"; +import { gitCommonDir } from "../../src/core/git.js"; +import { stopGroup, waitForPort, waitForPortFree } from "../../src/core/process.js"; + +const fixture = fileURLToPath(new URL("../fixtures/mini-server", import.meta.url)); + +async function setupRepo(): Promise { + const root = mkdtempSync(join(tmpdir(), "perchd-ctrlc-it-")); + await execa("git", ["init", "-q"], { cwd: root }); + await execa("git", ["config", "user.email", "t@t"], { cwd: root }); + await execa("git", ["config", "user.name", "t"], { cwd: root }); + cpSync(fixture, root, { recursive: true }); + await execa("git", ["add", "-A"], { cwd: root }); + await execa("git", ["commit", "-qm", "init"], { cwd: root }); + const { stdout } = await execa( + "git", ["rev-parse", "--path-format=absolute", "--show-toplevel"], { cwd: root }, + ); + return stdout.trim(); +} + +// Inject a runViewport that immediately reports a user interrupt, so we exercise +// the real stop/detach teardown against a real server without racing signals. +const interruptWith = (signal: "SIGINT" | "SIGHUP") => + (async (): Promise => ({ reason: "interrupted", signal })) as any; + +describe("Ctrl-C policy (hybrid)", () => { + let wtPath: string; + afterAll(async () => { if (wtPath) rmSync(wtPath, { recursive: true, force: true }); }); + + it("a server you STARTED is stopped on Ctrl-C (drop-in behaviour)", async () => { + wtPath = await setupRepo(); + const active = await runSwitch({ + target: wtPath, cmd: "PORT=3051 node server.js", port: 3051, + nowIso: "2026-07-11T00:00:00Z", cwd: wtPath, + }); + expect(await waitForPort(3051, 8000)).toBe(true); + const common = await gitCommonDir(wtPath); + + const code = await attachViewport( + active!, common, + { fromStart: true, stopOnInterrupt: true, stopTimeoutMs: 5000 }, + interruptWith("SIGINT"), + ); + + expect(code).toBe(0); + expect(isPidAlive(active!.pid)).toBe(false); // server really died + expect(await waitForPortFree(3051, 5000)).toBe(true); + expect(readState(common).active).toBeNull(); // record cleared + }); + + it("window-close (SIGHUP) also stops a server you started", async () => { + const active = await runSwitch({ + target: wtPath, cmd: "PORT=3052 node server.js", port: 3052, + nowIso: "2026-07-11T00:00:00Z", cwd: wtPath, + }); + expect(await waitForPort(3052, 8000)).toBe(true); + const common = await gitCommonDir(wtPath); + + const code = await attachViewport( + active!, common, + { fromStart: true, stopOnInterrupt: true, stopTimeoutMs: 5000 }, + interruptWith("SIGHUP"), + ); + + expect(code).toBe(0); + expect(await waitForPortFree(3052, 6000)).toBe(true); // SIGTERM'd on hangup + expect(readState(common).active).toBeNull(); + }); + + it("a server you ATTACHED to survives Ctrl-C (detach only)", async () => { + const active = await runSwitch({ + target: wtPath, cmd: "PORT=3053 node server.js", port: 3053, + nowIso: "2026-07-11T00:00:00Z", cwd: wtPath, + }); + expect(await waitForPort(3053, 8000)).toBe(true); + const common = await gitCommonDir(wtPath); + + const code = await attachViewport( + active!, common, + { fromStart: false, stopOnInterrupt: false, stopTimeoutMs: 5000 }, + interruptWith("SIGINT"), + ); + + expect(code).toBe(0); + expect(isPidAlive(active!.pid)).toBe(true); // still alive + expect(await waitForPort(3053, 2000)).toBe(true); // still serving + expect(readState(common).active?.pid).toBe(active!.pid); // record intact + + // cleanup + await stopGroup(active!.pgid, 5000); + expect(await waitForPortFree(3053, 5000)).toBe(true); + }); +}); diff --git a/test/picker.test.ts b/test/picker.test.ts index 34e2623..d62f00c 100644 --- a/test/picker.test.ts +++ b/test/picker.test.ts @@ -2,17 +2,36 @@ import { describe, it, expect } from "vitest"; import { pickerChoices } from "../src/ui/picker.js"; describe("pickerChoices", () => { + const wts = [ + { path: "/wt/main", branch: "main", head: "a", detached: false, locked: false, bare: false }, + { path: "/wt/auth", branch: "feature/auth", head: "b", detached: false, locked: false, bare: false }, + { path: "/wt/pay", branch: "fix/payments", head: "c", detached: false, locked: false, bare: false }, + ]; + it("builds labeled choices, marking the active one", () => { - const choices = pickerChoices( - [ - { path: "/wt/main", branch: "main", head: "a", detached: false, locked: false, bare: false }, - { path: "/wt/auth", branch: "feature/auth", head: "b", detached: false, locked: false, bare: false }, - ], - "/wt/auth", - ); + const choices = pickerChoices(wts.slice(0, 2), "/wt/auth"); expect(choices).toHaveLength(2); expect(choices[1].label).toContain("feature/auth"); expect(choices[1].label).toContain("ACTIVE"); expect(choices[1].value).toBe("/wt/auth"); }); + + it("moves the pre-selected worktree to the front (cursor lands there)", () => { + const choices = pickerChoices(wts, "/wt/auth", "/wt/pay"); + expect(choices[0].value).toBe("/wt/pay"); // cwd worktree first + expect(choices.map((c) => c.value)).toEqual(["/wt/pay", "/wt/main", "/wt/auth"]); + }); + + it("keeps the ACTIVE marker on the active worktree even when a different one is pre-selected", () => { + const choices = pickerChoices(wts, "/wt/auth", "/wt/pay"); + const active = choices.find((c) => c.value === "/wt/auth"); + expect(active!.label).toContain("ACTIVE"); + expect(choices[0].label).not.toContain("ACTIVE"); // pre-selected != active + }); + + it("is a no-op ordering when the pre-select is already first or absent", () => { + expect(pickerChoices(wts, null).map((c) => c.value)).toEqual(["/wt/main", "/wt/auth", "/wt/pay"]); + expect(pickerChoices(wts, null, "/wt/main").map((c) => c.value)).toEqual(["/wt/main", "/wt/auth", "/wt/pay"]); + expect(pickerChoices(wts, null, "/nope").map((c) => c.value)).toEqual(["/wt/main", "/wt/auth", "/wt/pay"]); + }); }); diff --git a/test/version.test.ts b/test/version.test.ts new file mode 100644 index 0000000..20fdd5c --- /dev/null +++ b/test/version.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from "vitest"; +import { execa } from "execa"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const repoRoot = fileURLToPath(new URL("..", import.meta.url)); +const cliEntry = fileURLToPath(new URL("../src/cli.ts", import.meta.url)); +const pkgVersion = JSON.parse( + readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8"), +).version as string; + +// Run the real CLI entry through tsx (a devDep, available in CI) so this guards +// the actual `perchd --version` path, not a stand-in. +const run = (args: string[]) => + execa("node", ["--import", "tsx", cliEntry, ...args], { cwd: repoRoot, reject: false }); + +describe("perchd --version", () => { + it("prints the package.json version for --version", async () => { + const { stdout, exitCode } = await run(["--version"]); + expect(exitCode).toBe(0); + expect(stdout).toContain(pkgVersion); + expect(stdout).toMatch(/^perchd\//); + }); + + it("accepts the -v alias", async () => { + const { stdout, exitCode } = await run(["-v"]); + expect(exitCode).toBe(0); + expect(stdout).toContain(pkgVersion); + }); + + it("does not throw 'Unknown option' (the pre-fix regression)", async () => { + const { stderr } = await run(["--version"]); + expect(stderr).not.toMatch(/Unknown option/); + }); +}); diff --git a/test/viewport.test.ts b/test/viewport.test.ts index 3e8cbaf..ec72147 100644 --- a/test/viewport.test.ts +++ b/test/viewport.test.ts @@ -27,10 +27,26 @@ const deps = (over: Partial): ViewportDeps => ({ }); describe("runViewport", () => { - it("SIGINT detaches (the server is never signalled)", async () => { + it("SIGINT reports interrupted (runViewport never signals the server itself)", async () => { const exit = await runViewport(active, deps({ onSigint: (h) => { setTimeout(h, 10); return () => {}; }, })); + expect(exit).toEqual({ reason: "interrupted", signal: "SIGINT" }); + }); + + it("SIGHUP reports interrupted with the SIGHUP signal", async () => { + const exit = await runViewport(active, deps({ + onSighup: (h) => { setTimeout(h, 10); return () => {}; }, + })); + expect(exit).toEqual({ reason: "interrupted", signal: "SIGHUP" }); + }); + + it("a dead tail process ends the viewport as a plain detach (no signal)", async () => { + const tail = fakeTail(); + const exit = await runViewport(active, deps({ + startTail: () => { setTimeout(() => tail.emit("exit", 0), 10); return tail; }, + onSigint: () => () => {}, + })); expect(exit).toEqual({ reason: "detached" }); });