@senad-d/branchme 0.3.1 → 0.3.3

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,4 +1,4 @@
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 {
@@ -629,7 +629,7 @@ function truncateWorktreeValue(value: string, limit: number): string {
629
629
  return `${value.slice(0, end)}…`;
630
630
  }
631
631
 
632
- function safeWorktreeValue(value: string, limit: number): string {
632
+ export function safeWorktreeValue(value: string, limit: number): string {
633
633
  const redacted = redactSecrets(value);
634
634
  const escaped = redacted.replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, escapeGitContextControlCharacter);
635
635
  return truncateWorktreeValue(escaped, limit);
@@ -954,6 +954,14 @@ export async function canonicalizePathAllowMissing(path: string): Promise<string
954
954
  }
955
955
  }
956
956
 
957
+ async function resolveWorktreeInventoryEntry(
958
+ record: ParsedWorktreeRecord,
959
+ index: number,
960
+ ): Promise<WorktreeInventoryEntry> {
961
+ const canonicalPath = await canonicalizePathAllowMissing(record.rawPath);
962
+ return { record, index, canonicalPath };
963
+ }
964
+
957
965
  export async function collectWorktreeInventory(
958
966
  pi: Pick<ExtensionAPI, "exec">,
959
967
  ctx: GitCommandContext,
@@ -971,10 +979,11 @@ export async function collectWorktreeInventory(
971
979
  throw new Error("Unable to inspect worktrees: current repository path could not be resolved.");
972
980
  }
973
981
 
974
- const entries: WorktreeInventoryEntry[] = [];
982
+ const entryPromises: Promise<WorktreeInventoryEntry>[] = [];
975
983
  for (const [index, record] of records.entries()) {
976
- entries.push({ record, index, canonicalPath: await canonicalizePathAllowMissing(record.rawPath) });
984
+ entryPromises.push(resolveWorktreeInventoryEntry(record, index));
977
985
  }
986
+ const entries = await Promise.all(entryPromises);
978
987
  return { repoRoot, canonicalCurrentPath, entries };
979
988
  }
980
989
 
@@ -983,16 +992,20 @@ export function pathIsInsideOrEqual(candidatePath: string, boundaryPath: string)
983
992
  return relation === "" || (relation !== ".." && !relation.startsWith(`..${sep}`) && !isAbsolute(relation));
984
993
  }
985
994
 
986
- async function requireCanonicalCreationPath(worktreePath: string): Promise<string> {
987
- const parentPath = dirname(worktreePath);
988
- let parentStats;
995
+ async function requireCanonicalCreationParent(parentPath: string): Promise<string> {
989
996
  try {
990
- parentStats = await stat(parentPath);
991
- } catch {
992
- throw new Error(`worktreePath parent ${safeWorktreePathLabel(parentPath)} must exist as a directory.`);
993
- }
994
- if (!parentStats.isDirectory()) {
995
- throw new Error(`worktreePath parent ${safeWorktreePathLabel(parentPath)} must be a directory.`);
997
+ // lstat distinguishes a missing directory from a dangling symlink.
998
+ await lstat(parentPath);
999
+ } catch (error) {
1000
+ if (filesystemErrorCode(error) === "ENOTDIR") {
1001
+ throw new Error(`worktreePath parent ${safeWorktreePathLabel(parentPath)} must be a directory.`);
1002
+ }
1003
+ const ancestorPath = dirname(parentPath);
1004
+ if (filesystemErrorCode(error) !== "ENOENT" || ancestorPath === parentPath) {
1005
+ throw new Error(`worktreePath parent ${safeWorktreePathLabel(parentPath)} could not be inspected.`);
1006
+ }
1007
+ // Resolve missing ancestors without writing; Git creates them after preflight.
1008
+ return join(await requireCanonicalCreationParent(ancestorPath), basename(parentPath));
996
1009
  }
997
1010
 
998
1011
  let canonicalParent: string;
@@ -1001,12 +1014,25 @@ async function requireCanonicalCreationPath(worktreePath: string): Promise<strin
1001
1014
  } catch {
1002
1015
  throw new Error(`worktreePath parent ${safeWorktreePathLabel(parentPath)} could not be resolved.`);
1003
1016
  }
1017
+ let parentStats;
1018
+ try {
1019
+ parentStats = await stat(canonicalParent);
1020
+ } catch {
1021
+ throw new Error(`worktreePath parent ${safeWorktreePathLabel(parentPath)} could not be inspected.`);
1022
+ }
1023
+ if (!parentStats.isDirectory()) {
1024
+ throw new Error(`worktreePath parent ${safeWorktreePathLabel(parentPath)} must be a directory.`);
1025
+ }
1026
+ return canonicalParent;
1027
+ }
1004
1028
 
