SLOPSHOPPER

rx-worktrees

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.

newpanecommandtoastprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · rx-worktrees
│ ┃ Worktrees ✕ › fix the failing auth test and add an audit log call │ ┃ [ Refresh ] │ ┃ No worktrees. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /worktrees │ ⎿ rx-worktrees: Worktrees pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Worktrees
[ Refresh ] No worktrees.
README

<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.

Packages

PackagenpmWhat it is
@rxova/ts-utilsnpmSmall, dependency-free runtime helpers, inlined at build time, plus a /react entry.
@rxova/repo-confignpmThe rxova-repo-config bin (verify gate, changesets, scope, tarball and docs checks) and presets for ESLint, Prettier, tsconfig, Vitest and more.
@rxova/agent-kitnpm10 hooks, 51 skills and 25 agents for Claude Code and OpenCode, installed in profiles by rxova-agent-kit.
@rxova/docs-kitnpmMarkdown 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.

CI building blocks

Composite actions

- uses: rxova/shared/actions/setup-pnpm@main
ActionWhat it does
setup-pnpmpnpm at the packageManager version and Node, with the store cached.
turbo-cacheRestores and saves the local Turbo cache across runs.
turbo-remote-cachePoints Turbo at a remote cache backed by the Actions cache.
setup-playwrightResolves, caches and installs Playwright browsers.
pin-reactPins one React at the root and in the given packages, and proves only that one resolves.
require-jobsFails unless every job in needs passed or was skipped: the one check to require.
notify-websiteTells the rxova.dev website to publish this run's docs build.

Inputs and examples: actions/README.md.

Reusable workflows

jobs:
  commitlint:
    uses: rxova/shared/.github/workflows/commit-messages.yml@main
WorkflowWhat it does
commit-messages.ymlThe root CI job: lints commits and outputs code-changed, docs-only and docs-changed.
docs-checks.ymlThe light job a documentation-only range runs instead of the build and test matrix.
lint-pr-title.ymlLints the PR title, which becomes the squash subject.
changeset-gate.ymlRequires a changeset when a pull request changes a published package.
react-minimum-version.ymlBuilds, tests and typechecks against the oldest React a peer range allows.
node-floor-smoke.ymlInstalls each packed package on exactly the oldest Node its engines promises.
changesets-release.ymlThe version pull request, then publishing with npm trusted publishing and provenance.
snapshot-release.ymlPublishes 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.

Renovate preset

{ extends: ["github>rxova/shared//renovate/default.json5"] }

The org rules live in renovate/default.json5; a repository keeps only its own.

Using it in an rxova repo

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.

Developing this repo

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
  • Releases. A change to a published package needs a changeset (see .changeset/README.md). Once CI is green on main, Changesets opens a chore: version packages pull request; merging it publishes to npm with trusted publishing and provenance.
  • Docs-only pull requests. When a range changes only Markdown (and changesets), CI skips the build, test and package jobs and runs the light docs checks job instead; the docs site still builds when apps/docs changed.
  • Releasing a README-only change. A package README change needs no changeset, but npm shows the README of the published version. To ship it, add a patch changeset; the range stays docs-only:
  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.

Docs

Every README in the repository:

The documentation site's sources are in apps/docs; pnpm docs serves it locally.

License

MIT

Source 6 files
hooks/register.tsx 125 lines
1import { 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};
125
hooks/base-name.ts 3 lines
1/** The last segment of a path. */
2export const baseName = (path: string): string => path.split("/").filter(Boolean).pop() ?? path;
3
hooks/pr-label.ts 6 lines
1import 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()}`;
6
hooks/parse-prs.ts 19 lines
1import 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};
19
hooks/parse-worktrees.ts 20 lines
1import 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    });
20
types/index.d.ts 19 lines
1export 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