A /worktrees pane: every git worktree of the repository with its branch and pull request state, a copy-path press per row and a prune button.

<img src="https://cdn.jsdelivr.net/npm/@rxova/brand@1/assets/rxova-logo-256.png" width="128" alt="rxova logo" />
<h1 align="center">rxova/shared</h1>
<a href="https://github.com/rxova/shared/actions/workflows/ci.yml"><img src="https://github.com/rxova/shared/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI status" /></a> <img src="https://img.shields.io/badge/Node.js-%E2%89%A522.13-5fa04e?logo=nodedotjs&logoColor=white" alt="Node.js 22.13 or newer" /> <img src="https://img.shields.io/badge/pnpm%20%2B%20Turborepo-%E2%9C%93-f69220" alt="Made for pnpm and Turborepo" /> <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license" /></a>
<a href="#packages">Packages</a> · <a href="#ci-building-blocks">CI building blocks</a> · <a href="#using-it-in-an-rxova-repo">Using it</a> · <a href="#developing-this-repo">Developing</a> · <a href="#docs">Docs</a>
Every repository ends up with the same helpers, the same verify gate, the same CI jobs and the same Renovate rules. Copied, they drift. Here they live once: repositories depend on the packages and call the actions and workflows by path, so a fix lands everywhere on the next install or run.
| Package | npm | What it is |
|---|---|---|
@rxova/ts-utils | Small, dependency-free runtime helpers, inlined at build time, plus a /react entry. | |
@rxova/repo-config | The rxova-repo-config bin (verify gate, changesets, scope, tarball and docs checks) and presets for ESLint, Prettier, tsconfig, Vitest and more. | |
@rxova/agent-kit | 10 hooks, 51 skills and 25 agents for Claude Code and OpenCode, installed in profiles by rxova-agent-kit. | |
@rxova/docs-kit | Markdown twins, llms.txt and the check-md-routes build check for an Astro Starlight docs site. |
Each package README lists every export, command and option.
- uses: rxova/shared/actions/setup-pnpm@main
| Action | What it does |
|---|---|
setup-pnpm | pnpm at the packageManager version and Node, with the store cached. |
turbo-cache | Restores and saves the local Turbo cache across runs. |
turbo-remote-cache | Points Turbo at a remote cache backed by the Actions cache. |
setup-playwright | Resolves, caches and installs Playwright browsers. |
pin-react | Pins one React at the root and in the given packages, and proves only that one resolves. |
require-jobs | Fails unless every job in needs passed or was skipped: the one check to require. |
notify-website | Tells the rxova.dev website to publish this run's docs build. |
Inputs and examples: actions/README.md.
jobs:
commitlint:
uses: rxova/shared/.github/workflows/commit-messages.yml@main
| Workflow | What it does |
|---|---|
commit-messages.yml | The root CI job: lints commits and outputs code-changed, docs-only and docs-changed. |
docs-checks.yml | The light job a documentation-only range runs instead of the build and test matrix. |
lint-pr-title.yml | Lints the PR title, which becomes the squash subject. |
changeset-gate.yml | Requires a changeset when a pull request changes a published package. |
react-minimum-version.yml | Builds, tests and typechecks against the oldest React a peer range allows. |
node-floor-smoke.yml | Installs each packed package on exactly the oldest Node its engines promises. |
changesets-release.yml | The version pull request, then publishing with npm trusted publishing and provenance. |
snapshot-release.yml | Publishes a prerelease under a dist-tag other than latest, to try a change in another repo. |
Inputs, outputs and a full CI graph: .github/workflows/README.md.
{ extends: ["github>rxova/shared//renovate/default.json5"] }
The org rules live in renovate/default.json5; a repository keeps only its own.
Tooling packages go in the root package.json only, never in a workspace package:
pnpm add -D -w @rxova/repo-config @rxova/ts-utils
Point each tool at its preset:
// eslint.config.js
import { rxova } from "@rxova/repo-config/eslint";
export default rxova({ tsconfigRootDir: import.meta.dirname, node: true, tests: true });
// .prettierrc
"@rxova/repo-config/prettier"
// tsconfig.json
{ "extends": "@rxova/repo-config/tsconfig.react.json", "include": ["src"] }
And CI at the shared workflows, pinned to @main:
jobs:
commitlint:
uses: rxova/shared/.github/workflows/commit-messages.yml@main
changeset:
needs: [commitlint]
uses: rxova/shared/.github/workflows/changeset-gate.yml@main
The @rxova/repo-config quick start covers the rest: the verify script, the pre-push hook, lint-staged, commitlint, tsdown and Vitest.
Node 22.13 or newer, and pnpm through Corepack:
corepack enable
pnpm install # dependencies and git hooks
pnpm test # unit tests, coverage enforced per file
pnpm docs # the docs site (apps/docs) on a local dev server
pnpm run verify # everything CI runs, in CI's order (also the pre-push hook)
pnpm changeset # record a change to a published package
main, Changesets opens a chore: version packages pull request; merging it publishes to npm with trusted publishing and provenance.docs checks job instead; the docs site still builds when apps/docs changed. pnpm exec rxova-repo-config add-changeset ts-utils patch "Rewrite the readme"
See CONTRIBUTING.md for hooks and commit rules, SECURITY.md to report a vulnerability, and SUPPORT.md for help.
Every README in the repository:
packages/ts-utils/README.md: @rxova/ts-utilspackages/repo-config/README.md: @rxova/repo-configpackages/agent-kit/README.md: @rxova/agent-kitpackages/docs-kit/README.md: @rxova/docs-kitactions/README.md: the composite actions.github/workflows/README.md: the reusable workflows.changeset/README.md: adding a changeset and how it is releasedThe documentation site's sources are in apps/docs; pnpm docs serves it locally.
hooks/register.tsx 125 lines1import { atom, read, update } from "claude-code";
2import type { EngineInterface, Register } from "claude-code";
3
4import { baseName } from "./base-name";
5import { prLabel } from "./pr-label";
6import { parsePrs } from "./parse-prs";
7import { parseWorktrees } from "./parse-worktrees";
8
9const PANE = "rx-worktrees";
10const view = atom({ plugin: "rx-worktrees", key: "view" } as const, {
11 rows: [],
12 error: null,
13 isLoading: false,
14});
15
16/** Reads the worktrees and their pull requests into the pane's state. */
17const refresh = async ($: EngineInterface): Promise<void> => {
18 await update($, view, (current) => ({ ...current, isLoading: true }));
19
20 const list = await $.process.run(["git", "worktree", "list", "--porcelain"]);
21 if (list.exitCode !== 0) {
22 await update($, view, () => ({
23 rows: [],
24 error: list.stderr.trim() || "Not a git repository.",
25 isLoading: false,
26 }));
27 return;
28 }
29
30 const prs = await $.process
31 .run([
32 "gh",
33 "pr",
34 "list",
35 "--state",
36 "all",
37 "--limit",
38 "200",
39 "--json",
40 "headRefName,number,state",
41 ])
42 .then((run) => parsePrs(run.exitCode === 0 ? run.stdout : "[]"))
43 .catch(() => parsePrs("[]"));
44
45 const rows = parseWorktrees(list.stdout).map((row) => ({
46 ...row,
47 pr: prs.get(row.branch) ?? null,
48 }));
49 await update($, view, () => ({ rows, error: null, isLoading: false }));
50};
51
52/** Runs `git worktree prune` (records of worktrees whose folder is gone) and reads the list again. */
53const prune = async ($: EngineInterface): Promise<void> => {
54 const run = await $.process.run(["git", "worktree", "prune", "--verbose"]);
55 const pruned = `${run.stdout}\n${run.stderr}`
56 .split("\n")
57 .filter((line) => line.startsWith("Removing")).length;
58 $.ui.toast(
59 run.exitCode === 0
60 ? `rx-worktrees: pruned ${pruned} stale worktree record(s)`
61 : `rx-worktrees: prune failed: ${run.stderr.trim()}`,
62 );
63 await refresh($);
64};
65
66export const register: Register = (on) => {
67 on("session.start", async ($, e, next) => {
68 await $.command.register({
69 name: "worktrees",
70 description: "Show the git worktrees of this repository with their pull requests",
71 });
72
73 return next(e);
74 });
75
76 on("command.run", { command: "worktrees" }, async ($) => {
77 await $.ui.open({ id: PANE, title: "Worktrees" });
78 await refresh($);
79
80 return { text: "Worktrees pane opened." };
81 });
82
83 on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
84 const { Box, Button, Text } = $.ui.resolve(e);
85 const { rows, error, isLoading } = await read($, view);
86 const prunable = rows.filter((row) => row.isPrunable).length;
87
88 return (
89 <Box flexDirection="column">
90 <Box flexDirection="row" gap={1}>
91 <Button
92 key="refresh"
93 hotkey="r"
94 label={isLoading ? "Loading" : "Refresh"}
95 onPress={() => refresh($)}
96 />
97 {prunable > 0 && (
98 <Button key="prune" hotkey="p" label={`Prune ${prunable}`} onPress={() => prune($)} />
99 )}
100 </Box>
101 {error !== null && <Text color="red">{error}</Text>}
102 {error === null && rows.length === 0 && (
103 <Text dimColor>{isLoading ? "Reading worktrees." : "No worktrees."}</Text>
104 )}
105 {rows.map((row) => (
106 <Button
107 key={`row-${row.path}`}
108 plain
109 onPress={async (press) => {
110 await $.ui.copy({ text: row.path, surface: press.surface });
111 $.ui.toast(`Copied ${row.path}`);
112 }}
113 >
114 {row.branch}{" "}
115 <Text dimColor>
116 {prLabel(row.pr)}
117 {row.isPrunable ? " · prunable" : ""} · {baseName(row.path)}
118 </Text>
119 </Button>
120 ))}
121 </Box>
122 );
123 });
124};
125hooks/base-name.ts 3 lines1/** The last segment of a path. */
2export const baseName = (path: string): string => path.split("/").filter(Boolean).pop() ?? path;
3hooks/pr-label.ts 6 lines1import type { Worktree } from "../types";
2
3/** A row's pull request as `#12 merged`, or `no PR`. */
4export const prLabel = (pr: Worktree["pr"]): string =>
5 pr === null ? "no PR" : `#${pr.number} ${pr.state.toLowerCase()}`;
6hooks/parse-prs.ts 19 lines1import type { Worktree } from "../types";
2
3/** The newest pull request per head branch, from `gh pr list --json headRefName,number,state`; empty when the JSON does not parse. */
4export const parsePrs = (json: string): Map<string, NonNullable<Worktree["pr"]>> => {
5 const byBranch = new Map<string, NonNullable<Worktree["pr"]>>();
6 try {
7 const list = JSON.parse(json) as { headRefName: string; number: number; state: string }[];
8 for (const pr of list) {
9 if (!byBranch.has(pr.headRefName)) {
10 byBranch.set(pr.headRefName, { number: pr.number, state: pr.state });
11 }
12 }
13 } catch {
14 // gh missing or not a GitHub repository: rows show no PR.
15 }
16
17 return byBranch;
18};
19hooks/parse-worktrees.ts 20 lines1import type { Worktree } from "../types";
2
3/** The worktrees `git worktree list --porcelain` lists, without their pull requests. */
4export const parseWorktrees = (porcelain: string): Omit<Worktree, "pr">[] =>
5 porcelain
6 .split("\n\n")
7 .map((block) => block.split("\n").filter((line) => line !== ""))
8 .filter((lines) => lines[0]?.startsWith("worktree ") === true)
9 .map((lines) => {
10 const field = (name: string) =>
11 lines.find((line) => line === name || line.startsWith(`${name} `));
12 const branch = field("branch")?.slice("branch refs/heads/".length);
13
14 return {
15 path: (field("worktree") ?? "").slice("worktree ".length),
16 branch: branch ?? (field("bare") === undefined ? "(detached)" : "(bare)"),
17 isPrunable: field("prunable") !== undefined,
18 };
19 });
20types/index.d.ts 19 lines1export type Worktree = {
2 path: string;
3 branch: string;
4 isPrunable: boolean;
5 pr: { number: number; state: string } | null;
6};
7
8export type WorktreesView = {
9 rows: Worktree[];
10 error: string | null;
11 isLoading: boolean;
12};
13
14declare module "claude-code" {
15 interface PluginState {
16 "rx-worktrees": { view: WorktreesView };
17 }
18}
19