1029
+ async function requireCanonicalCreationPath(worktreePath: string): Promise<string> {
1030
+ const canonicalParent = await requireCanonicalCreationParent(dirname(worktreePath));
1005
1031
  const canonicalPath = join(canonicalParent, basename(worktreePath));
1006
1032
  try {
1007
1033
  await lstat(canonicalPath);
1008
1034
  } catch (error) {
1009
- if (isMissingFilesystemPath(error)) return canonicalPath;
1035
+ if (filesystemErrorCode(error) === "ENOENT") return canonicalPath;
1010
1036
  throw new Error(`worktreePath destination ${safeWorktreePathLabel(canonicalPath)} could not be inspected.`);
1011
1037
  }
1012
1038
  throw new Error(`worktreePath destination ${safeWorktreePathLabel(canonicalPath)} already exists.`);
@@ -1092,10 +1118,8 @@ export async function getGitOperationState(
1092
1118
  }
1093
1119
 
1094
1120
  const active: GitOperationKind[] = [];
1095
- const presentMarkers: boolean[] = [];
1096
- for (const [index, path] of paths.entries()) {
1097
- const present = await gitOperationMarkerExists(path);
1098
- presentMarkers.push(present);
1121
+ const presentMarkers = await Promise.all(paths.map(gitOperationMarkerExists));
1122
+ for (const [index, present] of presentMarkers.entries()) {
1099
1123
  if (!present) continue;
1100
1124
  const operation = GIT_OPERATION_MARKERS[index][1];
1101
1125
  if (!active.includes(operation)) active.push(operation);
@@ -2378,6 +2402,33 @@ async function getVerifiedRefCommit(
2378
2402
  return commit;
2379
2403
  }
2380
2404
 
2405
+ export async function getMergeHeadCommit(
2406
+ pi: Pick<ExtensionAPI, "exec">,
2407
+ ctx: GitCommandContext,
2408
+ signal?: AbortSignal,
2409
+ ): Promise<string> {
2410
+ const result = await runGit(pi, ctx, ["rev-parse", "--path-format=absolute", "--git-path", "MERGE_HEAD"], {
2411
+ signal, timeout: GIT_STATUS_TIMEOUT_MS,
2412
+ });
2413
+ const path = stripSingleLineTerminator(result.stdout);
2414
+ if (!isAbsolute(path) || /[\p{Cc}\p{Cf}\u2028\u2029]/u.test(path)) {
2415
+ throw new Error("Unable to inspect MERGE_HEAD: Git returned an invalid state path.");
2416
+ }
2417
+ const metadata = await lstat(path);
2418
+ if (!metadata.isFile() || metadata.size > 65) {
2419
+ throw new Error("MERGE_HEAD must contain exactly one commit; multi-head merges are not supported.");
2420
+ }
2421
+ const contents = await readFile(path, { encoding: "utf8", signal });
2422
+ if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})\n?$/iu.test(contents)) {
2423
+ throw new Error("MERGE_HEAD must contain exactly one valid commit identity.");
2424
+ }
2425
+ const commit = await getVerifiedRefCommit(pi, ctx, "MERGE_HEAD", "MERGE_HEAD", signal);
2426
+ if (commit.toLowerCase() !== contents.trimEnd().toLowerCase()) {
2427
+ throw new Error("MERGE_HEAD changed during inspection; inspect the merge before retrying.");
2428
+ }
2429
+ return commit;
2430
+ }
2431
+
2381
2432
  export async function getLocalBranchCommit(
2382
2433
  pi: Pick<ExtensionAPI, "exec">,
2383
2434
  ctx: GitCommandContext,
@@ -9,7 +9,6 @@ import {
9
9
  CREATE_WORKTREE_TOOL_NAME,
10
10
  FETCH_BRANCH_TOOL_NAME,
11
11
  GIT_BRANCH_SUMMARY_LIMIT_CHARS,
12
- GIT_INTEGRATION_SUMMARY_LIMIT_CHARS,
13
12
  GIT_RETIREMENT_SUMMARY_LIMIT_CHARS,
14
13
  GIT_WORKTREE_SUMMARY_LIMIT_CHARS,
15
14
  INIT_REPOSITORY_TOOL_NAME,
@@ -47,7 +46,7 @@ import {
47
46
  withRepositoryMutationQueue,
48
47
  } from "../git.ts";
49
48
  import { collectGitContext, formatGitContext } from "../git-context.ts";
50
- import { integrateBranch } from "../git-integration.ts";
49
+ import { formatConflictPathList, integrateBranch } from "../git-integration.ts";
51
50
  import { retireBranch } from "../git-retirement.ts";
52
51
  import { formatLandBranch, landBranch } from "../git-landing.ts";
53
52
  import {
@@ -328,42 +327,17 @@ export function formatRemoveWorktree(details: RemoveWorktreeDetails): string {
328
327
  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
328
  }
330
329
 
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 {
330
+ function formatIntegrationConflict(details: Extract<IntegrateBranchDetails, { status: "conflict" | "conflict_kept" }>): string {
336
331
  const source = safeWorktreeFormatValue(details.request.sourceBranch, WORKTREE_FORMAT_BRANCH_LIMIT_CHARS);
337
332
  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");
333
+ const header = details.status === "conflict"
334
+ ? `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)}.`
335
+ : `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.`;
336
+ return formatConflictPathList(header, details.conflict);
363
337
  }
364
338
 
365
339
  export function formatIntegrateBranch(details: IntegrateBranchDetails): string {
366
- if (details.status === "conflict") return formatIntegrationConflict(details);
340
+ if (details.status === "conflict" || details.status === "conflict_kept") return formatIntegrationConflict(details);
367
341
 
368
342
  const source = safeWorktreeFormatValue(details.request.sourceBranch, WORKTREE_FORMAT_BRANCH_LIMIT_CHARS);
369
343
  const target = safeWorktreeFormatValue(details.request.targetBranch, WORKTREE_FORMAT_BRANCH_LIMIT_CHARS);
@@ -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
@@ -318,7 +318,7 @@ export interface PushBranchDetails {
318
318
  output: string;
319
319
  }
320
320
 
321
- export type IntegrateBranchStatus = "already_integrated" | "fast_forward" | "merge_commit" | "conflict";
321
+ export type IntegrateBranchStatus = "already_integrated" | "fast_forward" | "merge_commit" | "conflict" | "conflict_kept";
322
322
 
323
323
  export interface IntegrateBranchHeads {
324
324
  sourceHead: string;
@@ -406,11 +406,72 @@ export interface IntegrateBranchConflictDetails extends IntegrateBranchDetailsBa
406
406
  };
407
407
  }
408
408
 
409
+ /** A conflicted merge deliberately left in progress (update_from_base keepConflicts); conclude_merge finishes or abandons it. */
410
+ export interface IntegrateBranchConflictKeptDetails {
411
+ action: "integrate_branch";
412
+ status: "conflict_kept";
413
+ mergeExecuted: true;
414
+ request: IntegrateBranchToolInput;
415
+ /** targetHead is the unchanged HEAD; sourceHead is the commit recorded in MERGE_HEAD. */
416
+ heads: IntegrateBranchHeads;
417
+ conflict: {
418
+ paths: IntegrateBranchConflictPathEntry[];
419
+ omitted: number;
420
+ kept: true;
421
+ mergeInProgress: true;
422
+ };
423
+ }
424
+
409
425
  export type IntegrateBranchDetails =
410
426
  | IntegrateBranchAlreadyIntegratedDetails
411
427
  | IntegrateBranchFastForwardDetails
412
428
  | IntegrateBranchMergeCommitDetails
413
- | IntegrateBranchConflictDetails;
429
+ | IntegrateBranchConflictDetails
430
+ | IntegrateBranchConflictKeptDetails;
431
+
432
+ export type ConcludeMergeAction = "conclude" | "abort";
433
+
434
+ export interface ConcludeMergeToolInput {
435
+ action: ConcludeMergeAction;
436
+ }
437
+
438
+ export interface ConcludeMergeConcludedDetails {
439
+ action: "conclude_merge";
440
+ status: "concluded";
441
+ request: ConcludeMergeToolInput;
442
+ repoRoot: string;
443
+ branch: string;
444
+ heads: {
445
+ before: string;
446
+ after: string;
447
+ };
448
+ parents: {
449
+ first: string;
450
+ second: string;
451
+ };
452
+ mergeHead: string;
453
+ /** Exactly the formerly unmerged paths that were staged for the merge commit. */
454
+ resolvedPaths: IntegrateBranchConflictPathEntry[];
455
+ }
456
+
457
+ export interface ConcludeMergeAbortedDetails {
458
+ action: "conclude_merge";
459
+ status: "aborted";
460
+ request: ConcludeMergeToolInput;
461
+ repoRoot: string;
462
+ branch: string;
463
+ head: string;
464
+ mergeHead: string;
465
+ restoration: {
466
+ verified: true;
467
+ headRestored: true;
468
+ branchPreserved: true;
469
+ operationStateCleared: true;
470
+ cleanWorktree: true;
471
+ };
472
+ }
473
+
474
+ export type ConcludeMergeDetails = ConcludeMergeConcludedDetails | ConcludeMergeAbortedDetails;
414
475
 
415
476
  export type RetireBranchMode = "merged" | "forced_unmerged";
416
477