@senad-d/branchme 0.1.6 → 0.1.8

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/src/github.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  GITHUB_USER_AGENT,
11
11
  GIT_CONTEXT_VALUE_LIMIT_CHARS,
12
12
  MAX_SUMMARY_OUTPUT_CHARS,
13
+ PULL_REQUEST_AUTOFILL_ENV_NAME,
13
14
  } from "./constants.ts";
14
15
  import {
15
16
  getCurrentBranch,
@@ -27,6 +28,9 @@ import type {
27
28
  } from "./types.ts";
28
29
 
29
30
  type TokenEnvironmentKey = "GITHUB_TOKEN" | "GH_TOKEN";
31
+ type PullRequestAutofillEnvironmentKey = typeof PULL_REQUEST_AUTOFILL_ENV_NAME;
32
+ type SupportedDotEnvKey = TokenEnvironmentKey | PullRequestAutofillEnvironmentKey;
33
+ type DotEnvReadPurpose = "GitHub token fallback" | "pull request autofill configuration";
30
34
 
31
35
  export type TokenResolutionSource = TokenEnvironmentKey | `${TokenEnvironmentKey} (.env)`;
32
36
 
@@ -149,8 +153,8 @@ function resolveProcessToken(env: NodeJS.ProcessEnv): TokenResolution | null {
149
153
  return null;
150
154
  }
151
155
 
152
- function isTokenEnvironmentKey(value: string): value is TokenEnvironmentKey {
153
- return value === "GITHUB_TOKEN" || value === "GH_TOKEN";
156
+ function isSupportedDotEnvKey(value: string): value is SupportedDotEnvKey {
157
+ return value === "GITHUB_TOKEN" || value === "GH_TOKEN" || value === PULL_REQUEST_AUTOFILL_ENV_NAME;
154
158
  }
155
159
 
156
160
  function decodeDoubleQuotedDotEnvValue(value: string): string {
@@ -182,23 +186,46 @@ function stripDotEnvInlineComment(value: string): string {
182
186
  return value;
183
187
  }
184
188
 
189
+ function closingDotEnvQuoteIndex(value: string, quote: "\"" | "'"): number {
190
+ let escaped = false;
191
+ for (let index = 1; index < value.length; index += 1) {
192
+ const character = value.charAt(index);
193
+ if (quote === "\"" && character === "\\" && !escaped) {
194
+ escaped = true;
195
+ continue;
196
+ }
197
+ if (character === quote && !escaped) return index;
198
+ escaped = false;
199
+ }
200
+ return -1;
201
+ }
202
+
203
+ function parseQuotedDotEnvValue(value: string): string | null {
204
+ const quote = value.charAt(0);
205
+ if (quote !== "\"" && quote !== "'") return null;
206
+
207
+ const closingIndex = closingDotEnvQuoteIndex(value, quote);
208
+ if (closingIndex === -1) return null;
209
+
210
+ const remainder = value.slice(closingIndex + 1).trim();
211
+ if (remainder && !remainder.startsWith("#")) return null;
212
+
213
+ const quotedValue = value.slice(1, closingIndex);
214
+ return quote === "\"" ? decodeDoubleQuotedDotEnvValue(quotedValue).trim() : quotedValue.trim();
215
+ }
216
+
185
217
  function parseDotEnvValue(rawValue: string): string {
186
218
  const value = rawValue.trim();
187
219
  if (!value) return "";
188
220
 
189
- if (value.startsWith("\"") && value.endsWith("\"")) {
190
- return decodeDoubleQuotedDotEnvValue(value.slice(1, -1)).trim();
191
- }
192
-
193
- if (value.startsWith("'") && value.endsWith("'")) {
194
- return value.slice(1, -1).trim();
195
- }
221
+ const quotedValue = parseQuotedDotEnvValue(value);
222
+ if (quotedValue !== null) return quotedValue;
196
223
 
197
224
  return stripDotEnvInlineComment(value).trim();
198
225
  }
199
226
 
200
- function parseDotEnvTokens(contents: string): Partial<Record<TokenEnvironmentKey, string>> {
201
- const tokens: Partial<Record<TokenEnvironmentKey, string>> = {};
227
+ function parseDotEnvValues(contents: string): Partial<Record<SupportedDotEnvKey, string>> {
228
+ const values: Partial<Record<SupportedDotEnvKey, string>> = {};
202
229
 
203
230
  for (const line of contents.split(/\r?\n/u)) {
204
231
  const trimmed = line.trim();
@@ -209,12 +236,12 @@ function parseDotEnvTokens(contents: string): Partial<Record<TokenEnvironmentKey
209
236
  if (separatorIndex === -1) continue;
210
237
 
211
238
  const key = assignment.slice(0, separatorIndex).trim();
212
- if (!isTokenEnvironmentKey(key)) continue;
239
+ if (!isSupportedDotEnvKey(key)) continue;
213
240
 
214
- tokens[key] = parseDotEnvValue(assignment.slice(separatorIndex + 1).trimStart());
241
+ values[key] = parseDotEnvValue(assignment.slice(separatorIndex + 1).trimStart());
215
242
  }
216
243
 
217
- return tokens;
244
+ return values;
218
245
  }
219
246
 
220
247
  const MAX_DOTENV_BYTES = 64 * 1024;
@@ -223,55 +250,77 @@ function isMissingFileError(error: unknown): boolean {
223
250
  return error instanceof Error && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR");
224
251
  }
225
252
 
226
- function throwIfAborted(signal: AbortSignal | undefined): void {
227
- if (signal?.aborted) throw new Error("GitHub token .env fallback was aborted.");
253
+ function throwIfAborted(signal: AbortSignal | undefined, purpose: DotEnvReadPurpose): void {
254
+ if (signal?.aborted) throw new Error(`${purpose} .env read was aborted.`);
228
255
  }
229
256
 
230
257
  function errorMessage(error: unknown): string {
231
258
  return redactSecrets(error instanceof Error ? error.message : String(error));
232
259
  }
233
260
 
234
- async function readDotEnvTokens(
261
+ async function readDotEnvValues(
235
262
  cwd: string | undefined,
236
263
  signal?: AbortSignal,
237
- ): Promise<Partial<Record<TokenEnvironmentKey, string>>> {
264
+ purpose: DotEnvReadPurpose = "GitHub token fallback",
265
+ ): Promise<Partial<Record<SupportedDotEnvKey, string>>> {
238
266
  if (!cwd) return {};
239
267
 
240
268
  const envPath = join(cwd, ".env");
241
- throwIfAborted(signal);
269
+ throwIfAborted(signal, purpose);
242
270
 
243
271
  let stats: Awaited<ReturnType<typeof lstat>>;
244
272
  try {
245
273
  stats = await lstat(envPath);
246
274
  } catch (error) {
247
275
  if (isMissingFileError(error)) return {};
248
- throw new Error(`Unable to inspect .env file for GitHub token fallback: ${errorMessage(error)}`);
276
+ throw new Error(`Unable to inspect .env file for ${purpose}: ${errorMessage(error)}`);
249
277
  }
250
278
 
251
279
  if (!stats.isFile()) {
252
- throw new Error("Unable to read .env file for GitHub token fallback: .env must be a small regular file.");
280
+ throw new Error(`Unable to read .env file for ${purpose}: .env must be a small regular file.`);
253
281
  }
254
282
  if (stats.size > MAX_DOTENV_BYTES) {
255
283
  throw new Error(
256
- `Unable to read .env file for GitHub token fallback: .env is too large (${stats.size} bytes; limit ${MAX_DOTENV_BYTES} bytes).`,
284
+ `Unable to read .env file for ${purpose}: .env is too large (${stats.size} bytes; limit ${MAX_DOTENV_BYTES} bytes).`,
257
285
  );
258
286
  }
259
287
 
260
- throwIfAborted(signal);
288
+ throwIfAborted(signal, purpose);
261
289
 
262
290
  let contents: string;
263
291
  try {
264
292
  contents = await readFile(envPath, { encoding: "utf8", signal });
265
293
  } catch (error) {
266
294
  if (isMissingFileError(error)) return {};
267
- throw new Error(`Unable to read .env file for GitHub token fallback: ${errorMessage(error)}`);
295
+ throw new Error(`Unable to read .env file for ${purpose}: ${errorMessage(error)}`);
268
296
  }
269
297
 
270
298
  if (Buffer.byteLength(contents, "utf8") > MAX_DOTENV_BYTES) {
271
- throw new Error(`Unable to read .env file for GitHub token fallback: .env exceeded the ${MAX_DOTENV_BYTES} byte limit.`);
299
+ throw new Error(`Unable to read .env file for ${purpose}: .env exceeded the ${MAX_DOTENV_BYTES} byte limit.`);
272
300
  }
273
301
 
274
- return parseDotEnvTokens(contents);
302
+ return parseDotEnvValues(contents);
303
+ }
304
+
305
+ function parsePullRequestAutofillValue(value: string): boolean {
306
+ const normalized = value.trim().toLowerCase();
307
+ if (["1", "true", "yes", "on"].includes(normalized)) return true;
308
+ if (["0", "false", "no", "off"].includes(normalized)) return false;
309
+ throw new Error(
310
+ `${PULL_REQUEST_AUTOFILL_ENV_NAME} must be one of true, false, 1, 0, yes, no, on, or off.`,
311
+ );
312
+ }
313
+
314
+ export async function resolvePullRequestAutofill(
315
+ env: NodeJS.ProcessEnv = process.env,
316
+ options: TokenResolutionOptions = {},
317
+ ): Promise<boolean> {
318
+ const processValue = env[PULL_REQUEST_AUTOFILL_ENV_NAME];
319
+ if (processValue !== undefined) return parsePullRequestAutofillValue(processValue);
320
+
321
+ const dotEnvValues = await readDotEnvValues(options.cwd, options.signal, "pull request autofill configuration");
322
+ const dotEnvValue = dotEnvValues[PULL_REQUEST_AUTOFILL_ENV_NAME];
323
+ return dotEnvValue === undefined ? false : parsePullRequestAutofillValue(dotEnvValue);
275
324
  }
276
325
 
277
326
  export async function resolveGitHubToken(
@@ -281,7 +330,7 @@ export async function resolveGitHubToken(
281
330
  const processToken = resolveProcessToken(env);
282
331
  if (processToken) return processToken;
283
332
 
284
- const dotEnvTokens = await readDotEnvTokens(options.cwd, options.signal);
333
+ const dotEnvTokens = await readDotEnvValues(options.cwd, options.signal);
285
334
  const githubToken = dotEnvTokens.GITHUB_TOKEN?.trim();
286
335
  if (githubToken) return { token: githubToken, source: "GITHUB_TOKEN (.env)" };
287
336
 
@@ -1,25 +1,36 @@
1
+ import { StringEnum } from "@earendil-works/pi-ai";
1
2
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
3
  import { Type } from "typebox";
3
4
  import {
4
5
  BRANCH_STATUS_TOOL_NAME,
5
6
  CHANGE_BRANCH_TOOL_NAME,
6
7
  CREATE_BRANCH_TOOL_NAME,
8
+ CREATE_WORKTREE_TOOL_NAME,
7
9
  FETCH_BRANCH_TOOL_NAME,
10
+ GIT_WORKTREE_SUMMARY_LIMIT_CHARS,
11
+ LIST_WORKTREES_TOOL_NAME,
8
12
  PULL_BRANCH_TOOL_NAME,
9
13
  PULL_REQUEST_TOOL_NAME,
10
14
  PUSH_BRANCH_TOOL_NAME,
11
15
  REBASE_BRANCH_TOOL_NAME,
16
+ REMOVE_WORKTREE_TOOL_NAME,
12
17
  } from "../constants.ts";
13
18
  import {
14
19
  changeExistingLocalBranch,
15
20
  createLocalBranch,
21
+ createWorktree,
16
22
  fetchCurrentBranch,
17
23
  getGitRoot,
18
24
  getLocalBranchCommit,
25
+ getPullRequestCommitSubjects,
26
+ inferPullRequestBaseBranch,
27
+ listWorktrees,
19
28
  localBranchExists,
20
29
  pullCurrentBranch,
21
30
  pushCurrentBranch,
22
31
  rebaseCurrentBranch,
32
+ removeWorktree,
33
+ requireCurrentBranch,
23
34
  validateBranchName,
24
35
  withRepositoryMutationQueue,
25
36
  } from "../git.ts";
@@ -31,9 +42,22 @@ import {
31
42
  repositoryLabel,
32
43
  resolveGitHubRepository,
33
44
  resolveGitHubToken,
45
+ resolvePullRequestAutofill,
34
46
  validatePullRequestBranchRef,
35
47
  } from "../github.ts";
36
- import type { ChangeBranchDetails, GitContextDetails, PullRequestDetails } from "../types.ts";
48
+ import type {
49
+ ChangeBranchDetails,
50
+ CreateWorktreeDetails,
51
+ GitContextDetails,
52
+ ListWorktreesDetails,
53
+ PullRequestDetails,
54
+ PullRequestInput,
55
+ PullRequestInputField,
56
+ PullRequestToolDetails,
57
+ PullRequestToolInput,
58
+ RemoveWorktreeDetails,
59
+ WorktreeEntry,
60
+ } from "../types.ts";
37
61
 
38
62
  const EmptyParametersSchema = Type.Object({}, { additionalProperties: false });
39
63
 
@@ -51,13 +75,37 @@ const ChangeBranchParametersSchema = Type.Object(
51
75
  { additionalProperties: false },
52
76
  );
53
77
 
78
+ const CreateWorktreeParametersSchema = Type.Object(
79
+ {
80
+ worktreePath: Type.String({ minLength: 1, description: "Explicit absolute destination path for the linked worktree." }),
81
+ branchName: Type.String({ minLength: 1, description: "New or existing local branch name for the linked worktree." }),
82
+ branchMode: StringEnum(["new", "existing"] as const, {
83
+ description: "Whether create_worktree creates a new local branch from current HEAD or uses an existing local branch.",
84
+ }),
85
+ },
86
+ { additionalProperties: false },
87
+ );
88
+
89
+ const RemoveWorktreeParametersSchema = Type.Object(
90
+ {
91
+ worktreePath: Type.String({ minLength: 1, description: "Explicit absolute path of the linked worktree to remove." }),
92
+ },
93
+ { additionalProperties: false },
94
+ );
95
+
54
96
  const PullRequestParametersSchema = Type.Object(
55
97
  {
56
- headBranch: Type.String({ minLength: 1, description: "Existing local branch containing the pull request changes." }),
57
- baseBranch: Type.String({ minLength: 1, description: "Existing local target branch for the pull request." }),
58
- title: Type.String({ minLength: 1, description: "Pull request title." }),
59
- body: Type.String({ description: "Pull request body. Pass an empty string only when intentionally blank." }),
60
- draft: Type.Boolean({ description: "Whether to create the pull request as a draft." }),
98
+ headBranch: Type.Optional(
99
+ Type.String({ minLength: 1, description: "Existing local branch containing the pull request changes. May be omitted in PR autofill mode." }),
100
+ ),
101
+ baseBranch: Type.Optional(
102
+ Type.String({ minLength: 1, description: "Existing local target branch for the pull request. May be omitted in PR autofill mode." }),
103
+ ),
104
+ title: Type.Optional(Type.String({ minLength: 1, description: "Pull request title. May be omitted in PR autofill mode." })),
105
+ body: Type.Optional(
106
+ Type.String({ description: "Pull request body. Pass an empty string only when intentionally blank; may be omitted in PR autofill mode." }),
107
+ ),
108
+ draft: Type.Optional(Type.Boolean({ description: "Whether to create the pull request as a draft. Defaults to false in PR autofill mode." })),
61
109
  },
62
110
  { additionalProperties: false },
63
111
  );
@@ -77,8 +125,156 @@ export function formatChangeBranch(details: ChangeBranchDetails): string {
77
125
  return `Changed branch from ${previous} to ${details.currentBranch}.`;
78
126
  }
79
127
 
80
- export function formatPullRequest(details: PullRequestDetails): string {
81
- return `Created pull request #${details.number} (${details.state}) for ${repositoryLabel(details.repository)}: ${details.url}`;
128
+ export function formatPullRequest(details: PullRequestDetails, autofilledFields: PullRequestInputField[] = []): string {
129
+ const autofill = autofilledFields.length > 0 ? ` Autofilled fields: ${autofilledFields.join(", ")}.` : "";
130
+ return `Created pull request #${details.number} (${details.state}) for ${repositoryLabel(details.repository)}: ${details.url}.${autofill}`;
131
+ }
132
+
133
+ const WORKTREE_FORMAT_PATH_LIMIT_CHARS = 512;
134
+ const WORKTREE_FORMAT_BRANCH_LIMIT_CHARS = 256;
135
+
136
+ function truncateWorktreeFormatValue(value: string, limit: number): string {
137
+ if (value.length <= limit) return value;
138
+ let end = limit - 1;
139
+ const lastCodePoint = value.codePointAt(end - 1);
140
+ if (lastCodePoint !== undefined && lastCodePoint > 0xffff) end -= 1;
141
+ return `${value.slice(0, end)}…`;
142
+ }
143
+
144
+ function escapeWorktreeFormatControlCharacter(character: string): string {
145
+ const codePoint = character.codePointAt(0);
146
+ return codePoint === undefined ? "" : String.raw`\u${codePoint.toString(16).padStart(4, "0")}`;
147
+ }
148
+
149
+ function safeWorktreeFormatValue(value: string, limit: number): string {
150
+ const redacted = redactSecrets(value);
151
+ const escaped = redacted.replace(
152
+ /[\p{Cc}\p{Cf}\u2028\u2029]/gu,
153
+ escapeWorktreeFormatControlCharacter,
154
+ );
155
+ return truncateWorktreeFormatValue(escaped, limit);
156
+ }
157
+
158
+ function worktreeDisplayState(worktree: WorktreeEntry): string {
159
+ if (worktree.bare) return "bare";
160
+ if (worktree.detached) return "detached";
161
+ const branch = worktree.branch ?? "unknown";
162
+ return `branch ${safeWorktreeFormatValue(branch, WORKTREE_FORMAT_BRANCH_LIMIT_CHARS)}`;
163
+ }
164
+
165
+ function worktreeDisplayIndicators(worktree: WorktreeEntry): string[] {
166
+ const indicators: string[] = [];
167
+ if (worktree.main) indicators.push("main");
168
+ if (worktree.current) indicators.push("current");
169
+ if (worktree.locked) indicators.push("locked");
170
+ if (worktree.prunable) indicators.push("prunable");
171
+ return indicators;
172
+ }
173
+
174
+ function formatWorktreeEntry(worktree: WorktreeEntry): string {
175
+ const path = safeWorktreeFormatValue(worktree.path, WORKTREE_FORMAT_PATH_LIMIT_CHARS);
176
+ const head = worktree.head === null ? "none" : shortCommit(worktree.head);
177
+ const indicators = worktreeDisplayIndicators(worktree);
178
+ const indicatorText = indicators.length === 0 ? "" : ` | ${indicators.join(",")}`;
179
+ return `- ${path} | ${worktreeDisplayState(worktree)} | HEAD ${head}${indicatorText}`;
180
+ }
181
+
182
+ function worktreeOmissionLine(omitted: number): string {
183
+ return `${omitted} worktree entr${omitted === 1 ? "y" : "ies"} omitted.`;
184
+ }
185
+
186
+ export function formatListWorktrees(details: ListWorktreesDetails): string {
187
+ const repoRoot = safeWorktreeFormatValue(details.repoRoot, WORKTREE_FORMAT_PATH_LIMIT_CHARS);
188
+ const lines = [`Worktrees for ${repoRoot}:`];
189
+ let omitted = details.omitted;
190
+
191
+ for (const [index, worktree] of details.worktrees.entries()) {
192
+ const line = formatWorktreeEntry(worktree);
193
+ const remaining = details.worktrees.length - index - 1;
194
+ const candidateOmitted = details.omitted + remaining;
195
+ const candidate = [
196
+ ...lines,
197
+ line,
198
+ ...(candidateOmitted > 0 ? [worktreeOmissionLine(candidateOmitted)] : []),
199
+ ].join("\n");
200
+ if (candidate.length > GIT_WORKTREE_SUMMARY_LIMIT_CHARS) {
201
+ omitted += details.worktrees.length - index;
202
+ break;
203
+ }
204
+ lines.push(line);
205
+ }
206
+
207
+ if (omitted > 0) lines.push(worktreeOmissionLine(omitted));
208
+ return lines.join("\n");
209
+ }
210
+
211
+ export function formatCreateWorktree(details: CreateWorktreeDetails): string {
212
+ const cwd = safeWorktreeFormatValue(details.handoff.cwd, WORKTREE_FORMAT_PATH_LIMIT_CHARS);
213
+ const branch = safeWorktreeFormatValue(details.handoff.branch, WORKTREE_FORMAT_BRANCH_LIMIT_CHARS);
214
+ const mode = details.request.branchMode === "new" ? "new local branch" : "existing local branch";
215
+ return `Created linked worktree ${cwd} for ${mode} ${branch}. Verified its canonical path, local branch, HEAD ${shortCommit(details.handoff.head)}, clean working tree, and ready handoff.`;
216
+ }
217
+
218
+ export function formatRemoveWorktree(details: RemoveWorktreeDetails): string {
219
+ const path = safeWorktreeFormatValue(
220
+ details.verified.before.worktree.path,
221
+ WORKTREE_FORMAT_PATH_LIMIT_CHARS,
222
+ );
223
+ const branch = safeWorktreeFormatValue(details.handoff.branch, WORKTREE_FORMAT_BRANCH_LIMIT_CHARS);
224
+ return `Removed linked worktree directory ${path}. Verified it is no longer registered and retained local branch ${branch} at HEAD ${shortCommit(details.handoff.head)}; the removed cwd is not ready for handoff.`;
225
+ }
226
+
227
+ const PULL_REQUEST_INPUT_FIELDS: PullRequestInputField[] = ["headBranch", "baseBranch", "title", "body", "draft"];
228
+
229
+ interface ResolvedPullRequestBranches {
230
+ headBranch: string;
231
+ baseBranch: string;
232
+ autofilledFields: PullRequestInputField[];
233
+ }
234
+
235
+ interface ResolvedPullRequestInput {
236
+ input: PullRequestInput;
237
+ autofilledFields: PullRequestInputField[];
238
+ }
239
+
240
+ function missingPullRequestInputFields(params: PullRequestToolInput): PullRequestInputField[] {
241
+ return PULL_REQUEST_INPUT_FIELDS.filter((field) => params[field] === undefined);
242
+ }
243
+
244
+ function generatedPullRequestTitle(
245
+ headBranch: string,
246
+ commitSubjects: string[],
247
+ tokens: readonly string[],
248
+ ): string {
249
+ if (commitSubjects[0]) return commitSubjects[0];
250
+
251
+ const branchSegment = headBranch.split("/").at(-1) ?? headBranch;
252
+ const words = redactSecrets(branchSegment, tokens).replace(/[-_]+/gu, " ").trim();
253
+ if (!words) return "Update project";
254
+ return `${words.charAt(0).toUpperCase()}${words.slice(1)}`;
255
+ }
256
+
257
+ function escapePullRequestMarkdownText(value: string): string {
258
+ return value.replace(/[!-/:-@[-`{-~]/gu, String.raw`\$&`);
259
+ }
260
+
261
+ function generatedPullRequestBody(commitSubjects: string[]): string {
262
+ if (commitSubjects.length === 0) return "";
263
+ const bullets = commitSubjects.map((subject) => `- ${escapePullRequestMarkdownText(subject)}`);
264
+ return ["## Summary", "", ...bullets, "", "_Generated by BranchMe from commit subjects._"].join("\n");
265
+ }
266
+
267
+ async function requirePullRequestAutofill(
268
+ missingFields: PullRequestInputField[],
269
+ env: NodeJS.ProcessEnv | undefined,
270
+ repoRoot: string,
271
+ signal?: AbortSignal,
272
+ ): Promise<void> {
273
+ if (missingFields.length === 0) return;
274
+ if (await resolvePullRequestAutofill(env, { cwd: repoRoot, signal })) return;
275
+ throw new Error(
276
+ `Missing pull_request fields: ${missingFields.join(", ")}. Provide them explicitly or set BRANCHME_PR_AUTOFILL=true in the process environment or repository .env file.`,
277
+ );
82
278
  }
83
279
 
84
280
  async function validateLocalPullRequestBranchName(
@@ -109,6 +305,60 @@ async function requireExistingValidatedLocalPullRequestBranch(
109
305
  throw new Error(`${field} local branch '${redactSecrets(branchName)}' does not exist.`);
110
306
  }
111
307
 
308
+ async function resolvePullRequestBranches(
309
+ pi: Pick<ExtensionAPI, "exec">,
310
+ ctx: { cwd: string },
311
+ params: PullRequestToolInput,
312
+ env: NodeJS.ProcessEnv | undefined,
313
+ signal?: AbortSignal,
314
+ ): Promise<ResolvedPullRequestBranches> {
315
+ const autofilledFields = missingPullRequestInputFields(params);
316
+ await requirePullRequestAutofill(autofilledFields, env, ctx.cwd, signal);
317
+
318
+ const headBranch = params.headBranch ?? await requireCurrentBranch(pi, ctx, signal);
319
+ const baseBranch = params.baseBranch ?? await inferPullRequestBaseBranch(pi, ctx, headBranch, signal);
320
+ if (headBranch === baseBranch) throw new Error("headBranch and baseBranch must be different branches.");
321
+
322
+ await validateLocalPullRequestBranchName(pi, ctx, headBranch, "headBranch", signal);
323
+ await validateLocalPullRequestBranchName(pi, ctx, baseBranch, "baseBranch", signal);
324
+ await requireExistingValidatedLocalPullRequestBranch(pi, ctx, headBranch, "headBranch", signal);
325
+ await requireExistingValidatedLocalPullRequestBranch(pi, ctx, baseBranch, "baseBranch", signal);
326
+
327
+ return { headBranch, baseBranch, autofilledFields };
328
+ }
329
+
330
+ async function resolvePullRequestInput(
331
+ pi: Pick<ExtensionAPI, "exec">,
332
+ ctx: { cwd: string },
333
+ params: PullRequestToolInput,
334
+ branches: ResolvedPullRequestBranches,
335
+ tokens: readonly string[],
336
+ signal?: AbortSignal,
337
+ ): Promise<ResolvedPullRequestInput> {
338
+ const needsCommitSubjects = params.title === undefined || params.body === undefined;
339
+ const commitSubjects = needsCommitSubjects
340
+ ? await getPullRequestCommitSubjects(
341
+ pi,
342
+ ctx,
343
+ branches.headBranch,
344
+ branches.baseBranch,
345
+ signal,
346
+ tokens,
347
+ )
348
+ : [];
349
+
350
+ return {
351
+ input: {
352
+ headBranch: branches.headBranch,
353
+ baseBranch: branches.baseBranch,
354
+ title: params.title ?? generatedPullRequestTitle(branches.headBranch, commitSubjects, tokens),
355
+ body: params.body ?? generatedPullRequestBody(commitSubjects),
356
+ draft: params.draft ?? false,
357
+ },
358
+ autofilledFields: branches.autofilledFields,
359
+ };
360
+ }
361
+
112
362
  function shortCommit(commit: string): string {
113
363
  return commit.slice(0, 12);
114
364
  }
@@ -288,10 +538,12 @@ export function registerBranchMeTools(pi: Pick<ExtensionAPI, "registerTool" | "e
288
538
  pi.registerTool({
289
539
  name: PULL_REQUEST_TOOL_NAME,
290
540
  label: "Pull Request",
291
- description: "pull_request creates a GitHub pull request in the resolved current repository. pull_request requires headBranch and baseBranch to exist as safe local branch names, requires headBranch to match the GitHub-visible branch commit, and requires baseBranch to be visible on GitHub. Owner-prefixed refs, owner, and repo are never accepted as inputs.",
292
- promptSnippet: "pull_request: create a GitHub pull request in the current repository with all PR fields explicit",
541
+ description: "pull_request creates a GitHub pull request in the resolved current repository. PR fields are optional only when BRANCHME_PR_AUTOFILL=true; pull_request then infers branches, derives title/body from commit subjects, and defaults draft to false. Branches must exist locally and on GitHub, and headBranch must match the GitHub-visible commit. Owner-prefixed refs, owner, and repo are never accepted as inputs.",
542
+ promptSnippet: "pull_request: create a GitHub pull request with explicit fields or optional configured PR field autofill",
293
543
  promptGuidelines: [
294
- "Use pull_request only when the user provides explicit headBranch, baseBranch, title, body, and draft values.",
544
+ "Use pull_request only when the user explicitly asks to create a pull request; pull_request must not create one merely because a branch was pushed.",
545
+ "When automatic Git context reports pull request field autofill disabled, use pull_request only with explicit headBranch, baseBranch, title, body, and draft values.",
546
+ "When automatic Git context reports pull request field autofill enabled, pull_request may omit fields the user did not provide; prefer the user's explicit values whenever present.",
295
547
  "Use pull_request only with existing local branches for headBranch and baseBranch; headBranch must match the GitHub-visible branch commit.",
296
548
  "Do not call push_branch and pull_request in the same tool batch; call pull_request only after push_branch has completed.",
297
549
  "Use pull_request only for the resolved current repository; pull_request never accepts owner, repo, or owner-prefixed branch refs.",
@@ -303,30 +555,31 @@ export function registerBranchMeTools(pi: Pick<ExtensionAPI, "registerTool" | "e
303
555
  const rootCtx = { cwd: repoRoot };
304
556
 
305
557
  return withRepositoryMutationQueue(repoRoot, async () => {
306
- await validateLocalPullRequestBranchName(pi, rootCtx, params.headBranch, "headBranch", signal);
307
- await validateLocalPullRequestBranchName(pi, rootCtx, params.baseBranch, "baseBranch", signal);
308
- await requireExistingValidatedLocalPullRequestBranch(pi, rootCtx, params.headBranch, "headBranch", signal);
309
- await requireExistingValidatedLocalPullRequestBranch(pi, rootCtx, params.baseBranch, "baseBranch", signal);
558
+ const branches = await resolvePullRequestBranches(pi, rootCtx, params, options.env, signal);
310
559
  const repository = await resolveGitHubRepository(pi, rootCtx, signal, options.env);
311
560
  const token = (await resolveGitHubToken(options.env, { cwd: repoRoot, signal })).token;
312
561
 
313
562
  try {
314
- const headBranch = await ensureGitHubBranchExists(repository, params.headBranch, "headBranch", token, {
563
+ const resolved = await resolvePullRequestInput(pi, rootCtx, params, branches, [token], signal);
564
+ const headBranch = await ensureGitHubBranchExists(repository, resolved.input.headBranch, "headBranch", token, {
315
565
  fetchImpl: options.fetchImpl,
316
566
  signal,
317
567
  });
318
- await requireGitHubHeadMatchesLocalBranch(pi, rootCtx, params.headBranch, headBranch.commitSha, signal);
319
- await ensureGitHubBranchExists(repository, params.baseBranch, "baseBranch", token, {
568
+ await requireGitHubHeadMatchesLocalBranch(pi, rootCtx, resolved.input.headBranch, headBranch.commitSha, signal);
569
+ await ensureGitHubBranchExists(repository, resolved.input.baseBranch, "baseBranch", token, {
320
570
  fetchImpl: options.fetchImpl,
321
571
  signal,
322
572
  });
323
- const details = await createGitHubPullRequest(repository, params, token, {
573
+ const details = await createGitHubPullRequest(repository, resolved.input, token, {
324
574
  fetchImpl: options.fetchImpl,
325
575
  signal,
326
576
  });
577
+ const toolDetails: PullRequestToolDetails = resolved.autofilledFields.length > 0
578
+ ? { ...details, autofilledFields: resolved.autofilledFields }
579
+ : details;
327
580
  return {
328
- content: [{ type: "text", text: formatPullRequest(details) }],
329
- details,
581
+ content: [{ type: "text", text: formatPullRequest(details, resolved.autofilledFields) }],
582
+ details: toolDetails,
330
583
  };
331
584
  } catch (error) {
332
585
  const message = error instanceof Error ? error.message : String(error);
@@ -335,4 +588,70 @@ export function registerBranchMeTools(pi: Pick<ExtensionAPI, "registerTool" | "e
335
588
  });
336
589
  },
337
590
  });
591
+
592
+ pi.registerTool({
593
+ name: LIST_WORKTREES_TOOL_NAME,
594
+ label: "List Worktrees",
595
+ description: "list_worktrees reads the current repository's bounded linked-worktree inventory without changing Git or filesystem state. list_worktrees accepts no parameters and reports paths, local branches or detached state, HEADs, and safety indicators.",
596
+ promptSnippet: "list_worktrees: explicitly inspect the bounded current-repository worktree inventory without mutation",
597
+ promptGuidelines: [
598
+ "Use list_worktrees when the user or task explicitly needs the current repository's worktree inventory; list_worktrees is read-only.",
599
+ "Use list_worktrees explicitly because automatic Git context remains focused on the active worktree and does not include worktree inventory.",
600
+ ],
601
+ parameters: EmptyParametersSchema,
602
+ async execute(_toolCallId, _params, signal, _onUpdate, ctx) {
603
+ const details = await listWorktrees(pi, ctx, signal);
604
+ return {
605
+ content: [{ type: "text", text: formatListWorktrees(details) }],
606
+ details,
607
+ };
608
+ },
609
+ });
610
+
611
+ pi.registerTool({
612
+ name: CREATE_WORKTREE_TOOL_NAME,
613
+ label: "Create Worktree",
614
+ description: "create_worktree creates and verifies one linked worktree at an explicit absolute worktreePath for either a new local branch from current HEAD or an existing unoccupied local branch. create_worktree returns exact lossless handoff identity fields and never infers paths, remotes, base refs, detached or orphan modes, or force behavior.",
615
+ promptSnippet: "create_worktree: create and verify a linked worktree at an explicit absolute path with a ready handoff cwd",
616
+ promptGuidelines: [
617
+ "Use create_worktree only when the user explicitly requests worktree creation and provides or approves the exact absolute worktreePath; create_worktree must never infer a filesystem path silently.",
618
+ "Use create_worktree with exactly worktreePath, branchName, and branchMode; create_worktree never accepts force, baseRef, remote, detach, orphan, move, prune, repair, lock, or unlock parameters.",
619
+ "Do not batch create_worktree with dependent worktree mutations; wait for create_worktree to complete and verify its ready exact handoff.cwd before passing that cwd to another agent or session.",
620
+ ],
621
+ parameters: CreateWorktreeParametersSchema,
622
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
623
+ const details = await createWorktree(
624
+ pi,
625
+ ctx,
626
+ params.worktreePath,
627
+ params.branchName,
628
+ params.branchMode,
629
+ signal,
630
+ );
631
+ return {
632
+ content: [{ type: "text", text: formatCreateWorktree(details) }],
633
+ details,
634
+ };
635
+ },
636
+ });
637
+
638
+ pi.registerTool({
639
+ name: REMOVE_WORKTREE_TOOL_NAME,
640
+ label: "Remove Worktree",
641
+ description: "remove_worktree force-free removes one verified clean linked worktree at an explicit absolute worktreePath while retaining and returning its exact local branch identity. remove_worktree rejects main, current, dirty, detached, locked, prunable, missing, and foreign worktrees and never deletes branches.",
642
+ promptSnippet: "remove_worktree: force-free removal of an explicitly selected clean linked worktree while retaining its branch",
643
+ promptGuidelines: [
644
+ "Use remove_worktree only when the user explicitly requests worktree removal and provides or approves the exact absolute worktreePath; remove_worktree must never infer a filesystem path silently.",
645
+ "Use remove_worktree with exactly worktreePath; remove_worktree never accepts force, move, prune, repair, lock, unlock, branch deletion, remote, or refspec parameters.",
646
+ "Do not batch remove_worktree with dependent worktree mutations; wait for remove_worktree to complete and verify its non-ready handoff before continuing.",
647
+ ],
648
+ parameters: RemoveWorktreeParametersSchema,
649
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
650
+ const details = await removeWorktree(pi, ctx, params.worktreePath, signal);
651
+ return {
652
+ content: [{ type: "text", text: formatRemoveWorktree(details) }],
653
+ details,
654
+ };
655
+ },
656
+ });
338
657
  }