@mcuste/pi-herdr-worktree 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +137 -0
- package/dist/argument.d.ts +54 -0
- package/dist/argument.d.ts.map +1 -0
- package/dist/argument.js +101 -0
- package/dist/argument.js.map +1 -0
- package/dist/capability.d.ts +30 -0
- package/dist/capability.d.ts.map +1 -0
- package/dist/capability.js +154 -0
- package/dist/capability.js.map +1 -0
- package/dist/guidance.d.ts +22 -0
- package/dist/guidance.d.ts.map +1 -0
- package/dist/guidance.js +20 -0
- package/dist/guidance.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/process.d.ts +33 -0
- package/dist/process.d.ts.map +1 -0
- package/dist/process.js +91 -0
- package/dist/process.js.map +1 -0
- package/dist/response.d.ts +52 -0
- package/dist/response.d.ts.map +1 -0
- package/dist/response.js +129 -0
- package/dist/response.js.map +1 -0
- package/dist/tools.d.ts +79 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +555 -0
- package/dist/tools.js.map +1 -0
- package/dist/worktree.d.ts +16 -0
- package/dist/worktree.d.ts.map +1 -0
- package/dist/worktree.js +47 -0
- package/dist/worktree.js.map +1 -0
- package/package.json +89 -0
- package/src/argument.ts +183 -0
- package/src/capability.ts +223 -0
- package/src/guidance.ts +51 -0
- package/src/index.ts +5 -0
- package/src/process.ts +144 -0
- package/src/response.ts +189 -0
- package/src/tools.ts +952 -0
- package/src/worktree.ts +69 -0
package/src/tools.ts
ADDED
|
@@ -0,0 +1,952 @@
|
|
|
1
|
+
import type { Stats } from "node:fs";
|
|
2
|
+
import { readdir, stat } from "node:fs/promises";
|
|
3
|
+
import { isAbsolute, relative } from "node:path";
|
|
4
|
+
import { type Static, type TSchema, Type } from "typebox";
|
|
5
|
+
import {
|
|
6
|
+
hasParsedArgumentSyntax,
|
|
7
|
+
MAX_BRANCH_NAME_LENGTH,
|
|
8
|
+
MAX_LABEL_LENGTH,
|
|
9
|
+
MAX_PATH_LENGTH,
|
|
10
|
+
MAX_WORKSPACE_ID_LENGTH,
|
|
11
|
+
MIN_WORKSPACE_ID_LENGTH,
|
|
12
|
+
type ParsedAbsolutePath,
|
|
13
|
+
type ParsedArgumentValue,
|
|
14
|
+
type ParsedBase,
|
|
15
|
+
type ParsedBranchName,
|
|
16
|
+
type ParsedLabel,
|
|
17
|
+
type ParsedRevision,
|
|
18
|
+
type ParsedWorkspaceId,
|
|
19
|
+
parseAbsolutePath,
|
|
20
|
+
parseBase,
|
|
21
|
+
parseBranchName,
|
|
22
|
+
parseLabel,
|
|
23
|
+
parseWorkspaceId,
|
|
24
|
+
} from "./argument.js";
|
|
25
|
+
import {
|
|
26
|
+
type GitRepository,
|
|
27
|
+
type HerdrCapability,
|
|
28
|
+
HerdrCapabilityResolver,
|
|
29
|
+
HerdrUnavailableError,
|
|
30
|
+
} from "./capability.js";
|
|
31
|
+
import { type HerdrPromptApi, registerHerdrGuidance } from "./guidance.js";
|
|
32
|
+
import {
|
|
33
|
+
CommandCancelledError,
|
|
34
|
+
CommandInvocationError,
|
|
35
|
+
type CommandResult,
|
|
36
|
+
type CommandRunner,
|
|
37
|
+
runCommand,
|
|
38
|
+
} from "./process.js";
|
|
39
|
+
import {
|
|
40
|
+
parseEnvelope,
|
|
41
|
+
parseWorktreeList,
|
|
42
|
+
parseWorktreeOpened,
|
|
43
|
+
parseWorktreeRemoved,
|
|
44
|
+
type WorktreeInfo,
|
|
45
|
+
type WorktreeListResult,
|
|
46
|
+
type WorktreeOpenedResult,
|
|
47
|
+
type WorktreeRemovedResult,
|
|
48
|
+
} from "./response.js";
|
|
49
|
+
import { findGitWorktree, type GitWorktreeEntry, readGitWorktrees } from "./worktree.js";
|
|
50
|
+
|
|
51
|
+
const BranchName = Type.String({
|
|
52
|
+
minLength: 1,
|
|
53
|
+
maxLength: MAX_BRANCH_NAME_LENGTH,
|
|
54
|
+
description: "Exact branch name. Leading options are rejected.",
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const CheckoutPath = Type.String({
|
|
58
|
+
minLength: 1,
|
|
59
|
+
maxLength: MAX_PATH_LENGTH,
|
|
60
|
+
description: "Absolute path of the worktree checkout.",
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const WorkspaceLabel = Type.String({
|
|
64
|
+
minLength: 1,
|
|
65
|
+
maxLength: MAX_LABEL_LENGTH,
|
|
66
|
+
description: "Single-line label shown for the workspace in the Herdr sidebar.",
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const Focus = Type.Boolean({
|
|
70
|
+
description: "Move the user's view to the new workspace. Defaults to false.",
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const HerdrWorktreeParameters = Type.Union([
|
|
74
|
+
Type.Object({ operation: Type.Literal("list") }, { additionalProperties: false }),
|
|
75
|
+
Type.Object(
|
|
76
|
+
{
|
|
77
|
+
operation: Type.Literal("create"),
|
|
78
|
+
branch: BranchName,
|
|
79
|
+
base: Type.Optional(
|
|
80
|
+
Type.String({
|
|
81
|
+
minLength: 1,
|
|
82
|
+
maxLength: MAX_BRANCH_NAME_LENGTH,
|
|
83
|
+
description: "Existing commit, branch, or tag the new branch starts from.",
|
|
84
|
+
}),
|
|
85
|
+
),
|
|
86
|
+
path: Type.Optional(CheckoutPath),
|
|
87
|
+
label: Type.Optional(WorkspaceLabel),
|
|
88
|
+
focus: Type.Optional(Focus),
|
|
89
|
+
},
|
|
90
|
+
{ additionalProperties: false },
|
|
91
|
+
),
|
|
92
|
+
Type.Object(
|
|
93
|
+
{
|
|
94
|
+
operation: Type.Literal("open"),
|
|
95
|
+
path: Type.Optional(CheckoutPath),
|
|
96
|
+
branch: Type.Optional(BranchName),
|
|
97
|
+
label: Type.Optional(WorkspaceLabel),
|
|
98
|
+
focus: Type.Optional(Focus),
|
|
99
|
+
},
|
|
100
|
+
{ additionalProperties: false },
|
|
101
|
+
),
|
|
102
|
+
Type.Object(
|
|
103
|
+
{
|
|
104
|
+
operation: Type.Literal("remove"),
|
|
105
|
+
workspace_id: Type.String({
|
|
106
|
+
minLength: MIN_WORKSPACE_ID_LENGTH,
|
|
107
|
+
maxLength: MAX_WORKSPACE_ID_LENGTH,
|
|
108
|
+
description: "Herdr workspace id of the worktree workspace to remove, such as `wG`.",
|
|
109
|
+
}),
|
|
110
|
+
force: Type.Optional(
|
|
111
|
+
Type.Boolean({
|
|
112
|
+
description: "Remove the checkout even when it holds uncommitted changes.",
|
|
113
|
+
}),
|
|
114
|
+
),
|
|
115
|
+
},
|
|
116
|
+
{ additionalProperties: false },
|
|
117
|
+
),
|
|
118
|
+
]);
|
|
119
|
+
|
|
120
|
+
type HerdrWorktreeParameters = Static<typeof HerdrWorktreeParameters>;
|
|
121
|
+
type HerdrWorktreeOperation = HerdrWorktreeParameters["operation"];
|
|
122
|
+
|
|
123
|
+
/** The fields one operation accepts, typed against the schema so the two cannot drift. */
|
|
124
|
+
type FieldsOf<TOperation extends HerdrWorktreeOperation> = keyof Extract<
|
|
125
|
+
HerdrWorktreeParameters,
|
|
126
|
+
{ operation: TOperation }
|
|
127
|
+
> &
|
|
128
|
+
string;
|
|
129
|
+
|
|
130
|
+
interface RequestSchema<TOperation extends HerdrWorktreeOperation> {
|
|
131
|
+
readonly required: readonly FieldsOf<TOperation>[];
|
|
132
|
+
readonly optional: readonly FieldsOf<TOperation>[];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export const OPERATION_FIELDS: { readonly [K in HerdrWorktreeOperation]: RequestSchema<K> } = {
|
|
136
|
+
list: { required: ["operation"], optional: [] },
|
|
137
|
+
create: { required: ["operation", "branch"], optional: ["base", "path", "label", "focus"] },
|
|
138
|
+
open: { required: ["operation"], optional: ["path", "branch", "label", "focus"] },
|
|
139
|
+
remove: { required: ["operation", "workspace_id"], optional: ["force"] },
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
type ParsedOperation =
|
|
143
|
+
| { readonly operation: "list" }
|
|
144
|
+
| {
|
|
145
|
+
readonly operation: "create";
|
|
146
|
+
readonly branch: ParsedBranchName;
|
|
147
|
+
readonly base: ParsedBase | undefined;
|
|
148
|
+
readonly path: ParsedAbsolutePath | undefined;
|
|
149
|
+
readonly label: ParsedLabel | undefined;
|
|
150
|
+
readonly focus: boolean;
|
|
151
|
+
}
|
|
152
|
+
| {
|
|
153
|
+
readonly operation: "open";
|
|
154
|
+
readonly path: ParsedAbsolutePath | undefined;
|
|
155
|
+
readonly branch: ParsedBranchName | undefined;
|
|
156
|
+
readonly label: ParsedLabel | undefined;
|
|
157
|
+
readonly focus: boolean;
|
|
158
|
+
}
|
|
159
|
+
| {
|
|
160
|
+
readonly operation: "remove";
|
|
161
|
+
readonly workspaceId: ParsedWorkspaceId;
|
|
162
|
+
readonly force: boolean;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
/** The only `herdr` subcommand this extension is allowed to spawn. */
|
|
166
|
+
const HERDR_SUBCOMMAND = "worktree";
|
|
167
|
+
|
|
168
|
+
type HerdrAction = "list" | "create" | "open" | "remove";
|
|
169
|
+
|
|
170
|
+
type HerdrSwitch = "--focus" | "--no-focus" | "--force";
|
|
171
|
+
|
|
172
|
+
/** An option and its value, so a value can only travel under the option it was parsed for. */
|
|
173
|
+
type HerdrOption =
|
|
174
|
+
| { readonly name: "--cwd"; readonly value: ParsedAbsolutePath }
|
|
175
|
+
| { readonly name: "--branch"; readonly value: ParsedBranchName }
|
|
176
|
+
| { readonly name: "--base"; readonly value: ParsedRevision }
|
|
177
|
+
| { readonly name: "--path"; readonly value: ParsedAbsolutePath }
|
|
178
|
+
| { readonly name: "--label"; readonly value: ParsedLabel }
|
|
179
|
+
| { readonly name: "--workspace"; readonly value: ParsedWorkspaceId };
|
|
180
|
+
|
|
181
|
+
interface HerdrCommand {
|
|
182
|
+
readonly action: HerdrAction;
|
|
183
|
+
readonly options: readonly HerdrOption[];
|
|
184
|
+
readonly switches: readonly HerdrSwitch[];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const ALLOWED_OPTIONS: Record<HerdrAction, readonly HerdrOption["name"][]> = {
|
|
188
|
+
list: ["--cwd"],
|
|
189
|
+
create: ["--cwd", "--branch", "--base", "--path", "--label"],
|
|
190
|
+
open: ["--cwd", "--branch", "--path", "--label"],
|
|
191
|
+
remove: ["--workspace"],
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const ALLOWED_SWITCHES: Record<HerdrAction, readonly HerdrSwitch[]> = {
|
|
195
|
+
list: [],
|
|
196
|
+
create: ["--focus", "--no-focus"],
|
|
197
|
+
open: ["--focus", "--no-focus"],
|
|
198
|
+
remove: ["--force"],
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
interface TextContent {
|
|
202
|
+
readonly type: "text";
|
|
203
|
+
readonly text: string;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
interface ToolResult<TDetails> {
|
|
207
|
+
readonly content: readonly TextContent[];
|
|
208
|
+
readonly details: TDetails;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
interface ToolContext {
|
|
212
|
+
readonly cwd: string;
|
|
213
|
+
readonly ui?: {
|
|
214
|
+
confirm(title: string, message: string): Promise<boolean>;
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
type ToolTier = "read" | "write" | "exec";
|
|
219
|
+
|
|
220
|
+
type ToolApprovalDecision =
|
|
221
|
+
| ToolTier
|
|
222
|
+
| {
|
|
223
|
+
readonly tier: ToolTier;
|
|
224
|
+
readonly reason?: string;
|
|
225
|
+
readonly policy?: "allow" | "deny" | "prompt";
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
interface ToolDefinition<TParameters extends TSchema, TDetails> {
|
|
229
|
+
readonly name: string;
|
|
230
|
+
readonly label: string;
|
|
231
|
+
readonly description: string;
|
|
232
|
+
readonly parameters: TParameters;
|
|
233
|
+
readonly approval: ToolApprovalDecision | ((args: unknown) => ToolApprovalDecision);
|
|
234
|
+
readonly loadMode: "essential" | "discoverable";
|
|
235
|
+
readonly concurrency?:
|
|
236
|
+
| "shared"
|
|
237
|
+
| "exclusive"
|
|
238
|
+
| ((args: Partial<Static<TParameters>>) => "shared" | "exclusive");
|
|
239
|
+
readonly executionMode?: "sequential" | "parallel";
|
|
240
|
+
readonly formatApprovalDetails?: (args: unknown) => string | readonly string[] | undefined;
|
|
241
|
+
execute(
|
|
242
|
+
toolCallId: string,
|
|
243
|
+
parameters: Static<TParameters>,
|
|
244
|
+
signal: AbortSignal | undefined,
|
|
245
|
+
onUpdate: unknown,
|
|
246
|
+
context: ToolContext,
|
|
247
|
+
): Promise<ToolResult<TDetails>>;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export interface HerdrExtensionApi extends HerdrPromptApi {
|
|
251
|
+
registerTool<TParameters extends TSchema, TDetails>(
|
|
252
|
+
definition: ToolDefinition<TParameters, TDetails>,
|
|
253
|
+
): void;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export interface HerdrExtensionDependencies {
|
|
257
|
+
readonly runner?: CommandRunner;
|
|
258
|
+
readonly capabilities?: HerdrCapabilityResolver;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
interface CommandDetails {
|
|
262
|
+
readonly executable: string;
|
|
263
|
+
readonly args: readonly string[];
|
|
264
|
+
readonly exitCode: number;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
interface CapabilityDetails {
|
|
268
|
+
readonly repositoryRoot: string;
|
|
269
|
+
readonly herdrVersion: string;
|
|
270
|
+
readonly cache: HerdrCapability["cache"];
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
interface ListDetails {
|
|
274
|
+
readonly operation: "list";
|
|
275
|
+
readonly capability: CapabilityDetails;
|
|
276
|
+
readonly command: CommandDetails;
|
|
277
|
+
readonly result: WorktreeListResult;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
interface OpenDetails {
|
|
281
|
+
readonly operation: "create" | "open";
|
|
282
|
+
readonly capability: CapabilityDetails;
|
|
283
|
+
readonly command: CommandDetails;
|
|
284
|
+
readonly result: WorktreeOpenedResult;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
interface RemoveDetails {
|
|
288
|
+
readonly operation: "remove";
|
|
289
|
+
readonly capability: CapabilityDetails;
|
|
290
|
+
readonly command: CommandDetails;
|
|
291
|
+
readonly result: WorktreeRemovedResult;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
type HerdrWorktreeToolDetails = ListDetails | OpenDetails | RemoveDetails;
|
|
295
|
+
|
|
296
|
+
const repositoryQueues = new Map<string, Promise<void>>();
|
|
297
|
+
|
|
298
|
+
async function withRepositoryLock<T>(
|
|
299
|
+
repositoryRoot: ParsedAbsolutePath,
|
|
300
|
+
signal: AbortSignal | undefined,
|
|
301
|
+
operation: () => Promise<T>,
|
|
302
|
+
): Promise<T> {
|
|
303
|
+
const predecessor = repositoryQueues.get(repositoryRoot) ?? Promise.resolve();
|
|
304
|
+
const { promise: releasePromise, resolve: release } = Promise.withResolvers<void>();
|
|
305
|
+
const queueTail = predecessor.then(() => releasePromise);
|
|
306
|
+
repositoryQueues.set(repositoryRoot, queueTail);
|
|
307
|
+
|
|
308
|
+
await predecessor;
|
|
309
|
+
try {
|
|
310
|
+
if (signal?.aborted) {
|
|
311
|
+
throw new CommandCancelledError("Herdr worktree operation");
|
|
312
|
+
}
|
|
313
|
+
return await operation();
|
|
314
|
+
} finally {
|
|
315
|
+
release();
|
|
316
|
+
if (repositoryQueues.get(repositoryRoot) === queueTail) {
|
|
317
|
+
repositoryQueues.delete(repositoryRoot);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function capabilityDetails(capability: HerdrCapability): CapabilityDetails {
|
|
323
|
+
return {
|
|
324
|
+
repositoryRoot: capability.repository.root,
|
|
325
|
+
herdrVersion: capability.herdrVersion,
|
|
326
|
+
cache: capability.cache,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function commandDetails(result: CommandResult): CommandDetails {
|
|
331
|
+
return { executable: result.command, args: result.args, exitCode: result.exitCode };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function parseOperationFrom(args: unknown): HerdrWorktreeOperation | undefined {
|
|
335
|
+
if (!args || typeof args !== "object") {
|
|
336
|
+
return undefined;
|
|
337
|
+
}
|
|
338
|
+
switch (Reflect.get(args, "operation")) {
|
|
339
|
+
case "list":
|
|
340
|
+
return "list";
|
|
341
|
+
case "create":
|
|
342
|
+
return "create";
|
|
343
|
+
case "open":
|
|
344
|
+
return "open";
|
|
345
|
+
case "remove":
|
|
346
|
+
return "remove";
|
|
347
|
+
default:
|
|
348
|
+
return undefined;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** The fields an operation declares, with values that are not parsed yet. */
|
|
353
|
+
type RequestFields<TOperation extends HerdrWorktreeOperation> = Readonly<
|
|
354
|
+
Record<FieldsOf<TOperation>, unknown>
|
|
355
|
+
>;
|
|
356
|
+
|
|
357
|
+
function parseRequestFields<TOperation extends HerdrWorktreeOperation>(
|
|
358
|
+
parameters: unknown,
|
|
359
|
+
operation: TOperation,
|
|
360
|
+
fields: RequestSchema<TOperation>,
|
|
361
|
+
): RequestFields<TOperation> {
|
|
362
|
+
if (!parameters || typeof parameters !== "object" || Array.isArray(parameters)) {
|
|
363
|
+
throw new Error(`Operation ${operation} requires an object request.`);
|
|
364
|
+
}
|
|
365
|
+
const entries = Object.entries(parameters);
|
|
366
|
+
const present = entries.map(([field]) => field);
|
|
367
|
+
const allowed: readonly string[] = [...fields.required, ...fields.optional];
|
|
368
|
+
const unexpected = present.filter((field) => !allowed.includes(field));
|
|
369
|
+
if (unexpected.length > 0) {
|
|
370
|
+
throw new Error(
|
|
371
|
+
`Operation ${operation} does not accept ${unexpected.join(", ")}. Allowed fields: ${allowed.join(", ")}.`,
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
const missing = fields.required.filter((field) => !present.includes(field));
|
|
375
|
+
if (missing.length > 0) {
|
|
376
|
+
throw new Error(`Operation ${operation} requires ${missing.join(", ")}.`);
|
|
377
|
+
}
|
|
378
|
+
// A null prototype, so an absent field cannot resolve to an inherited value.
|
|
379
|
+
return Object.assign(
|
|
380
|
+
Object.create(null) as Record<string, unknown>,
|
|
381
|
+
Object.fromEntries(entries),
|
|
382
|
+
) as RequestFields<TOperation>;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function parseFlag(raw: unknown, label: string): boolean {
|
|
386
|
+
if (raw === undefined || typeof raw === "boolean") {
|
|
387
|
+
return raw === true;
|
|
388
|
+
}
|
|
389
|
+
throw new Error(`${label} must be a boolean.`);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function optional<T>(raw: unknown, parse: (raw: unknown) => T): T | undefined {
|
|
393
|
+
return raw === undefined ? undefined : parse(raw);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async function optionalAsync<T>(
|
|
397
|
+
raw: unknown,
|
|
398
|
+
parse: (raw: unknown) => Promise<T>,
|
|
399
|
+
): Promise<T | undefined> {
|
|
400
|
+
return raw === undefined ? undefined : await parse(raw);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async function parseOperation(
|
|
404
|
+
parameters: unknown,
|
|
405
|
+
runner: CommandRunner,
|
|
406
|
+
cwd: string,
|
|
407
|
+
signal: AbortSignal | undefined,
|
|
408
|
+
): Promise<ParsedOperation> {
|
|
409
|
+
const operation = parseOperationFrom(parameters);
|
|
410
|
+
if (!operation) {
|
|
411
|
+
throw new Error("Unknown Herdr worktree operation.");
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
switch (operation) {
|
|
415
|
+
case "list":
|
|
416
|
+
parseRequestFields(parameters, operation, OPERATION_FIELDS[operation]);
|
|
417
|
+
return { operation };
|
|
418
|
+
case "create": {
|
|
419
|
+
const fields = parseRequestFields(parameters, operation, OPERATION_FIELDS[operation]);
|
|
420
|
+
return {
|
|
421
|
+
operation,
|
|
422
|
+
branch: await parseBranchName(runner, fields.branch, "branch", cwd, signal),
|
|
423
|
+
base: await optionalAsync(fields.base, (raw) =>
|
|
424
|
+
parseBase(runner, raw, "base", cwd, signal),
|
|
425
|
+
),
|
|
426
|
+
path: optional(fields.path, (raw) => parseAbsolutePath(raw, "path")),
|
|
427
|
+
label: optional(fields.label, (raw) => parseLabel(raw, "label")),
|
|
428
|
+
focus: parseFlag(fields.focus, "focus"),
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
case "open": {
|
|
432
|
+
const fields = parseRequestFields(parameters, operation, OPERATION_FIELDS[operation]);
|
|
433
|
+
const path = optional(fields.path, (raw) => parseAbsolutePath(raw, "path"));
|
|
434
|
+
const branch = await optionalAsync(fields.branch, (raw) =>
|
|
435
|
+
parseBranchName(runner, raw, "branch", cwd, signal),
|
|
436
|
+
);
|
|
437
|
+
if ((path === undefined) === (branch === undefined)) {
|
|
438
|
+
throw new Error("Operation open requires exactly one of path or branch.");
|
|
439
|
+
}
|
|
440
|
+
return {
|
|
441
|
+
operation,
|
|
442
|
+
path,
|
|
443
|
+
branch,
|
|
444
|
+
label: optional(fields.label, (raw) => parseLabel(raw, "label")),
|
|
445
|
+
focus: parseFlag(fields.focus, "focus"),
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
case "remove": {
|
|
449
|
+
const fields = parseRequestFields(parameters, operation, OPERATION_FIELDS[operation]);
|
|
450
|
+
return {
|
|
451
|
+
operation,
|
|
452
|
+
workspaceId: parseWorkspaceId(fields.workspace_id, "workspace_id"),
|
|
453
|
+
force: parseFlag(fields.force, "force"),
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function approvalFor(args: unknown): ToolApprovalDecision {
|
|
460
|
+
const operation = parseOperationFrom(args);
|
|
461
|
+
if (operation === "list") {
|
|
462
|
+
return "read";
|
|
463
|
+
}
|
|
464
|
+
if (operation === "remove") {
|
|
465
|
+
return {
|
|
466
|
+
tier: "exec",
|
|
467
|
+
policy: "prompt",
|
|
468
|
+
reason: "Removing a worktree deletes its checkout and closes its Herdr workspace.",
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
// A caller-chosen path writes wherever it names, so the user decides. Otherwise Herdr picks.
|
|
472
|
+
if (operation === "create" && args && typeof args === "object") {
|
|
473
|
+
if (Reflect.get(args, "path") !== undefined) {
|
|
474
|
+
return {
|
|
475
|
+
tier: "exec",
|
|
476
|
+
policy: "prompt",
|
|
477
|
+
reason: "Creating a worktree at an explicit path writes to a directory the caller chose.",
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
if (operation) {
|
|
482
|
+
return "exec";
|
|
483
|
+
}
|
|
484
|
+
return { tier: "exec", policy: "deny", reason: "Unknown Herdr worktree operation." };
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function concurrencyFor(args: Partial<HerdrWorktreeParameters>): "shared" | "exclusive" {
|
|
488
|
+
return parseOperationFrom(args) === "list" ? "shared" : "exclusive";
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const APPROVAL_VALUE_MAX_LENGTH = 200;
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* The approval prompt is built before the request is parsed, so it shows raw text. A value must
|
|
495
|
+
* stay on one line, or it could forge the lines around it.
|
|
496
|
+
*/
|
|
497
|
+
function approvalValue(raw: unknown): string {
|
|
498
|
+
const text = typeof raw === "string" ? raw : (JSON.stringify(raw) ?? String(raw));
|
|
499
|
+
const singleLine = text.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/gu, " ").trim();
|
|
500
|
+
return singleLine.length > APPROVAL_VALUE_MAX_LENGTH
|
|
501
|
+
? `${singleLine.slice(0, APPROVAL_VALUE_MAX_LENGTH)}...`
|
|
502
|
+
: singleLine;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function approvalDetails(args: unknown): readonly string[] | undefined {
|
|
506
|
+
const operation = parseOperationFrom(args);
|
|
507
|
+
if (!operation || !args || typeof args !== "object") {
|
|
508
|
+
return undefined;
|
|
509
|
+
}
|
|
510
|
+
const lines = [`Operation: ${operation}`];
|
|
511
|
+
for (const field of ["branch", "base", "path", "label", "workspace_id"]) {
|
|
512
|
+
const raw = Reflect.get(args, field);
|
|
513
|
+
if (raw !== undefined) {
|
|
514
|
+
lines.push(`${field}: ${approvalValue(raw)}`);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
if (Reflect.get(args, "force") === true) {
|
|
518
|
+
lines.push("force: discards uncommitted changes in the checkout");
|
|
519
|
+
}
|
|
520
|
+
return lines;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Last line of defence before spawning `herdr`, kept although the types already make most of it
|
|
525
|
+
* impossible. Herdr does not accept `--flag=value`, so every value travels as its own argument.
|
|
526
|
+
*/
|
|
527
|
+
function buildHerdrArguments(command: HerdrCommand): readonly string[] {
|
|
528
|
+
if (!Object.hasOwn(ALLOWED_OPTIONS, command.action)) {
|
|
529
|
+
throw new Error(
|
|
530
|
+
`Refusing to run an unknown Herdr worktree action: ${JSON.stringify(command.action)}.`,
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
const allowedOptions = ALLOWED_OPTIONS[command.action];
|
|
534
|
+
const allowedSwitches = ALLOWED_SWITCHES[command.action];
|
|
535
|
+
const args: string[] = [HERDR_SUBCOMMAND, command.action];
|
|
536
|
+
const used = new Set<string>();
|
|
537
|
+
|
|
538
|
+
for (const option of command.options) {
|
|
539
|
+
if (!allowedOptions.includes(option.name)) {
|
|
540
|
+
throw new Error(`Refusing to pass ${option.name} to ${command.action}.`);
|
|
541
|
+
}
|
|
542
|
+
if (used.has(option.name)) {
|
|
543
|
+
throw new Error(`Refusing to pass ${option.name} twice.`);
|
|
544
|
+
}
|
|
545
|
+
const value: ParsedArgumentValue = option.value;
|
|
546
|
+
if (!hasParsedArgumentSyntax(value)) {
|
|
547
|
+
throw new Error(`Refusing to pass ${option.name} without a safe value.`);
|
|
548
|
+
}
|
|
549
|
+
used.add(option.name);
|
|
550
|
+
args.push(option.name, value);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
for (const flag of command.switches) {
|
|
554
|
+
if (!allowedSwitches.includes(flag)) {
|
|
555
|
+
throw new Error(`Refusing to pass ${flag} to ${command.action}.`);
|
|
556
|
+
}
|
|
557
|
+
if (used.has(flag)) {
|
|
558
|
+
throw new Error(`Refusing to pass ${flag} twice.`);
|
|
559
|
+
}
|
|
560
|
+
used.add(flag);
|
|
561
|
+
args.push(flag);
|
|
562
|
+
}
|
|
563
|
+
if (used.has("--focus") && used.has("--no-focus")) {
|
|
564
|
+
throw new Error("Refusing to pass both --focus and --no-focus.");
|
|
565
|
+
}
|
|
566
|
+
return args;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
async function runHerdr(
|
|
570
|
+
runner: CommandRunner,
|
|
571
|
+
capabilities: HerdrCapabilityResolver,
|
|
572
|
+
capability: HerdrCapability,
|
|
573
|
+
command: HerdrCommand,
|
|
574
|
+
signal: AbortSignal | undefined,
|
|
575
|
+
): Promise<{ result: CommandResult; payload: Record<string, unknown> }> {
|
|
576
|
+
const args = buildHerdrArguments(command);
|
|
577
|
+
let result: CommandResult;
|
|
578
|
+
try {
|
|
579
|
+
result = await runner("herdr", args, { cwd: capability.repository.root, signal });
|
|
580
|
+
} catch (error) {
|
|
581
|
+
if (error instanceof CommandInvocationError) {
|
|
582
|
+
capabilities.forget(capability.repository.root);
|
|
583
|
+
throw new HerdrUnavailableError(
|
|
584
|
+
"The Herdr session was detected, but the `herdr` executable could not be started.",
|
|
585
|
+
{ cause: error },
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
throw error;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// Exit 2 is a CLI syntax error: the argument vocabulary above and the installed CLI
|
|
592
|
+
// disagree, which is a bug here rather than a rejected request.
|
|
593
|
+
if (result.exitCode === 2) {
|
|
594
|
+
capabilities.forget(capability.repository.root);
|
|
595
|
+
throw new HerdrUnavailableError(
|
|
596
|
+
`The installed Herdr CLI rejected the argument list: ${[result.stderr, result.stdout]
|
|
597
|
+
.map((text) => text.trim())
|
|
598
|
+
.filter(Boolean)
|
|
599
|
+
.join("\n")}`,
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
return { result, payload: parseEnvelope(result) };
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function describeWorktree(worktree: WorktreeInfo): string {
|
|
606
|
+
const branch = worktree.branch ?? "detached HEAD";
|
|
607
|
+
const workspace = worktree.openWorkspaceId ? ` in workspace ${worktree.openWorkspaceId}` : "";
|
|
608
|
+
const flags = [
|
|
609
|
+
worktree.isLinkedWorktree ? "linked" : "main checkout",
|
|
610
|
+
worktree.isPrunable ? "prunable" : undefined,
|
|
611
|
+
worktree.isBare ? "bare" : undefined,
|
|
612
|
+
].filter(Boolean);
|
|
613
|
+
return `${worktree.path} [${branch}]${workspace} (${flags.join(", ")})`;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function textResult<TDetails>(text: string, details: TDetails): ToolResult<TDetails> {
|
|
617
|
+
return { content: [{ type: "text", text }], details };
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
async function assertGitWorktreeRegistered(
|
|
621
|
+
runner: CommandRunner,
|
|
622
|
+
cwd: string,
|
|
623
|
+
signal: AbortSignal | undefined,
|
|
624
|
+
path: string,
|
|
625
|
+
branch: string | null,
|
|
626
|
+
label: string,
|
|
627
|
+
): Promise<GitWorktreeEntry> {
|
|
628
|
+
const entries = await readGitWorktrees(runner, cwd, signal);
|
|
629
|
+
const entry = findGitWorktree(entries, path);
|
|
630
|
+
if (!entry) {
|
|
631
|
+
throw new Error(`Git does not list ${JSON.stringify(path)} as a worktree of this repository.`);
|
|
632
|
+
}
|
|
633
|
+
if (branch !== null && entry.branch !== branch) {
|
|
634
|
+
throw new Error(
|
|
635
|
+
`${label} reports branch ${JSON.stringify(branch)}, but Git has ${JSON.stringify(entry.branch)} checked out there.`,
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
return entry;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function isInside(directory: string, path: string): boolean {
|
|
642
|
+
const offset = relative(directory, path);
|
|
643
|
+
return offset === "" || (!offset.startsWith("..") && !isAbsolute(offset));
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Git refuses to overwrite files too, but only after Herdr made the workspace for it. The tool may
|
|
648
|
+
* run from a linked worktree, where the main checkout is not the root Git reported, so every
|
|
649
|
+
* registered checkout is checked and not only that root.
|
|
650
|
+
*/
|
|
651
|
+
async function assertCheckoutPathAvailable(
|
|
652
|
+
path: ParsedAbsolutePath,
|
|
653
|
+
repository: GitRepository,
|
|
654
|
+
existing: readonly GitWorktreeEntry[],
|
|
655
|
+
): Promise<void> {
|
|
656
|
+
const directories: readonly (readonly [string, string])[] = [
|
|
657
|
+
[repository.root, "repository"],
|
|
658
|
+
[repository.gitDir, "Git directory"],
|
|
659
|
+
...existing.map((entry) => [entry.path, "worktree"] as const),
|
|
660
|
+
];
|
|
661
|
+
for (const [directory, label] of directories) {
|
|
662
|
+
if (isInside(directory, path)) {
|
|
663
|
+
throw new Error(
|
|
664
|
+
`path ${JSON.stringify(path)} is inside the ${label} at ${JSON.stringify(directory)}. Create the worktree outside it.`,
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
let target: Stats;
|
|
670
|
+
try {
|
|
671
|
+
target = await stat(path);
|
|
672
|
+
} catch (error) {
|
|
673
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
throw new Error(`Unable to inspect path ${JSON.stringify(path)}.`, { cause: error });
|
|
677
|
+
}
|
|
678
|
+
if (!target.isDirectory()) {
|
|
679
|
+
throw new Error(`path ${JSON.stringify(path)} already exists and is not a directory.`);
|
|
680
|
+
}
|
|
681
|
+
if ((await readdir(path)).length > 0) {
|
|
682
|
+
throw new Error(`path ${JSON.stringify(path)} already exists and is not empty.`);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function focusSwitch(focus: boolean): HerdrSwitch {
|
|
687
|
+
return focus ? "--focus" : "--no-focus";
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function listCommand(root: ParsedAbsolutePath): HerdrCommand {
|
|
691
|
+
return { action: "list", options: [{ name: "--cwd", value: root }], switches: [] };
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
export function registerHerdrWorktreeTools(
|
|
695
|
+
pi: HerdrExtensionApi,
|
|
696
|
+
dependencies: HerdrExtensionDependencies = {},
|
|
697
|
+
): void {
|
|
698
|
+
const runner = dependencies.runner ?? runCommand;
|
|
699
|
+
const capabilities = dependencies.capabilities ?? new HerdrCapabilityResolver(runner);
|
|
700
|
+
|
|
701
|
+
registerHerdrGuidance(pi, capabilities);
|
|
702
|
+
|
|
703
|
+
pi.registerTool<typeof HerdrWorktreeParameters, HerdrWorktreeToolDetails>({
|
|
704
|
+
name: "herdr_worktree",
|
|
705
|
+
label: "Herdr worktree",
|
|
706
|
+
description:
|
|
707
|
+
"List, create, open, and remove Herdr Git worktree workspaces for the current repository, with checked arguments and verified results.",
|
|
708
|
+
parameters: HerdrWorktreeParameters,
|
|
709
|
+
approval: approvalFor,
|
|
710
|
+
formatApprovalDetails: approvalDetails,
|
|
711
|
+
loadMode: "discoverable",
|
|
712
|
+
concurrency: concurrencyFor,
|
|
713
|
+
executionMode: "sequential",
|
|
714
|
+
async execute(_toolCallId, parameters, signal, _onUpdate, context) {
|
|
715
|
+
const capability = await capabilities.ensure(context.cwd, signal);
|
|
716
|
+
const root = capability.repository.root;
|
|
717
|
+
const operation = await parseOperation(parameters, runner, root, signal);
|
|
718
|
+
|
|
719
|
+
switch (operation.operation) {
|
|
720
|
+
case "list": {
|
|
721
|
+
const { result, payload } = await runHerdr(
|
|
722
|
+
runner,
|
|
723
|
+
capabilities,
|
|
724
|
+
capability,
|
|
725
|
+
listCommand(root),
|
|
726
|
+
signal,
|
|
727
|
+
);
|
|
728
|
+
const list = parseWorktreeList(payload);
|
|
729
|
+
const lines = list.worktrees.map((worktree) => `- ${describeWorktree(worktree)}`);
|
|
730
|
+
const body = lines.length > 0 ? lines.join("\n") : "No worktrees are registered.";
|
|
731
|
+
return textResult(`${list.source.repoName} (${list.source.repoRoot})\n${body}`, {
|
|
732
|
+
operation: "list",
|
|
733
|
+
capability: capabilityDetails(capability),
|
|
734
|
+
command: commandDetails(result),
|
|
735
|
+
result: list,
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
case "create":
|
|
739
|
+
return withRepositoryLock(root, signal, async () => {
|
|
740
|
+
const existing = await readGitWorktrees(runner, root, signal);
|
|
741
|
+
if (existing.some((entry) => entry.branch === operation.branch)) {
|
|
742
|
+
throw new Error(
|
|
743
|
+
`Branch ${JSON.stringify(operation.branch)} is already checked out in a worktree of this repository. Open it instead of creating it.`,
|
|
744
|
+
);
|
|
745
|
+
}
|
|
746
|
+
if (operation.path && findGitWorktree(existing, operation.path)) {
|
|
747
|
+
throw new Error(
|
|
748
|
+
`${JSON.stringify(operation.path)} is already a worktree of this repository.`,
|
|
749
|
+
);
|
|
750
|
+
}
|
|
751
|
+
if (operation.path) {
|
|
752
|
+
await assertCheckoutPathAvailable(operation.path, capability.repository, existing);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const options: HerdrOption[] = [
|
|
756
|
+
{ name: "--cwd", value: root },
|
|
757
|
+
{ name: "--branch", value: operation.branch },
|
|
758
|
+
];
|
|
759
|
+
if (operation.base) {
|
|
760
|
+
options.push({ name: "--base", value: operation.base.revision });
|
|
761
|
+
}
|
|
762
|
+
if (operation.path) {
|
|
763
|
+
options.push({ name: "--path", value: operation.path });
|
|
764
|
+
}
|
|
765
|
+
if (operation.label) {
|
|
766
|
+
options.push({ name: "--label", value: operation.label });
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
const { result, payload } = await runHerdr(
|
|
770
|
+
runner,
|
|
771
|
+
capabilities,
|
|
772
|
+
capability,
|
|
773
|
+
{ action: "create", options, switches: [focusSwitch(operation.focus)] },
|
|
774
|
+
signal,
|
|
775
|
+
);
|
|
776
|
+
const created = parseWorktreeOpened(payload);
|
|
777
|
+
if (created.type !== "worktree_created") {
|
|
778
|
+
throw new Error(
|
|
779
|
+
`Herdr answered a create request with ${JSON.stringify(created.type)}.`,
|
|
780
|
+
);
|
|
781
|
+
}
|
|
782
|
+
if (created.worktree.branch !== operation.branch) {
|
|
783
|
+
throw new Error(
|
|
784
|
+
`Herdr created the worktree on branch ${JSON.stringify(created.worktree.branch)} instead of ${JSON.stringify(operation.branch)}.`,
|
|
785
|
+
);
|
|
786
|
+
}
|
|
787
|
+
if (!created.worktree.isLinkedWorktree) {
|
|
788
|
+
throw new Error("Herdr reported the main checkout instead of a new linked worktree.");
|
|
789
|
+
}
|
|
790
|
+
if (operation.path && created.worktree.path !== operation.path) {
|
|
791
|
+
throw new Error(
|
|
792
|
+
`Herdr created the worktree at ${JSON.stringify(created.worktree.path)} instead of ${JSON.stringify(operation.path)}.`,
|
|
793
|
+
);
|
|
794
|
+
}
|
|
795
|
+
const entry = await assertGitWorktreeRegistered(
|
|
796
|
+
runner,
|
|
797
|
+
root,
|
|
798
|
+
signal,
|
|
799
|
+
created.worktree.path,
|
|
800
|
+
created.worktree.branch,
|
|
801
|
+
"Herdr",
|
|
802
|
+
);
|
|
803
|
+
// A ref can move between the parse and the create, so the commit is checked again.
|
|
804
|
+
if (operation.base && entry.head !== operation.base.commit) {
|
|
805
|
+
throw new Error(
|
|
806
|
+
`base ${JSON.stringify(operation.base.revision)} was at ${operation.base.commit}, but the new worktree is at ${JSON.stringify(entry.head)}.`,
|
|
807
|
+
);
|
|
808
|
+
}
|
|
809
|
+
return textResult(
|
|
810
|
+
`Created ${describeWorktree(created.worktree)}\nWorkspace ${created.workspaceId}, tab ${created.tabId}, pane ${created.rootPaneId}`,
|
|
811
|
+
{
|
|
812
|
+
operation: "create",
|
|
813
|
+
capability: capabilityDetails(capability),
|
|
814
|
+
command: commandDetails(result),
|
|
815
|
+
result: created,
|
|
816
|
+
},
|
|
817
|
+
);
|
|
818
|
+
});
|
|
819
|
+
case "open":
|
|
820
|
+
return withRepositoryLock(root, signal, async () => {
|
|
821
|
+
const options: HerdrOption[] = [{ name: "--cwd", value: root }];
|
|
822
|
+
if (operation.path) {
|
|
823
|
+
options.push({ name: "--path", value: operation.path });
|
|
824
|
+
}
|
|
825
|
+
if (operation.branch) {
|
|
826
|
+
options.push({ name: "--branch", value: operation.branch });
|
|
827
|
+
}
|
|
828
|
+
if (operation.label) {
|
|
829
|
+
options.push({ name: "--label", value: operation.label });
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
const { result, payload } = await runHerdr(
|
|
833
|
+
runner,
|
|
834
|
+
capabilities,
|
|
835
|
+
capability,
|
|
836
|
+
{ action: "open", options, switches: [focusSwitch(operation.focus)] },
|
|
837
|
+
signal,
|
|
838
|
+
);
|
|
839
|
+
const opened = parseWorktreeOpened(payload);
|
|
840
|
+
if (opened.type !== "worktree_opened") {
|
|
841
|
+
throw new Error(
|
|
842
|
+
`Herdr answered an open request with ${JSON.stringify(opened.type)}.`,
|
|
843
|
+
);
|
|
844
|
+
}
|
|
845
|
+
if (operation.branch && opened.worktree.branch !== operation.branch) {
|
|
846
|
+
throw new Error(
|
|
847
|
+
`Herdr opened branch ${JSON.stringify(opened.worktree.branch)} instead of ${JSON.stringify(operation.branch)}.`,
|
|
848
|
+
);
|
|
849
|
+
}
|
|
850
|
+
if (operation.path && opened.worktree.path !== operation.path) {
|
|
851
|
+
throw new Error(
|
|
852
|
+
`Herdr opened ${JSON.stringify(opened.worktree.path)} instead of ${JSON.stringify(operation.path)}.`,
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
await assertGitWorktreeRegistered(
|
|
856
|
+
runner,
|
|
857
|
+
root,
|
|
858
|
+
signal,
|
|
859
|
+
opened.worktree.path,
|
|
860
|
+
opened.worktree.branch,
|
|
861
|
+
"Herdr",
|
|
862
|
+
);
|
|
863
|
+
const prefix = opened.alreadyOpen ? "Already open:" : "Opened";
|
|
864
|
+
return textResult(
|
|
865
|
+
`${prefix} ${describeWorktree(opened.worktree)}\nWorkspace ${opened.workspaceId}, tab ${opened.tabId}, pane ${opened.rootPaneId}`,
|
|
866
|
+
{
|
|
867
|
+
operation: "open",
|
|
868
|
+
capability: capabilityDetails(capability),
|
|
869
|
+
command: commandDetails(result),
|
|
870
|
+
result: opened,
|
|
871
|
+
},
|
|
872
|
+
);
|
|
873
|
+
});
|
|
874
|
+
case "remove":
|
|
875
|
+
return withRepositoryLock(root, signal, async () => {
|
|
876
|
+
const { payload: listPayload } = await runHerdr(
|
|
877
|
+
runner,
|
|
878
|
+
capabilities,
|
|
879
|
+
capability,
|
|
880
|
+
listCommand(root),
|
|
881
|
+
signal,
|
|
882
|
+
);
|
|
883
|
+
const list = parseWorktreeList(listPayload);
|
|
884
|
+
const target = list.worktrees.find(
|
|
885
|
+
(worktree) => worktree.openWorkspaceId === operation.workspaceId,
|
|
886
|
+
);
|
|
887
|
+
if (!target) {
|
|
888
|
+
throw new Error(
|
|
889
|
+
`Workspace ${JSON.stringify(operation.workspaceId)} does not hold a worktree of ${JSON.stringify(list.source.repoRoot)}.`,
|
|
890
|
+
);
|
|
891
|
+
}
|
|
892
|
+
if (!target.isLinkedWorktree) {
|
|
893
|
+
throw new Error(
|
|
894
|
+
`Workspace ${JSON.stringify(operation.workspaceId)} holds the main checkout at ${JSON.stringify(target.path)}, which cannot be removed.`,
|
|
895
|
+
);
|
|
896
|
+
}
|
|
897
|
+
if (target.path === capability.repository.root) {
|
|
898
|
+
throw new Error("remove cannot remove the repository the tool is running in.");
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
const confirmed = await context.ui?.confirm(
|
|
902
|
+
`Remove Herdr worktree ${target.path}?`,
|
|
903
|
+
operation.force
|
|
904
|
+
? "This deletes the checkout even if it holds uncommitted changes, and closes its Herdr workspace."
|
|
905
|
+
: "This deletes the checkout and closes its Herdr workspace.",
|
|
906
|
+
);
|
|
907
|
+
if (!confirmed) {
|
|
908
|
+
throw new Error("Removing a worktree requires explicit user confirmation.");
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
const { result, payload } = await runHerdr(
|
|
912
|
+
runner,
|
|
913
|
+
capabilities,
|
|
914
|
+
capability,
|
|
915
|
+
{
|
|
916
|
+
action: "remove",
|
|
917
|
+
options: [{ name: "--workspace", value: operation.workspaceId }],
|
|
918
|
+
switches: operation.force ? ["--force"] : [],
|
|
919
|
+
},
|
|
920
|
+
signal,
|
|
921
|
+
);
|
|
922
|
+
const removed = parseWorktreeRemoved(payload);
|
|
923
|
+
if (removed.workspaceId !== operation.workspaceId) {
|
|
924
|
+
throw new Error(
|
|
925
|
+
`Herdr removed workspace ${JSON.stringify(removed.workspaceId)} instead of ${JSON.stringify(operation.workspaceId)}.`,
|
|
926
|
+
);
|
|
927
|
+
}
|
|
928
|
+
if (removed.path !== target.path) {
|
|
929
|
+
throw new Error(
|
|
930
|
+
`Herdr removed ${JSON.stringify(removed.path)} instead of ${JSON.stringify(target.path)}.`,
|
|
931
|
+
);
|
|
932
|
+
}
|
|
933
|
+
const remaining = await readGitWorktrees(runner, root, signal);
|
|
934
|
+
if (findGitWorktree(remaining, removed.path)) {
|
|
935
|
+
throw new Error(
|
|
936
|
+
`Herdr reported ${JSON.stringify(removed.path)} removed, but Git still lists it as a worktree.`,
|
|
937
|
+
);
|
|
938
|
+
}
|
|
939
|
+
return textResult(
|
|
940
|
+
`Removed ${removed.path} and closed workspace ${removed.workspaceId}${removed.forced ? " (forced)" : ""}.`,
|
|
941
|
+
{
|
|
942
|
+
operation: "remove",
|
|
943
|
+
capability: capabilityDetails(capability),
|
|
944
|
+
command: commandDetails(result),
|
|
945
|
+
result: removed,
|
|
946
|
+
},
|
|
947
|
+
);
|
|
948
|
+
});
|
|
949
|
+
}
|
|
950
|
+
},
|
|
951
|
+
});
|
|
952
|
+
}
|