@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/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 result = await runGit(pi, rootCtx, [
1284
- "for-each-ref", "--sort=refname", `--format=${GIT_BRANCH_FORMAT}`, "refs/heads/", "refs/remotes/",
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 = result.stdout.split("\n");
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
- GIT_INTEGRATION_SUMMARY_LIMIT_CHARS,
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 { integrateBranch } from "../git-integration.ts";
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 integrationConflictOmissionLine(omitted: number): string {
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 lines = [
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
- "Conflict paths:",
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: EmptyParametersSchema,
558
- async execute(_toolCallId, _params, signal, _onUpdate, ctx) {
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 { PULL_REQUEST_STATUS_TOOL_NAME, TRACK_BRANCH_TOOL_NAME, UPDATE_FROM_BASE_TOOL_NAME } from "../constants.ts";
4
- import { getGitRoot, requireCurrentBranch } from "../git.ts";
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 conflict results. Never rebases, pushes, stashes, or changes upstream configuration.",
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 preserves published history and upstream configuration; committing conflict-resolution changes belongs to a separate workflow.",
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: `update_from_base: ${details.status}. ${details.status === "conflict" ? "Merge aborted and restoration verified; inspect conflict paths in details." : "Base integration verified without rewriting published history."}` }], details };
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 type ListBranchesToolInput = Record<string, never>;
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