@senad-d/branchme 0.3.2 → 0.3.4
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/CHANGELOG.md +8 -2
- package/README.md +28 -12
- package/SECURITY.md +8 -5
- package/docs/PROJECT_DEFINITION_BRIEF.md +11 -8
- package/docs/SMOKE_TEST.md +2 -2
- package/docs/STRUCTURE.md +12 -7
- package/docs/TUI_CAPTURE.md +7 -3
- package/package.json +1 -1
- package/src/commands/branchme-command.ts +7 -3
- package/src/constants.ts +5 -0
- package/src/git-discovery.ts +231 -0
- package/src/git-integration.ts +389 -7
- package/src/git-workflow.ts +14 -4
- package/src/git.ts +98 -7
- package/src/tools/branchme-tools.ts +59 -37
- package/src/tools/workflow-tools.ts +78 -9
- package/src/types.ts +82 -3
package/src/git.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { lstat, realpath, stat } from "node:fs/promises";
|
|
1
|
+
import { lstat, readFile, realpath, stat } from "node:fs/promises";
|
|
2
2
|
import { basename, dirname, isAbsolute, join, normalize, relative, sep } from "node:path";
|
|
3
3
|
import { withFileMutationQueue, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import {
|
|
5
5
|
GIT_BRANCH_ENTRY_LIMIT,
|
|
6
|
+
GIT_BRANCH_PATTERN_LIMIT,
|
|
6
7
|
GIT_BRANCH_RAW_OUTPUT_LIMIT_BYTES,
|
|
7
8
|
GIT_CONTEXT_CHANGE_LIMIT,
|
|
8
9
|
GIT_CONTEXT_RECENT_COMMIT_LIMIT,
|
|
@@ -42,6 +43,7 @@ import type {
|
|
|
42
43
|
GitFileChangeSummary,
|
|
43
44
|
InitRepositoryDetails,
|
|
44
45
|
ListBranchesDetails,
|
|
46
|
+
ListBranchesToolInput,
|
|
45
47
|
ListWorktreesDetails,
|
|
46
48
|
PullBranchDetails,
|
|
47
49
|
PushBranchDetails,
|
|
@@ -629,7 +631,7 @@ function truncateWorktreeValue(value: string, limit: number): string {
|
|
|
629
631
|
return `${value.slice(0, end)}…`;
|
|
630
632
|
}
|
|
631
633
|
|
|
632
|
-
function safeWorktreeValue(value: string, limit: number): string {
|
|
634
|
+
export function safeWorktreeValue(value: string, limit: number): string {
|
|
633
635
|
const redacted = redactSecrets(value);
|
|
634
636
|
const escaped = redacted.replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, escapeGitContextControlCharacter);
|
|
635
637
|
return truncateWorktreeValue(escaped, limit);
|
|
@@ -1273,19 +1275,81 @@ export function parseBranchRefs(output: string): Pick<ListBranchesDetails, "bran
|
|
|
1273
1275
|
return { branches, omitted: records.length - branches.length };
|
|
1274
1276
|
}
|
|
1275
1277
|
|
|
1278
|
+
function validateBranchFilters(filters: ListBranchesToolInput): void {
|
|
1279
|
+
if (filters.kind !== undefined && filters.kind !== "local" && filters.kind !== "remote-tracking") {
|
|
1280
|
+
throw new TypeError("list_branches kind must be 'local' or 'remote-tracking'.");
|
|
1281
|
+
}
|
|
1282
|
+
if (filters.patterns === undefined) return;
|
|
1283
|
+
if (!Array.isArray(filters.patterns) || filters.patterns.length === 0 || filters.patterns.length > GIT_BRANCH_PATTERN_LIMIT) {
|
|
1284
|
+
throw new TypeError(`list_branches patterns must contain 1 to ${GIT_BRANCH_PATTERN_LIMIT} strings.`);
|
|
1285
|
+
}
|
|
1286
|
+
for (const pattern of filters.patterns) {
|
|
1287
|
+
if (typeof pattern !== "string" || !pattern.trim() || pattern.length > GIT_CONTEXT_VALUE_LIMIT_CHARS ||
|
|
1288
|
+
/[\p{Cc}\p{Cf}\u2028\u2029]/u.test(pattern) || pattern.startsWith("-")) {
|
|
1289
|
+
throw new TypeError("list_branches patterns must be nonblank, bounded strings without leading '-' or control characters.");
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
async function readBranchScope(
|
|
1295
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
1296
|
+
ctx: GitCommandContext,
|
|
1297
|
+
patterns: string[],
|
|
1298
|
+
signal: AbortSignal | undefined,
|
|
1299
|
+
kind: string,
|
|
1300
|
+
): Promise<string> {
|
|
1301
|
+
const result = await runGit(pi, ctx, [
|
|
1302
|
+
"branch", "--list", "--no-color", "--no-column", "--sort=refname",
|
|
1303
|
+
// Detached/rebasing pseudo-branches are not refs; render them as empty lines.
|
|
1304
|
+
`--format=%(if:equals=refs)%(refname:rstrip=-1)%(then)${GIT_BRANCH_FORMAT}%(end)`,
|
|
1305
|
+
...(kind === "remote-tracking" ? ["--remotes"] : []), "--", ...patterns,
|
|
1306
|
+
], { signal });
|
|
1307
|
+
return result.stdout;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
async function readFilteredBranchRefs(
|
|
1311
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
1312
|
+
ctx: GitCommandContext,
|
|
1313
|
+
filters: ListBranchesToolInput,
|
|
1314
|
+
signal?: AbortSignal,
|
|
1315
|
+
): Promise<string> {
|
|
1316
|
+
const kinds = filters.kind === undefined ? ["local", "remote-tracking"] : [filters.kind];
|
|
1317
|
+
if (filters.patterns === undefined) {
|
|
1318
|
+
const prefixes = kinds.map((kind) => kind === "local" ? "refs/heads/" : "refs/remotes/");
|
|
1319
|
+
const result = await runGit(pi, ctx, [
|
|
1320
|
+
"for-each-ref", "--sort=refname", `--format=${GIT_BRANCH_FORMAT}`, ...prefixes,
|
|
1321
|
+
], { signal });
|
|
1322
|
+
return result.stdout;
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
// Git applies branch-list glob semantics before the raw-output and entry limits.
|
|
1326
|
+
// Separate scopes keep remote names as origin/topic, not remotes/origin/topic.
|
|
1327
|
+
const outputs = await Promise.all(kinds.map(readBranchScope.bind(undefined, pi, ctx, filters.patterns, signal)));
|
|
1328
|
+
let output = "";
|
|
1329
|
+
for (const scopeOutput of outputs) {
|
|
1330
|
+
if (Buffer.byteLength(output, "utf8") + Buffer.byteLength(scopeOutput, "utf8") > GIT_BRANCH_RAW_OUTPUT_LIMIT_BYTES) {
|
|
1331
|
+
throw new TypeError("Unable to parse branches: ref output exceeded the safety limit.");
|
|
1332
|
+
}
|
|
1333
|
+
for (const record of scopeOutput.split("\n")) {
|
|
1334
|
+
if (record !== "") output += `${record}\n`;
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
return output;
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1276
1340
|
export async function listBranches(
|
|
1277
1341
|
pi: Pick<ExtensionAPI, "exec">,
|
|
1278
1342
|
ctx: GitCommandContext,
|
|
1279
1343
|
signal?: AbortSignal,
|
|
1344
|
+
filters: ListBranchesToolInput = {},
|
|
1280
1345
|
): Promise<ListBranchesDetails> {
|
|
1346
|
+
validateBranchFilters(filters);
|
|
1281
1347
|
const repoRoot = await getGitRoot(pi, ctx, signal);
|
|
1282
1348
|
const rootCtx = { cwd: repoRoot };
|
|
1283
|
-
const
|
|
1284
|
-
|
|
1285
|
-
], { signal });
|
|
1286
|
-
const parsed = parseBranchRefs(result.stdout);
|
|
1349
|
+
const output = await readFilteredBranchRefs(pi, rootCtx, filters, signal);
|
|
1350
|
+
const parsed = parseBranchRefs(output);
|
|
1287
1351
|
const inventory = await collectWorktreeInventory(pi, rootCtx, signal);
|
|
1288
|
-
const rawRecords =
|
|
1352
|
+
const rawRecords = output.split("\n");
|
|
1289
1353
|
for (const [index, branch] of parsed.branches.entries()) {
|
|
1290
1354
|
if (branch.kind !== "local") continue;
|
|
1291
1355
|
const rawName = rawRecords[index].split(NUL_SEPARATOR, 1)[0].slice("refs/heads/".length);
|
|
@@ -2402,6 +2466,33 @@ async function getVerifiedRefCommit(
|
|
|
2402
2466
|
return commit;
|
|
2403
2467
|
}
|
|
2404
2468
|
|
|
2469
|
+
export async function getMergeHeadCommit(
|
|
2470
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
2471
|
+
ctx: GitCommandContext,
|
|
2472
|
+
signal?: AbortSignal,
|
|
2473
|
+
): Promise<string> {
|
|
2474
|
+
const result = await runGit(pi, ctx, ["rev-parse", "--path-format=absolute", "--git-path", "MERGE_HEAD"], {
|
|
2475
|
+
signal, timeout: GIT_STATUS_TIMEOUT_MS,
|
|
2476
|
+
});
|
|
2477
|
+
const path = stripSingleLineTerminator(result.stdout);
|
|
2478
|
+
if (!isAbsolute(path) || /[\p{Cc}\p{Cf}\u2028\u2029]/u.test(path)) {
|
|
2479
|
+
throw new Error("Unable to inspect MERGE_HEAD: Git returned an invalid state path.");
|
|
2480
|
+
}
|
|
2481
|
+
const metadata = await lstat(path);
|
|
2482
|
+
if (!metadata.isFile() || metadata.size > 65) {
|
|
2483
|
+
throw new Error("MERGE_HEAD must contain exactly one commit; multi-head merges are not supported.");
|
|
2484
|
+
}
|
|
2485
|
+
const contents = await readFile(path, { encoding: "utf8", signal });
|
|
2486
|
+
if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})\n?$/iu.test(contents)) {
|
|
2487
|
+
throw new Error("MERGE_HEAD must contain exactly one valid commit identity.");
|
|
2488
|
+
}
|
|
2489
|
+
const commit = await getVerifiedRefCommit(pi, ctx, "MERGE_HEAD", "MERGE_HEAD", signal);
|
|
2490
|
+
if (commit.toLowerCase() !== contents.trimEnd().toLowerCase()) {
|
|
2491
|
+
throw new Error("MERGE_HEAD changed during inspection; inspect the merge before retrying.");
|
|
2492
|
+
}
|
|
2493
|
+
return commit;
|
|
2494
|
+
}
|
|
2495
|
+
|
|
2405
2496
|
export async function getLocalBranchCommit(
|
|
2406
2497
|
pi: Pick<ExtensionAPI, "exec">,
|
|
2407
2498
|
ctx: GitCommandContext,
|
|
@@ -8,8 +8,10 @@ import {
|
|
|
8
8
|
CREATE_BRANCH_TOOL_NAME,
|
|
9
9
|
CREATE_WORKTREE_TOOL_NAME,
|
|
10
10
|
FETCH_BRANCH_TOOL_NAME,
|
|
11
|
+
FETCH_REMOTE_TOOL_NAME,
|
|
12
|
+
GIT_BRANCH_PATTERN_LIMIT,
|
|
11
13
|
GIT_BRANCH_SUMMARY_LIMIT_CHARS,
|
|
12
|
-
|
|
14
|
+
GIT_CONTEXT_VALUE_LIMIT_CHARS,
|
|
13
15
|
GIT_RETIREMENT_SUMMARY_LIMIT_CHARS,
|
|
14
16
|
GIT_WORKTREE_SUMMARY_LIMIT_CHARS,
|
|
15
17
|
INIT_REPOSITORY_TOOL_NAME,
|
|
@@ -47,7 +49,8 @@ import {
|
|
|
47
49
|
withRepositoryMutationQueue,
|
|
48
50
|
} from "../git.ts";
|
|
49
51
|
import { collectGitContext, formatGitContext } from "../git-context.ts";
|
|
50
|
-
import {
|
|
52
|
+
import { fetchRemote } from "../git-discovery.ts";
|
|
53
|
+
import { formatConflictPathList, integrateBranch } from "../git-integration.ts";
|
|
51
54
|
import { retireBranch } from "../git-retirement.ts";
|
|
52
55
|
import { formatLandBranch, landBranch } from "../git-landing.ts";
|
|
53
56
|
import {
|
|
@@ -78,6 +81,28 @@ import type {
|
|
|
78
81
|
|
|
79
82
|
const EmptyParametersSchema = Type.Object({}, { additionalProperties: false });
|
|
80
83
|
|
|
84
|
+
const ListBranchesParametersSchema = Type.Object(
|
|
85
|
+
{
|
|
86
|
+
kind: Type.Optional(StringEnum(["local", "remote-tracking"] as const, {
|
|
87
|
+
description: "Limit discovery to local or remote-tracking branches; omit for both.",
|
|
88
|
+
})),
|
|
89
|
+
patterns: Type.Optional(Type.Array(Type.String({
|
|
90
|
+
minLength: 1,
|
|
91
|
+
maxLength: GIT_CONTEXT_VALUE_LIMIT_CHARS,
|
|
92
|
+
description: "Git branch-list glob on branch names, including remote prefix (for example origin/feat/23-*).",
|
|
93
|
+
}), { minItems: 1, maxItems: GIT_BRANCH_PATTERN_LIMIT, description: "Match any of these patterns before output limits; omit to list all selected branches." })),
|
|
94
|
+
},
|
|
95
|
+
{ additionalProperties: false },
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const FetchRemoteParametersSchema = Type.Object(
|
|
99
|
+
{
|
|
100
|
+
remote: Type.Optional(Type.String({ minLength: 1, description: "Configured remote name; defaults to origin." })),
|
|
101
|
+
prune: Type.Optional(Type.Boolean({ description: "Delete stale cached branch refs only for this remote; defaults to false. Never deletes local branches or tags." })),
|
|
102
|
+
},
|
|
103
|
+
{ additionalProperties: false },
|
|
104
|
+
);
|
|
105
|
+
|
|
81
106
|
const InitRepositoryParametersSchema = Type.Object(
|
|
82
107
|
{
|
|
83
108
|
initialBranch: Type.Optional(Type.String({
|
|
@@ -328,42 +353,17 @@ export function formatRemoveWorktree(details: RemoveWorktreeDetails): string {
|
|
|
328
353
|
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.`;
|
|
329
354
|
}
|
|
330
355
|
|
|
331
|
-
function
|
|
332
|
-
return `${omitted} conflict path${omitted === 1 ? "" : "s"} omitted.`;
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
function formatIntegrationConflict(details: Extract<IntegrateBranchDetails, { status: "conflict" }>): string {
|
|
356
|
+
function formatIntegrationConflict(details: Extract<IntegrateBranchDetails, { status: "conflict" | "conflict_kept" }>): string {
|
|
336
357
|
const source = safeWorktreeFormatValue(details.request.sourceBranch, WORKTREE_FORMAT_BRANCH_LIMIT_CHARS);
|
|
337
358
|
const target = safeWorktreeFormatValue(details.request.targetBranch, WORKTREE_FORMAT_BRANCH_LIMIT_CHARS);
|
|
338
|
-
const
|
|
339
|
-
`integrate_branch found conflicts while integrating ${source} into ${target}; the merge was automatically aborted and exact restoration was verified at target HEAD ${shortCommit(details.verified.heads.after.targetHead)}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
let omitted = details.conflict.omitted;
|
|
343
|
-
|
|
344
|
-
for (const [index, entry] of details.conflict.paths.entries()) {
|
|
345
|
-
const path = safeWorktreeFormatValue(entry.path, WORKTREE_FORMAT_PATH_LIMIT_CHARS);
|
|
346
|
-
const line = `- ${path}`;
|
|
347
|
-
const remaining = details.conflict.paths.length - index - 1;
|
|
348
|
-
const candidateOmitted = details.conflict.omitted + remaining;
|
|
349
|
-
const candidate = [
|
|
350
|
-
...lines,
|
|
351
|
-
line,
|
|
352
|
-
...(candidateOmitted > 0 ? [integrationConflictOmissionLine(candidateOmitted)] : []),
|
|
353
|
-
].join("\n");
|
|
354
|
-
if (candidate.length > GIT_INTEGRATION_SUMMARY_LIMIT_CHARS) {
|
|
355
|
-
omitted += details.conflict.paths.length - index;
|
|
356
|
-
break;
|
|
357
|
-
}
|
|
358
|
-
lines.push(line);
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
if (omitted > 0) lines.push(integrationConflictOmissionLine(omitted));
|
|
362
|
-
return lines.join("\n");
|
|
359
|
+
const header = details.status === "conflict"
|
|
360
|
+
? `integrate_branch found conflicts while integrating ${source} into ${target}; the merge was automatically aborted and exact restoration was verified at target HEAD ${shortCommit(details.verified.heads.after.targetHead)}.`
|
|
361
|
+
: `integrate_branch found conflicts while integrating ${source} into ${target}; the merge was kept in progress at target HEAD ${shortCommit(details.heads.targetHead)} and must be finished with conclude_merge.`;
|
|
362
|
+
return formatConflictPathList(header, details.conflict);
|
|
363
363
|
}
|
|
364
364
|
|
|
365
365
|
export function formatIntegrateBranch(details: IntegrateBranchDetails): string {
|
|
366
|
-
if (details.status === "conflict") return formatIntegrationConflict(details);
|
|
366
|
+
if (details.status === "conflict" || details.status === "conflict_kept") return formatIntegrationConflict(details);
|
|
367
367
|
|
|
368
368
|
const source = safeWorktreeFormatValue(details.request.sourceBranch, WORKTREE_FORMAT_BRANCH_LIMIT_CHARS);
|
|
369
369
|
const target = safeWorktreeFormatValue(details.request.targetBranch, WORKTREE_FORMAT_BRANCH_LIMIT_CHARS);
|
|
@@ -548,15 +548,16 @@ export function registerBranchMeTools(pi: Pick<ExtensionAPI, "registerTool" | "e
|
|
|
548
548
|
pi.registerTool({
|
|
549
549
|
name: LIST_BRANCHES_TOOL_NAME,
|
|
550
550
|
label: "List Branches",
|
|
551
|
-
description: "list_branches reads up to 200 local and remote-tracking branches, including commits, upstream counts, symbolic refs, and worktree occupancy. Read-only; cached remote refs are not fetched. Text is bounded to 4000 characters.",
|
|
551
|
+
description: "list_branches reads up to 200 local and remote-tracking branches, including commits, upstream counts, symbolic refs, and worktree occupancy. Optional kind and Git branch-list patterns filter before limits. Read-only; cached remote refs are not fetched. Text is bounded to 4000 characters.",
|
|
552
552
|
promptSnippet: "list_branches: discover local and cached remote-tracking branches without mutation",
|
|
553
553
|
promptGuidelines: [
|
|
554
554
|
"Use list_branches to discover names and worktree occupancy before branch operations; it never fetches or mutates Git state.",
|
|
555
555
|
"Treat list_branches names and paths as display metadata; redacted or truncated values are not executable identities.",
|
|
556
|
+
"Use list_branches with kind: 'remote-tracking' and patterns such as ['origin/feat/23', 'origin/feat/23-*'] for issue branch discovery; wait for fetch_remote to complete first when fresh remote refs are needed.",
|
|
556
557
|
],
|
|
557
|
-
parameters:
|
|
558
|
-
async execute(_toolCallId,
|
|
559
|
-
const details = await listBranches(pi, ctx, signal);
|
|
558
|
+
parameters: ListBranchesParametersSchema,
|
|
559
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
560
|
+
const details = await listBranches(pi, ctx, signal, params);
|
|
560
561
|
const lines = details.branches.map((branch) =>
|
|
561
562
|
`${branch.current ? "*" : "-"} ${JSON.stringify(branch.name)} (${branch.kind}) ${shortCommit(branch.head)}; upstream ${JSON.stringify(branch.upstream)}; ahead ${branch.ahead ?? "?"}, behind ${branch.behind ?? "?"}; worktrees ${JSON.stringify(branch.worktreePaths)}`);
|
|
562
563
|
const text = [`Branches: ${details.branches.length}; omitted: ${details.omitted}.`, ...lines].join("\n");
|
|
@@ -696,6 +697,27 @@ export function registerBranchMeTools(pi: Pick<ExtensionAPI, "registerTool" | "e
|
|
|
696
697
|
},
|
|
697
698
|
});
|
|
698
699
|
|
|
700
|
+
pi.registerTool({
|
|
701
|
+
name: FETCH_REMOTE_TOOL_NAME,
|
|
702
|
+
label: "Fetch Remote",
|
|
703
|
+
description: "fetch_remote refreshes all branch refs for one configured remote (default origin). Optional prune deletes only that remote's stale cached branch refs; default false. Uses an internal atomic heads-to-remote-tracking refspec, ignores configured refmaps, and disables tags, tag pruning, and submodules. Requires the files ref backend and non-overlapping remote namespaces; refuses unsafe symbolic destinations. Preserves safe remote HEAD aliases by excluding the remote branch named HEAD. Never changes local branches, checkout, upstream configuration, or working-tree files.",
|
|
704
|
+
promptSnippet: "fetch_remote: refresh a configured remote's branch cache with optional remote-tracking-only pruning",
|
|
705
|
+
promptGuidelines: [
|
|
706
|
+
"Use fetch_remote when fresh discovery of unknown remote branch names is needed; fetch_branch remains the narrow tool for a known branch.",
|
|
707
|
+
"Use fetch_remote with prune: true only when stale cached branch deletion is intended; it never deletes local branches, tags, or branches on the server.",
|
|
708
|
+
"Call fetch_remote by itself and wait for it to complete before list_branches, track_branch, or other dependent Git operations; do not batch Git mutations.",
|
|
709
|
+
"fetch_remote accepts only a configured remote name and optional prune boolean; never pass URLs, refspecs, force, tags, or checkout controls.",
|
|
710
|
+
],
|
|
711
|
+
parameters: FetchRemoteParametersSchema,
|
|
712
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
713
|
+
const details = await fetchRemote(pi, ctx, params.remote ?? "origin", params.prune ?? false, signal);
|
|
714
|
+
return {
|
|
715
|
+
content: [{ type: "text", text: `Fetched branch cache for remote ${details.remote}${details.prune ? " and pruned its stale remote-tracking branch refs" : " without pruning"}. No local branches or tags changed.` }],
|
|
716
|
+
details,
|
|
717
|
+
};
|
|
718
|
+
},
|
|
719
|
+
});
|
|
720
|
+
|
|
699
721
|
pi.registerTool({
|
|
700
722
|
name: PULL_BRANCH_TOOL_NAME,
|
|
701
723
|
label: "Pull Branch",
|
|
@@ -1,11 +1,57 @@
|
|
|
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
|
-
import {
|
|
4
|
-
|
|
4
|
+
import {
|
|
5
|
+
CONCLUDE_MERGE_TOOL_NAME, GIT_CONTEXT_VALUE_LIMIT_CHARS, PULL_REQUEST_STATUS_TOOL_NAME, TRACK_BRANCH_TOOL_NAME,
|
|
6
|
+
UPDATE_FROM_BASE_TOOL_NAME,
|
|
7
|
+
} from "../constants.ts";
|
|
8
|
+
import { getGitRoot, requireCurrentBranch, safeWorktreeValue } from "../git.ts";
|
|
9
|
+
import { concludeMerge, formatConflictPathList } from "../git-integration.ts";
|
|
5
10
|
import { findGitHubPullRequest, getGitHubPullRequest, resolveGitHubRepository, resolveGitHubToken } from "../github.ts";
|
|
6
|
-
import type { PullRequestStatusDetails } from "../types.ts";
|
|
11
|
+
import type { ConcludeMergeDetails, PullRequestStatusDetails } from "../types.ts";
|
|
7
12
|
import type { BranchMeToolOptions } from "./branchme-tools.ts";
|
|
8
|
-
import { trackBranch, updateFromBase } from "../git-workflow.ts";
|
|
13
|
+
import { trackBranch, updateFromBase, type UpdateFromBaseDetails } from "../git-workflow.ts";
|
|
14
|
+
|
|
15
|
+
function shortCommit(commit: string): string {
|
|
16
|
+
return commit.slice(0, 12);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function branchLabel(branch: string): string {
|
|
20
|
+
return safeWorktreeValue(branch, GIT_CONTEXT_VALUE_LIMIT_CHARS);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function formatUpdateFromBase(details: UpdateFromBaseDetails): string {
|
|
24
|
+
const { integration } = details;
|
|
25
|
+
const source = branchLabel(integration.request.sourceBranch);
|
|
26
|
+
const target = branchLabel(integration.request.targetBranch);
|
|
27
|
+
if (integration.status === "conflict") {
|
|
28
|
+
return formatConflictPathList(
|
|
29
|
+
`update_from_base: conflict. Merging ${source} into ${target} conflicted; the merge was automatically aborted and restoration was verified at HEAD ${shortCommit(integration.verified.heads.after.targetHead)}. Rerun with keepConflicts: true to keep the conflicted merge in progress for resolution.`,
|
|
30
|
+
integration.conflict,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
if (integration.status === "conflict_kept") {
|
|
34
|
+
return formatConflictPathList(
|
|
35
|
+
`update_from_base: conflict_kept. Merging ${source} (${shortCommit(integration.heads.sourceHead)}) into ${target} conflicted; the merge is in progress with MERGE_HEAD set and HEAD unchanged at ${shortCommit(integration.heads.targetHead)}. Remove the conflict markers in the listed paths, then run conclude_merge with action "conclude" to commit the merge or action "abort" to restore the branch.`,
|
|
36
|
+
integration.conflict,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
return `update_from_base: ${integration.status}. Base integration verified without rewriting published history.`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function formatConcludeMerge(details: ConcludeMergeDetails): string {
|
|
43
|
+
const branch = branchLabel(details.branch);
|
|
44
|
+
if (details.status === "aborted") {
|
|
45
|
+
return `conclude_merge: aborted. git merge --abort restored ${branch} to HEAD ${shortCommit(details.head)}; MERGE_HEAD ${shortCommit(details.mergeHead)} is cleared, no Git operation is in progress, and the working tree is clean.`;
|
|
46
|
+
}
|
|
47
|
+
const count = details.resolvedPaths.length;
|
|
48
|
+
return formatConflictPathList(
|
|
49
|
+
`conclude_merge: concluded. Committed merge ${shortCommit(details.heads.after)} on ${branch} with parents ${shortCommit(details.parents.first)} (previous HEAD) and ${shortCommit(details.parents.second)} (MERGE_HEAD); MERGE_HEAD is cleared and both parents were verified. Only the ${count} resolved path${count === 1 ? "" : "s"} listed below ${count === 1 ? "was" : "were"} staged during this call; other working-tree changes remain unstaged. The commit includes all previously staged entries.`,
|
|
50
|
+
{ paths: details.resolvedPaths, omitted: 0 },
|
|
51
|
+
"Resolved paths:",
|
|
52
|
+
"resolved path",
|
|
53
|
+
);
|
|
54
|
+
}
|
|
9
55
|
|
|
10
56
|
function formatPullRequestStatus(pullRequest: PullRequestStatusDetails | null): string {
|
|
11
57
|
if (!pullRequest) return "No matching pull request found.";
|
|
@@ -47,20 +93,43 @@ export function registerWorkflowTools(pi: Pick<ExtensionAPI, "registerTool" | "e
|
|
|
47
93
|
pi.registerTool({
|
|
48
94
|
name: UPDATE_FROM_BASE_TOOL_NAME,
|
|
49
95
|
label: "Update From Base",
|
|
50
|
-
description: "update_from_base fetches one remote base and merges its captured commit into the clean current feature branch with verified no-op, fast-forward, merge-commit, or automatically aborted
|
|
51
|
-
promptSnippet: "update_from_base: merge a fresh remote base into the current feature without rewriting published history",
|
|
96
|
+
description: "update_from_base fetches one remote base and merges its captured commit into the clean current feature branch with verified no-op, fast-forward, merge-commit, or conflict results. A conflict is automatically aborted with restoration verified unless keepConflicts is true, which leaves the conflicted merge in progress for conclude_merge. Never rebases, pushes, stashes, or changes upstream configuration.",
|
|
97
|
+
promptSnippet: "update_from_base: merge a fresh remote base into the current feature without rewriting published history; keepConflicts keeps a conflicted merge in progress",
|
|
52
98
|
promptGuidelines: [
|
|
53
99
|
"Use update_from_base only when asked to update the current feature branch from an explicit baseBranch; remote defaults to origin.",
|
|
54
|
-
"Run update_from_base by itself. It fetches the base and uses the fixed normal-merge policy; conflicts are automatically aborted with restoration verified.",
|
|
55
|
-
"update_from_base
|
|
100
|
+
"Run update_from_base by itself. It fetches the base and uses the fixed normal-merge policy; by default conflicts are automatically aborted with restoration verified and the conflict paths are listed.",
|
|
101
|
+
"Pass keepConflicts: true to update_from_base only when a file-editing step will resolve the listed paths; the result conflict_kept leaves MERGE_HEAD set, and conclude_merge must then conclude or abort it before any other Git mutation.",
|
|
102
|
+
"update_from_base preserves published history and upstream configuration; it never resolves conflict content itself.",
|
|
56
103
|
],
|
|
57
104
|
parameters: Type.Object({
|
|
58
105
|
baseBranch: Type.String({ minLength: 1, description: "Exact base branch on the remote, for example main." }),
|
|
59
106
|
remote: Type.Optional(Type.String({ minLength: 1, description: "Configured remote; defaults to origin." })),
|
|
107
|
+
keepConflicts: Type.Optional(Type.Boolean({ description: "When true, a conflicted merge is left in progress (MERGE_HEAD set, conflicted paths unmerged) for conclude_merge instead of being aborted; defaults to false." })),
|
|
60
108
|
}, { additionalProperties: false }),
|
|
61
109
|
async execute(_id, params, signal, _update, ctx) {
|
|
62
110
|
const details = await updateFromBase(pi, ctx, params, signal);
|
|
63
|
-
return { content: [{ type: "text", text:
|
|
111
|
+
return { content: [{ type: "text", text: formatUpdateFromBase(details) }], details };
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
pi.registerTool({
|
|
115
|
+
name: CONCLUDE_MERGE_TOOL_NAME,
|
|
116
|
+
label: "Conclude Merge",
|
|
117
|
+
description: "conclude_merge finishes or abandons the in-progress merge on the current checkout, normally one kept by update_from_base with keepConflicts: true. action \"conclude\" checks unmerged working-tree paths and staged blobs for default/custom-sized conflict marker lines, stages exactly the unmerged paths, checks the candidate index again, and commits the full index with Git's prepared merge message, then verifies the two-parent result; action \"abort\" runs git merge --abort and verifies restoration. conclude_merge never stages other changes, accepts no commit message, and never pushes.",
|
|
118
|
+
promptSnippet: "conclude_merge: commit or abort the kept in-progress merge after conflict markers are removed",
|
|
119
|
+
promptGuidelines: [
|
|
120
|
+
"Use conclude_merge only after update_from_base reported conflict_kept, or when a single-head merge is otherwise in progress on the current checkout; run conclude_merge by itself. Multi-head merges are refused before mutation.",
|
|
121
|
+
"Before conclude_merge with action conclude, a file-editing step must remove every <<<<<<<, |||||||, =======, and >>>>>>> marker line from the listed paths; conclude_merge refuses and names the paths that still contain markers.",
|
|
122
|
+
"conclude_merge with action conclude stages only the currently unmerged paths and uses Git's prepared merge message; unrelated unstaged edits stay unstaged and are not part of the merge commit. Git commits the entire index, including previously staged entries: do not stage unrelated edits during the merge.",
|
|
123
|
+
"conclude_merge with action abort restores the branch with git merge --abort and verifies a clean idle checkout; conclude_merge refuses when no merge is in progress.",
|
|
124
|
+
],
|
|
125
|
+
parameters: Type.Object({
|
|
126
|
+
action: StringEnum(["conclude", "abort"] as const, {
|
|
127
|
+
description: "conclude stages the resolved formerly unmerged paths and commits the merge; abort runs git merge --abort and verifies restoration.",
|
|
128
|
+
}),
|
|
129
|
+
}, { additionalProperties: false }),
|
|
130
|
+
async execute(_id, params, signal, _update, ctx) {
|
|
131
|
+
const details = await concludeMerge(pi, ctx, params, signal);
|
|
132
|
+
return { content: [{ type: "text", text: formatConcludeMerge(details) }], details };
|
|
64
133
|
},
|
|
65
134
|
});
|
|
66
135
|
pi.registerTool({
|
package/src/types.ts
CHANGED
|
@@ -29,7 +29,25 @@ export interface BranchStatusToolInput {
|
|
|
29
29
|
ancestry?: BranchStatusAncestryQuery;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
export
|
|
32
|
+
export interface ListBranchesToolInput {
|
|
33
|
+
kind?: "local" | "remote-tracking";
|
|
34
|
+
/** Git branch-list glob patterns on names, including the remote prefix for remote-tracking refs. */
|
|
35
|
+
patterns?: string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface FetchRemoteToolInput {
|
|
39
|
+
remote?: string;
|
|
40
|
+
prune?: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface FetchRemoteDetails {
|
|
44
|
+
action: "fetch_remote";
|
|
45
|
+
repoRoot: string;
|
|
46
|
+
remote: string;
|
|
47
|
+
prune: boolean;
|
|
48
|
+
refspec: string;
|
|
49
|
+
output: string;
|
|
50
|
+
}
|
|
33
51
|
|
|
34
52
|
export interface BranchEntry {
|
|
35
53
|
name: string;
|
|
@@ -318,7 +336,7 @@ export interface PushBranchDetails {
|
|
|
318
336
|
output: string;
|
|
319
337
|
}
|
|
320
338
|
|
|
321
|
-
export type IntegrateBranchStatus = "already_integrated" | "fast_forward" | "merge_commit" | "conflict";
|
|
339
|
+
export type IntegrateBranchStatus = "already_integrated" | "fast_forward" | "merge_commit" | "conflict" | "conflict_kept";
|
|
322
340
|
|
|
323
341
|
export interface IntegrateBranchHeads {
|
|
324
342
|
sourceHead: string;
|
|
@@ -406,11 +424,72 @@ export interface IntegrateBranchConflictDetails extends IntegrateBranchDetailsBa
|
|
|
406
424
|
};
|
|
407
425
|
}
|
|
408
426
|
|
|
427
|
+
/** A conflicted merge deliberately left in progress (update_from_base keepConflicts); conclude_merge finishes or abandons it. */
|
|
428
|
+
export interface IntegrateBranchConflictKeptDetails {
|
|
429
|
+
action: "integrate_branch";
|
|
430
|
+
status: "conflict_kept";
|
|
431
|
+
mergeExecuted: true;
|
|
432
|
+
request: IntegrateBranchToolInput;
|
|
433
|
+
/** targetHead is the unchanged HEAD; sourceHead is the commit recorded in MERGE_HEAD. */
|
|
434
|
+
heads: IntegrateBranchHeads;
|
|
435
|
+
conflict: {
|
|
436
|
+
paths: IntegrateBranchConflictPathEntry[];
|
|
437
|
+
omitted: number;
|
|
438
|
+
kept: true;
|
|
439
|
+
mergeInProgress: true;
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
409
443
|
export type IntegrateBranchDetails =
|
|
410
444
|
| IntegrateBranchAlreadyIntegratedDetails
|
|
411
445
|
| IntegrateBranchFastForwardDetails
|
|
412
446
|
| IntegrateBranchMergeCommitDetails
|
|
413
|
-
| IntegrateBranchConflictDetails
|
|
447
|
+
| IntegrateBranchConflictDetails
|
|
448
|
+
| IntegrateBranchConflictKeptDetails;
|
|
449
|
+
|
|
450
|
+
export type ConcludeMergeAction = "conclude" | "abort";
|
|
451
|
+
|
|
452
|
+
export interface ConcludeMergeToolInput {
|
|
453
|
+
action: ConcludeMergeAction;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export interface ConcludeMergeConcludedDetails {
|
|
457
|
+
action: "conclude_merge";
|
|
458
|
+
status: "concluded";
|
|
459
|
+
request: ConcludeMergeToolInput;
|
|
460
|
+
repoRoot: string;
|
|
461
|
+
branch: string;
|
|
462
|
+
heads: {
|
|
463
|
+
before: string;
|
|
464
|
+
after: string;
|
|
465
|
+
};
|
|
466
|
+
parents: {
|
|
467
|
+
first: string;
|
|
468
|
+
second: string;
|
|
469
|
+
};
|
|
470
|
+
mergeHead: string;
|
|
471
|
+
/** Exactly the formerly unmerged paths that were staged for the merge commit. */
|
|
472
|
+
resolvedPaths: IntegrateBranchConflictPathEntry[];
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
export interface ConcludeMergeAbortedDetails {
|
|
476
|
+
action: "conclude_merge";
|
|
477
|
+
status: "aborted";
|
|
478
|
+
request: ConcludeMergeToolInput;
|
|
479
|
+
repoRoot: string;
|
|
480
|
+
branch: string;
|
|
481
|
+
head: string;
|
|
482
|
+
mergeHead: string;
|
|
483
|
+
restoration: {
|
|
484
|
+
verified: true;
|
|
485
|
+
headRestored: true;
|
|
486
|
+
branchPreserved: true;
|
|
487
|
+
operationStateCleared: true;
|
|
488
|
+
cleanWorktree: true;
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
export type ConcludeMergeDetails = ConcludeMergeConcludedDetails | ConcludeMergeAbortedDetails;
|
|
414
493
|
|
|
415
494
|
export type RetireBranchMode = "merged" | "forced_unmerged";
|
|
416
495
|
|