Isolated, branch-named workspaces for parallel work: the harness creates and removes them through wt, raw git worktree calls are refused with the wt command…

Isolated, branch-named workspaces for parallel agent work, built on wt (worktrunk), for the person who runs several agents on one project.
The harness's own worktree is a bare checkout: it carries only what git tracks, while a project also runs on the env file, the local docs and the generated config that never enter the repository. qsm-worktrunk replaces that default with wt, so a workspace an agent creates is a full working copy, branch-named and sharing the main checkout's task store, and the agent can run the project there without a human copying things across. It takes the stance that a workspace is a place to work in, never just a place to diff. It refuses to be a branching strategy, a merge tool, or a second git. When the harness creates worktrees that run out of the box, this plugin has no reason to exist.
| Kind | Name | What it does |
|---|---|---|
| Skill | worktrunk | Holds the conventions for using wt, and how to change or repair its configuration |
| Command | /qsm-worktrunk | Reports whether wt is installed and new enough |
| Hook | tool.call on Bash | Refuses raw git worktree and names the wt command instead |
| Hook | prompt.submit | Tells the agent which workspace it runs in, and sets the working marker |
| Hook | session.end | Clears the working marker |
| Hook | WorktreeCreate | Creates the workspace through wt and points it at the main checkout's task store |
| Hook | WorktreeRemove | Removes the workspace through wt |
| Hook | Notification | Sets the activity marker |
This plugin ships no CLI of its own. wt is not shipped here.
claude plugin install qsm-worktrunk@qsm-marketplace
bun plugins/qsm-worktrunk/scripts/install.ts
The install script compiles the three classic hooks. It links no binary. It reports wt when it is missing. Install wt first (sudo pacman -S worktrunk, brew install worktrunk or cargo install worktrunk), then run wt config shell install once and open a new shell.
Ask: "work on this in an isolated workspace" or "start a branch for this in parallel". The harness creates the workspace through wt. By hand:
wt switch --create <branch>
wt list
wt merge <branch>
wt remove <branch>
/qsm-worktrunk reports whether wt is installed and new enough.
wt on PATH the hooks give up quietly. Only workspace create and remove fail loudly: wt missing, older than 0.22.0, or switch without --create and --base. The usual cause is an older wt earlier on PATH.git worktree call built inside a command substitution or a script file.wt remove keeps a branch with unmerged commits.wt merge squashes unless you pass --no-squash.hooks/register.tsx 106 lines1// qsm-worktrunk's function-hooks module: the raw `git worktree` block, the workspace context on
2// every prompt, the activity marker, and the doctor command. WorktreeCreate, WorktreeRemove and
3// Notification have no function form, so they stay classic command hooks (hooks.json).
4import type { EngineInterface, On } from "claude-code";
5import { doctor } from "../lib/doctor.ts";
6import { denialFor } from "../lib/git-worktree-block.ts";
7import { gitReader, readWorkspace, workspaceContext } from "../lib/workspace.ts";
8import type { Runner } from "../lib/wt.ts";
9import { setMarker } from "../lib/wt.ts";
10
11type Engine = EngineInterface;
12
13function runner($: Engine): Runner {
14 return async (argv, cwd) => {
15 const result = await $.process.run(argv, cwd === undefined ? undefined : { cwd });
16 return { exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr };
17 };
18}
19
20function failure(error: { kind: string; message?: string }): string {
21 return `${error.kind}${error.message === undefined ? "" : `: ${error.message.slice(0, 120)}`}`;
22}
23
24function registerBlock(on: On): void {
25 on("tool.call", { tool: "Bash" }, (_$, e, next) => {
26 const denial = denialFor(e.command);
27 return denial === null ? next(e) : { deny: denial };
28 }).catch(($, _e, next) => {
29 const reason = failure(next.error);
30 $.ui.toast(`qsm-worktrunk ERROR: tool.call: ${reason}`);
31 $.ui.log(`the git worktree block failed (${next.error.kind}); the call was denied`);
32 return {
33 deny: `qsm-worktrunk: the git worktree block failed (${reason}); the call was not run`,
34 };
35 });
36}
37
38/** The workspace the prompt runs in, as the hidden context line the agent reads; null in the main
39 * checkout and outside a repository. */
40async function workspaceLine($: Engine): Promise<string | null> {
41 const workspace = await readWorkspace(gitReader(runner($), await $.session.cwd()));
42 return workspace === null ? null : workspaceContext(workspace, await $.env.get("HOME"));
43}
44
45function registerMarker(on: On): void {
46 // One prompt.submit handler: the engine takes a second unmatched one for the same event as a
47 // load error.
48 on("prompt.submit", async ($, e, next) => {
49 const line = await workspaceLine($);
50 await setMarker(runner($), "working");
51 return next(line === null ? e : { ...e, context: [...(e.context ?? []), line] });
52 }).catch(($, e, next) => {
53 $.ui.toast(`qsm-worktrunk ERROR: prompt.submit: ${failure(next.error)}`);
54 $.ui.log(`the working marker or workspace context failed (${next.error.kind})`);
55 return next(e);
56 });
57
58 on("session.end", async ($, e, next) => {
59 await setMarker(runner($), "cleared");
60 return next(e);
61 }).catch(($, e, next) => {
62 const reason = `${next.error.kind}${next.error.message === undefined ? "" : `: ${next.error.message.slice(0, 120)}`}`;
63 $.ui.toast(`qsm-worktrunk ERROR: session.end: ${reason}`);
64 $.ui.log(`clearing the marker failed (${next.error.kind})`);
65 return next(e);
66 });
67}
68
69function registerDoctor(on: On): void {
70 // Two halves, and neither works alone: `$.command.register` is what makes `/qsm-worktrunk` a
71 // command the engine knows about for this session, and the `command.run` hook below is what
72 // answers it.
73 on("session.start", async ($, e, next) => {
74 try {
75 await $.command.register({
76 name: "qsm-worktrunk",
77 description: "Check that wt is installed and able to create workspaces",
78 });
79 } catch (error) {
80 $.ui.log(`/qsm-worktrunk not registered: ${String(error).slice(0, 120)}`);
81 }
82 return next(e);
83 }).catch(($, e, next) => {
84 const reason = `${next.error.kind}${next.error.message === undefined ? "" : `: ${next.error.message.slice(0, 120)}`}`;
85 $.ui.toast(`qsm-worktrunk ERROR: session.start: ${reason}`);
86 $.ui.log(`registering /qsm-worktrunk failed (${next.error.kind})`);
87 return next(e);
88 });
89
90 on("command.run", { command: "qsm-worktrunk" }, async ($, _e, _next) => {
91 const report = await doctor(runner($));
92 return { text: report.text };
93 }).catch(($, _e, next) => {
94 const reason = `${next.error.kind}${next.error.message === undefined ? "" : `: ${next.error.message.slice(0, 120)}`}`;
95 $.ui.toast(`qsm-worktrunk ERROR: command.run: ${reason}`);
96 $.ui.log(`the doctor check failed (${next.error.kind})`);
97 return { text: "qsm-worktrunk: the doctor check could not run" };
98 });
99}
100
101export function register(on: On): void {
102 registerBlock(on);
103 registerMarker(on);
104 registerDoctor(on);
105}
106lib/doctor.ts 69 lines1// The binary check, in one place, because two surfaces ask it: the installer, which refuses to call
2// itself installed without it, and the slash command, which an operator runs when something has
3// stopped working.
4
5import {
6 MINIMUM_WT_VERSION,
7 meetsMinimum,
8 REQUIRED_SWITCH_FLAGS,
9 type Runner,
10 supportsWorkspaceCreate,
11 wtPath,
12 wtVersion,
13} from "./wt.ts";
14
15export const INSTALL_HINT =
16 "wt ships as a system package: `pacman -S worktrunk`, `brew install worktrunk`, or `cargo install worktrunk`. Then run `wt config shell install` once.";
17
18export const UPGRADE_HINT =
19 "Upgrade it through the package manager that put it there, or move a newer wt earlier on PATH.";
20
21/** What the three probes answered, so the report reads without a machine in
22 * any of the states it describes. */
23export interface Probe {
24 /** The line `wt --version` printed, or null when nothing answered. */
25 readonly version: string | null;
26 /** Whether `wt switch` takes the flags the create path passes. */
27 readonly createReady: boolean;
28 /** Which binary PATH resolved, when that could be read. */
29 readonly path: string | null;
30}
31
32export interface DoctorReport {
33 readonly healthy: boolean;
34 readonly text: string;
35}
36
37export function reportFor(probe: Probe): DoctorReport {
38 if (probe.version === null) {
39 return { healthy: false, text: `qsm-worktrunk: wt is not on PATH. ${INSTALL_HINT}` };
40 }
41 const found = `${probe.version}${probe.path === null ? "" : ` at ${probe.path}`}`;
42 if (!meetsMinimum(probe.version)) {
43 return {
44 healthy: false,
45 text: `qsm-worktrunk: ${found} is too old; this plugin needs wt ${MINIMUM_WT_VERSION} or newer. ${UPGRADE_HINT}`,
46 };
47 }
48 if (!probe.createReady) {
49 const flags = REQUIRED_SWITCH_FLAGS.join(" and ");
50 return {
51 healthy: false,
52 text: `qsm-worktrunk: ${found} cannot create a workspace. Its \`wt switch\` does not take ${flags}, the flags this plugin creates a workspace with, so every workspace request would fail. ${UPGRADE_HINT}`,
53 };
54 }
55 return { healthy: true, text: `qsm-worktrunk: healthy, ${found} on PATH.` };
56}
57
58export async function doctor(run: Runner, cwd?: string): Promise<DoctorReport> {
59 const version = await wtVersion(run, cwd);
60 if (version === null) {
61 return reportFor({ version: null, createReady: false, path: null });
62 }
63 return reportFor({
64 version,
65 createReady: await supportsWorkspaceCreate(run, cwd),
66 path: await wtPath(run, cwd),
67 });
68}
69lib/git-worktree-block.ts 143 lines1// Reads a shell command the way the block rule needs to read it: is any part of this line a `git
2// worktree` subcommand, and which one.
3
4const SUBCOMMAND_REPLACEMENT: Record<string, string> = {
5 add: "`wt switch --create <branch>` creates the workspace and its branch",
6 list: "`wt list` prints every workspace with its branch and its divergence",
7 remove: "`wt remove <branch>` deletes the workspace and the branch together",
8 prune: "`wt list` is the state; wt tracks its workspaces, so there is nothing to prune",
9 move: "`wt switch <branch>` moves between workspaces by name, never by path",
10 lock: "`wt config` holds workspace state",
11 unlock: "`wt config` holds workspace state",
12 repair: "`wt list` then `wt switch <branch>`; wt owns the paths",
13};
14
15const GENERIC_REPLACEMENT = "`wt --help` lists the equivalent; every subcommand has one";
16
17/** Options git takes before its subcommand that consume the next word. */
18const VALUE_OPTIONS = new Set([
19 "-C",
20 "-c",
21 "--git-dir",
22 "--work-tree",
23 "--namespace",
24 "--exec-path",
25]);
26
27/** Words that wrap a command without changing what it is. */
28const WRAPPERS = new Set(["sudo", "command", "nohup", "time", "env"]);
29
30const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
31
32/** The denial for a command that reaches for raw git worktree, or null. */
33export function denialFor(command: string): string | null {
34 for (const segment of segments(command)) {
35 const subcommand = worktreeSubcommandOf(segment);
36 if (subcommand !== null) {
37 return denial(subcommand);
38 }
39 }
40 return null;
41}
42
43function denial(subcommand: string): string {
44 const replacement = SUBCOMMAND_REPLACEMENT[subcommand] ?? GENERIC_REPLACEMENT;
45 const named = subcommand.length > 0 ? `git worktree ${subcommand}` : "git worktree";
46 return `qsm-worktrunk: ${named} is blocked. Use wt instead: ${replacement}. wt addresses a workspace by branch name and computes its path; raw git leaves the path yours to track.`;
47}
48
49/** The `git worktree` subcommand this one command runs, "" for a bare
50 * `git worktree`, or null when the command is not one. */
51function worktreeSubcommandOf(tokens: readonly string[]): string | null {
52 const rest = afterGit(tokens);
53 if (rest === null) {
54 return null;
55 }
56 const [first, ...tail] = rest;
57 if (first !== "worktree") {
58 return null;
59 }
60 return tail.find((token) => !token.startsWith("-")) ?? "";
61}
62
63/** The words after `git` and its own options, or null when the command does
64 * not run git. */
65function afterGit(tokens: readonly string[]): string[] | null {
66 const start = skipWrappers(tokens);
67 const executable = tokens[start];
68 if (executable === undefined || basename(executable) !== "git") {
69 return null;
70 }
71 return tokens.slice(skipGitOptions(tokens, start + 1));
72}
73
74/** The index of the first word that is not an assignment or a wrapper. */
75function skipWrappers(tokens: readonly string[]): number {
76 let index = 0;
77 while (index < tokens.length) {
78 const token = tokens[index] ?? "";
79 if (ASSIGNMENT.test(token) || WRAPPERS.has(token)) {
80 index += 1;
81 continue;
82 }
83 break;
84 }
85 return index;
86}
87
88/** The index of the first word at or after `from` that is not a git option. */
89function skipGitOptions(tokens: readonly string[], from: number): number {
90 let index = from;
91 while (index < tokens.length) {
92 const token = tokens[index] ?? "";
93 if (!token.startsWith("-")) {
94 break;
95 }
96 index += VALUE_OPTIONS.has(token) ? 2 : 1;
97 }
98 return index;
99}
100
101function basename(path: string): string {
102 return path.split("/").pop() ?? path;
103}
104
105/** The command line split into the commands it runs, each already tokenised.
106 * Quotes are honoured, so `echo "git worktree add"` is one word, not three. */
107function segments(command: string): string[][] {
108 const all: string[][] = [];
109 let current: string[] = [];
110 for (const [piece = ""] of command.matchAll(SCANNER)) {
111 if (SEPARATORS.has(piece)) {
112 if (current.length > 0) {
113 all.push(current);
114 current = [];
115 }
116 continue;
117 }
118 current.push(unquote(piece));
119 }
120 if (current.length > 0) {
121 all.push(current);
122 }
123 return all;
124}
125
126/** One separator between commands, or one word: a run of bare characters,
127 * escapes, and quoted spans. Anything else (a stray quote) is skipped. */
128const SCANNER = /[;|&\n]|(?:[^\s;|&\n'"\\]|\\.|'[^']*'|"[^"]*")+/g;
129const SEPARATORS = new Set([";", "|", "&", "\n"]);
130const QUOTED = /\\(.)|'([^']*)'|"([^"]*)"/g;
131
132function unquote(word: string): string {
133 return word.replace(
134 QUOTED,
135 (
136 _match: string,
137 escaped: string | undefined,
138 single: string | undefined,
139 double: string | undefined,
140 ) => escaped ?? single ?? double ?? "",
141 );
142}
143lib/workspace.ts 97 lines1// Which workspace is this, told by git itself rather than by a path the session remembers.
2
3import type { Runner } from "./wt.ts";
4
5export interface Workspace {
6 readonly name: string;
7 readonly path: string;
8 readonly branch: string;
9}
10
11/** Runs one git command and resolves its trimmed output, or null when git
12 * refused: a caller reads "no answer" instead of an exit code. */
13export type GitReader = (args: readonly string[]) => Promise<string | null>;
14
15export function gitReader(run: Runner, cwd: string): GitReader {
16 return async (args) => {
17 let result: Awaited<ReturnType<Runner>>;
18 try {
19 result = await run(["git", ...args], cwd);
20 } catch {
21 return null;
22 }
23 return result.exitCode === 0 ? result.stdout.trim() : null;
24 };
25}
26
27const DIRS_ARGS = ["rev-parse", "--path-format=absolute", "--git-dir", "--git-common-dir"] as const;
28
29/** The workspace the reader's directory sits in, or null for the main
30 * checkout and for anywhere outside a repository. */
31export async function readWorkspace(git: GitReader): Promise<Workspace | null> {
32 if ((await git(["rev-parse", "--is-inside-work-tree"])) !== "true") {
33 return null;
34 }
35 const dirs = await git(DIRS_ARGS);
36 const [gitDir, commonDir] = (dirs ?? "").split("\n").map((line) => line.trim());
37 if (gitDir === undefined || commonDir === undefined || gitDir.length === 0) {
38 return null;
39 }
40 if (gitDir === commonDir) {
41 return null;
42 }
43 const path = await git(["rev-parse", "--show-toplevel"]);
44 if (path === null) {
45 return null;
46 }
47 return { name: lastSegment(gitDir), path, branch: await readBranch(git) };
48}
49
50/** The main checkout's root, read from the common git directory, or null
51 * outside a repository. Every workspace of a repository answers the same. */
52export async function readMainCheckout(git: GitReader): Promise<string | null> {
53 const dirs = await git(DIRS_ARGS);
54 const commonDir = (dirs ?? "").split("\n")[1]?.trim() ?? "";
55 if (commonDir.length === 0) {
56 return null;
57 }
58 return parentOf(commonDir);
59}
60
61async function readBranch(git: GitReader): Promise<string> {
62 const branch = await git(["symbolic-ref", "--short", "HEAD"]);
63 if (branch !== null && branch.length > 0) {
64 return branch;
65 }
66 const head = await git(["rev-parse", "--short", "HEAD"]);
67 return `detached at ${head ?? "an unknown commit"}`;
68}
69
70/** What the turn carries: the three facts the agent would otherwise ask for. */
71export function workspaceContext(workspace: Workspace, home?: string): string {
72 return [
73 "---",
74 `workspace: ${workspace.name}`,
75 `path: ${shorten(workspace.path, home)}`,
76 `branch: ${workspace.branch}`,
77 "---",
78 ].join("\n");
79}
80
81function shorten(path: string, home?: string): string {
82 if (home === undefined || home.length === 0 || !path.startsWith(home)) {
83 return path;
84 }
85 return `~${path.slice(home.length)}`;
86}
87
88function lastSegment(path: string): string {
89 return path.replace(/\/+$/, "").split("/").pop() ?? path;
90}
91
92function parentOf(path: string): string {
93 const trimmed = path.replace(/\/+$/, "");
94 const cut = trimmed.lastIndexOf("/");
95 return cut > 0 ? trimmed.slice(0, cut) : "/";
96}
97lib/wt.ts 275 lines1// The one place that knows how this plugin calls `wt`: the argument vectors, and the things it asks
2// for.
3
4export interface CommandResult {
5 readonly exitCode: number;
6 readonly stdout: string;
7 readonly stderr: string;
8}
9
10/** Runs a command by its argument vector and resolves once it exits. */
11export type Runner = (argv: readonly string[], cwd?: string) => Promise<CommandResult>;
12
13/** What the session is doing, as a shell prompt or a status line reads it. */
14export type MarkerState = "working" | "waiting" | "cleared";
15
16const MARKER_GLYPH: Record<"waiting" | "working", string> = {
17 working: "\u{1F916}",
18 waiting: "\u{1F4AC}",
19};
20
21export function markerArgv(state: MarkerState): string[] {
22 return state === "cleared"
23 ? ["wt", "config", "state", "marker", "clear"]
24 : ["wt", "config", "state", "marker", "set", MARKER_GLYPH[state]];
25}
26
27/**
28 * Create the workspace for a new branch off `base` (the caller's current branch), or move to the
29 * one that exists.
30 */
31export function createWorkspaceArgv(branch: string, base: string | null): string[] {
32 return ["wt", "switch", "--create", branch, ...(base === null ? [] : ["--base", base])];
33}
34
35/**
36 * Remove a workspace, with no confirmation and no background step left running after the call
37 * returns.
38 */
39export function removeWorkspaceArgv(branch: string): string[] {
40 return ["wt", "remove", "--foreground", branch];
41}
42
43function versionArgv(): string[] {
44 return ["wt", "--version"];
45}
46
47/** The flags `createWorkspaceArgv` passes. */
48export const REQUIRED_SWITCH_FLAGS = ["--create", "--base"] as const;
49
50/**
51 * The oldest wt whose `switch` is known to take the flags above and to put the worktree where `git
52 * worktree list` reports it.
53 */
54export const MINIMUM_WT_VERSION = "0.22.0";
55
56function switchHelpArgv(): string[] {
57 return ["wt", "switch", "--help"];
58}
59
60/** Asks the shell which `wt` PATH resolves to.
61 * differs: hooks sandbox (the hooks module reaches this through doctor.ts, and the sandbox has
62 * no root lib onPath and no Bun.which) */
63function resolveArgv(): string[] {
64 return ["sh", "-c", "command -v wt"];
65}
66
67function worktreeListArgv(): string[] {
68 return ["git", "worktree", "list", "--porcelain"];
69}
70
71function currentBranchArgv(): string[] {
72 return ["git", "symbolic-ref", "--short", "HEAD"];
73}
74
75export interface Worktree {
76 readonly path: string;
77 /** the branch checked out, or null when detached */
78 readonly branch: string | null;
79}
80
81const WORKTREE_LINE = "worktree ";
82const BRANCH_LINE = "branch refs/heads/";
83
84/**
85 * `git worktree list --porcelain`: stanzas separated by a blank line, each `worktree <path>`
86 * first, then `HEAD <sha>`, then `branch refs/heads/<name>` or `detached`.
87 */
88export function parseWorktreeList(porcelain: string): Worktree[] {
89 const out: Worktree[] = [];
90 let current: { path: string; branch: string | null } | null = null;
91 for (const raw of porcelain.split("\n")) {
92 const line = raw.trim();
93 if (line.startsWith(WORKTREE_LINE)) {
94 current = { path: line.slice(WORKTREE_LINE.length), branch: null };
95 out.push(current);
96 } else if (line.startsWith(BRANCH_LINE) && current !== null) {
97 current.branch = line.slice(BRANCH_LINE.length);
98 }
99 }
100 return out;
101}
102
103export function worktreePathOf(list: readonly Worktree[], branch: string): string | null {
104 return list.find((w) => w.branch === branch)?.path ?? null;
105}
106
107export function worktreeBranchOf(list: readonly Worktree[], path: string): string | null {
108 const wanted = path.replace(/\/+$/, "");
109 return list.find((w) => w.path.replace(/\/+$/, "") === wanted)?.branch ?? null;
110}
111
112function lastLineOf(result: CommandResult | null): string {
113 if (result === null) {
114 return "";
115 }
116 const lines = `${result.stderr}\n${result.stdout}`.split("\n").map((l) => l.trim());
117 return lines.findLast((l) => l.length > 0) ?? "";
118}
119
120function describeFailure(
121 argv: readonly string[],
122 result: CommandResult | null,
123 why?: string,
124): string {
125 const command = argv.join(" ");
126 if (why !== undefined) {
127 return `${command}: ${why}`;
128 }
129 if (result === null) {
130 return `${command}: could not start`;
131 }
132 const last = lastLineOf(result);
133 return last.length === 0
134 ? `${command}: exit ${result.exitCode}`
135 : `${command}: exit ${result.exitCode}: ${last}`;
136}
137
138/** A wt or git call that did not do what was asked, with the command and the
139 * last line it printed, so the harness's error names the cause. */
140export class WtError extends Error {
141 constructor(
142 argv: readonly string[],
143 result: CommandResult | null,
144 why?: string,
145 cause?: unknown,
146 ) {
147 super(describeFailure(argv, result, why), { cause });
148 this.name = "WtError";
149 }
150}
151
152async function mustRun(run: Runner, argv: readonly string[], cwd?: string): Promise<CommandResult> {
153 let result: CommandResult | null = null;
154 let failure: unknown;
155 try {
156 result = await run(argv, cwd);
157 } catch (error) {
158 failure = error;
159 }
160 if (result === null) {
161 throw new WtError(argv, null, undefined, failure);
162 }
163 if (result.exitCode !== 0) {
164 throw new WtError(argv, result);
165 }
166 return result;
167}
168
169export async function setMarker(run: Runner, state: MarkerState, cwd?: string): Promise<boolean> {
170 const result = await run(markerArgv(state), cwd);
171 return result.exitCode === 0;
172}
173
174async function currentBranch(run: Runner, cwd?: string): Promise<string | null> {
175 const result = await tryRun(run, currentBranchArgv(), cwd);
176 const name = result?.stdout.trim() ?? "";
177 return result?.exitCode === 0 && name.length > 0 ? name : null;
178}
179
180async function listWorktrees(run: Runner, cwd?: string): Promise<Worktree[]> {
181 return parseWorktreeList((await mustRun(run, worktreeListArgv(), cwd)).stdout);
182}
183
184/**
185 * Creates the workspace for `branch` through wt, off the caller's current branch, and returns the
186 * path git lists for it.
187 */
188export async function createWorkspace(run: Runner, branch: string, cwd?: string): Promise<string> {
189 const base = await currentBranch(run, cwd);
190 const argv = createWorkspaceArgv(branch, base);
191 await mustRun(run, argv, cwd);
192 const path = worktreePathOf(await listWorktrees(run, cwd), branch);
193 if (path === null) {
194 throw new WtError(argv, null, `wt exited 0 but git lists no worktree on branch ${branch}`);
195 }
196 return path;
197}
198
199/** Removes the workspace at `path` through wt, and its branch when nothing on it is unmerged. */
200export async function removeWorkspace(run: Runner, path: string, cwd?: string): Promise<void> {
201 const branch = worktreeBranchOf(await listWorktrees(run, cwd), path);
202 if (branch === null) {
203 throw new WtError(worktreeListArgv(), null, `no worktree with a branch at ${path}`);
204 }
205 await mustRun(run, removeWorkspaceArgv(branch), cwd);
206}
207
208/** Runs a command and answers null instead of throwing, because every caller
209 * here treats "could not ask" the same as "answered no". */
210async function tryRun(
211 run: Runner,
212 argv: readonly string[],
213 cwd?: string,
214): Promise<CommandResult | null> {
215 try {
216 return await run(argv, cwd);
217 } catch {
218 return null;
219 }
220}
221
222/** The version string `wt` prints, or null when it is not on PATH or refuses
223 * to report one. Standard error counts: some builds print the version there. */
224export async function wtVersion(run: Runner, cwd?: string): Promise<string | null> {
225 const result = await tryRun(run, versionArgv(), cwd);
226 if (result === null || result.exitCode !== 0) {
227 return null;
228 }
229 const printed = result.stdout.trim().length > 0 ? result.stdout : result.stderr;
230 const line = printed.split("\n")[0]?.trim() ?? "";
231 return line.length > 0 ? line : null;
232}
233
234/** "wt 0.22.0" -> [0, 22, 0]; null when no dotted number is in the line. */
235export function parseVersion(line: string): number[] | null {
236 const [, major, minor, patch] = /(\d+)\.(\d+)(?:\.(\d+))?/.exec(line) ?? [];
237 return major === undefined ? null : [Number(major), Number(minor), Number(patch ?? "0")];
238}
239
240export function meetsMinimum(line: string, minimum: string = MINIMUM_WT_VERSION): boolean {
241 const have = parseVersion(line);
242 const want = parseVersion(minimum);
243 if (have === null || want === null) {
244 return false;
245 }
246 for (let i = 0; i < 3; i += 1) {
247 const [h, w] = [have[i] ?? 0, want[i] ?? 0];
248 if (h !== w) {
249 return h > w;
250 }
251 }
252 return true;
253}
254
255/** True when this `wt`'s own help for `switch` lists every flag the create path passes. */
256export async function supportsWorkspaceCreate(run: Runner, cwd?: string): Promise<boolean> {
257 const result = await tryRun(run, switchHelpArgv(), cwd);
258 if (result === null || result.exitCode !== 0) {
259 return false;
260 }
261 const help = `${result.stdout}\n${result.stderr}`;
262 return REQUIRED_SWITCH_FLAGS.every((flag) => help.includes(flag));
263}
264
265/** Where `wt` resolves on PATH, or null when the question could not be asked.
266 * Used only to name the binary in a report, never to call it. */
267export async function wtPath(run: Runner, cwd?: string): Promise<string | null> {
268 const result = await tryRun(run, resolveArgv(), cwd);
269 if (result === null || result.exitCode !== 0) {
270 return null;
271 }
272 const line = result.stdout.split("\n")[0]?.trim() ?? "";
273 return line.length > 0 ? line : null;
274}
275