SLOPSHOPPER

qsm-worktrunk

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…

newguardcommandtoastpromptprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · qsm-worktrunk
› fix the failing auth test and add an audit log call ⏺ 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 › /qsm-worktrunk ⎿ qsm-worktrunk: qsm-worktrunk: wt is not on PATH. wt ships as a system package: `pacman -S worktrunk`, `brew install worktrunk ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

qsm-worktrunk

Isolated, branch-named workspaces for parallel agent work, built on wt (worktrunk), for the person who runs several agents on one project.

Why it exists

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.

What you get

KindNameWhat it does
SkillworktrunkHolds the conventions for using wt, and how to change or repair its configuration
Command/qsm-worktrunkReports whether wt is installed and new enough
Hooktool.call on BashRefuses raw git worktree and names the wt command instead
Hookprompt.submitTells the agent which workspace it runs in, and sets the working marker
Hooksession.endClears the working marker
HookWorktreeCreateCreates the workspace through wt and points it at the main checkout's task store
HookWorktreeRemoveRemoves the workspace through wt
HookNotificationSets the activity marker

This plugin ships no CLI of its own. wt is not shipped here.

Install

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.

Use

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.

Caveats

  • Without 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.
  • The block misses a git worktree call built inside a command substitution or a script file.
  • The task store is shared. A task closed in a workspace is closed everywhere, and it stays closed after the workspace is deleted.
  • Removing a workspace deletes its branch and does not ask. wt remove keeps a branch with unmerged commits.
  • wt merge squashes unless you pass --no-squash.
Source 5 files
hooks/register.tsx 106 lines
1// 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}
106
lib/doctor.ts 69 lines
1// 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}
69
lib/git-worktree-block.ts 143 lines
1// 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}
143
lib/workspace.ts 97 lines
1// 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}
97
lib/wt.ts 275 lines
1// 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