@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/.env.example +6 -2
- package/CHANGELOG.md +9 -5
- package/README.md +96 -19
- package/SECURITY.md +25 -7
- package/docs/PROJECT_DEFINITION_BRIEF.md +112 -77
- package/docs/SMOKE_TEST.md +21 -11
- package/docs/STRUCTURE.md +22 -17
- package/docs/TUI_CAPTURE.md +53 -22
- package/package.json +11 -7
- package/src/commands/branchme-command.ts +10 -1
- package/src/constants.ts +15 -0
- package/src/git-context.ts +22 -2
- package/src/git.ts +1124 -4
- package/src/github.ts +76 -27
- package/src/tools/branchme-tools.ts +340 -21
- package/src/types.ts +120 -0
- package/src/ui/branchme-panel.ts +14 -3
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
|
|
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
|
-
|
|
190
|
-
|
|
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
|
|
201
|
-
const
|
|
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 (!
|
|
239
|
+
if (!isSupportedDotEnvKey(key)) continue;
|
|
213
240
|
|
|
214
|
-
|
|
241
|
+
values[key] = parseDotEnvValue(assignment.slice(separatorIndex + 1).trimStart());
|
|
215
242
|
}
|
|
216
243
|
|
|
217
|
-
return
|
|
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(
|
|
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
|
|
261
|
+
async function readDotEnvValues(
|
|
235
262
|
cwd: string | undefined,
|
|
236
263
|
signal?: AbortSignal,
|
|
237
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
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.
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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.
|
|
292
|
-
promptSnippet: "pull_request: create a GitHub pull request
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
319
|
-
await ensureGitHubBranchExists(repository,
|
|
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,
|
|
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
|
}
|