@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.
@@ -16,6 +16,7 @@ import {
16
16
  getCurrentBranch,
17
17
  getGitOperationState,
18
18
  getLocalBranchCommit,
19
+ getMergeHeadCommit,
19
20
  getRemoteTrackingRefCommit,
20
21
  inspectDirectRemoteTrackingRef,
21
22
  isCommitAncestor,
@@ -24,6 +25,7 @@ import {
24
25
  requireExistingLocalBranch,
25
26
  requireLosslessWorktreeIdentity,
26
27
  runGit,
28
+ safeWorktreeValue,
27
29
  validateBranchName,
28
30
  validateBranchNameInput,
29
31
  withRepositoryMutationQueue,
@@ -31,7 +33,12 @@ import {
31
33
  } from "./git.ts";
32
34
  import { redactSecrets } from "./redaction.ts";
33
35
  import type {
36
+ ConcludeMergeAbortedDetails,
37
+ ConcludeMergeConcludedDetails,
38
+ ConcludeMergeDetails,
39
+ ConcludeMergeToolInput,
34
40
  GitExecResult,
41
+ IntegrateBranchConflictKeptDetails,
35
42
  IntegrateBranchConflictPathEntry,
36
43
  IntegrateBranchDetails,
37
44
  IntegrateBranchToolInput,
@@ -47,6 +54,8 @@ export interface PreparedBranchIntegration {
47
54
  targetHead: string;
48
55
  sourceAlreadyIntegrated: boolean;
49
56
  remoteSource?: true;
57
+ /** conclude_merge: the source is MERGE_HEAD, which disappears on abort or commit; trust the captured sourceHead. */
58
+ sourceHeadFixed?: true;
50
59
  }
51
60
 
52
61
  interface IntegrationStateSnapshot {
@@ -80,6 +89,10 @@ const INTEGRATION_MERGE_POLICY_ARGS = [
80
89
 
81
90
  const INTEGRATION_STATUS_ARGS = ["status", "--porcelain=v1", "-z", "--untracked-files=normal"];
82
91
  const CONFLICT_PATH_ARGS = ["diff", "--name-only", "--diff-filter=U", "-z", "--"];
92
+ // Also inspect the index: manually staging a conflicted file must not bypass the marker check.
93
+ const STAGED_MERGE_PATH_ARGS = ["diff", "--cached", "--name-only", "--no-renames", "--diff-filter=ACMRT", "-z"];
94
+ const CONFLICT_MARKER_SIZE_LIMIT = 4_096;
95
+ const CONFLICT_DISPLAY_PATH_LIMIT_CHARS = 512;
83
96
  const COMMIT_PATTERN = /^[0-9a-f]{40,64}$/iu;
84
97
 
85
98
  function sameCommit(left: string, right: string): boolean {
@@ -167,7 +180,7 @@ function requireLosslessConflictPath(path: string): void {
167
180
  }
168
181
  }
169
182
 
170
- function parseConflictPaths(output: string): CapturedConflictPaths {
183
+ function parseConflictPaths(output: string, entryLimit = GIT_INTEGRATION_CONFLICT_ENTRY_LIMIT): CapturedConflictPaths {
171
184
  if (Buffer.byteLength(output, "utf8") > GIT_INTEGRATION_CONFLICT_RAW_OUTPUT_LIMIT_BYTES) {
172
185
  throw new Error("Conflict-path output exceeded the bounded safety limit.");
173
186
  }
@@ -182,7 +195,7 @@ function parseConflictPaths(output: string): CapturedConflictPaths {
182
195
  seen.add(path);
183
196
  }
184
197
 
185
- const returnedPaths = rawPaths.slice(0, GIT_INTEGRATION_CONFLICT_ENTRY_LIMIT);
198
+ const returnedPaths = rawPaths.slice(0, entryLimit);
186
199
  return {
187
200
  paths: returnedPaths.map((path) => ({ path })),
188
201
  omitted: rawPaths.length - returnedPaths.length,
@@ -212,7 +225,9 @@ async function inspectIntegrationState(
212
225
  const worktreeRoot = await getCanonicalGitWorktreeRoot(pi, rootCtx, signal);
213
226
  const canonicalCommonGitDirectory = await getCanonicalCommonGitDirectory(pi, rootCtx, signal);
214
227
  const current = await getCurrentBranch(pi, rootCtx, signal);
215
- const sourceHead = await integrationSourceHead(pi, rootCtx, prepared.sourceBranch, signal, prepared.remoteSource);
228
+ const sourceHead = prepared.sourceHeadFixed
229
+ ? prepared.sourceHead
230
+ : await integrationSourceHead(pi, rootCtx, prepared.sourceBranch, signal, prepared.remoteSource);
216
231
  const targetHead = await getLocalBranchCommit(pi, rootCtx, prepared.targetBranch, signal);
217
232
  const operationState = await getGitOperationState(pi, rootCtx, signal);
218
233
  const statusResult = await runGit(pi, rootCtx, INTEGRATION_STATUS_ARGS, {
@@ -254,6 +269,7 @@ function integrationStateProblems(
254
269
  snapshot: IntegrationStateSnapshot,
255
270
  expectedTargetHead: string | null,
256
271
  requireIntegratedAncestry: boolean,
272
+ requireClean = true,
257
273
  ): string[] {
258
274
  const problems: string[] = [];
259
275
  if (snapshot.worktreeRoot !== prepared.worktreeRoot) problems.push("the control worktree root changed");
@@ -268,7 +284,7 @@ function integrationStateProblems(
268
284
  problems.push("the target ref did not have the required commit");
269
285
  }
270
286
  if (snapshot.activeOperations.length > 0) problems.push("a Git operation remains in progress");
271
- if (!snapshot.clean) problems.push("the control worktree is not clean");
287
+ if (requireClean && !snapshot.clean) problems.push("the control worktree is not clean");
272
288
  if (requireIntegratedAncestry && !snapshot.sourceIsAncestorOfTarget) {
273
289
  problems.push("the captured source is not an ancestor of the resulting target");
274
290
  }
@@ -452,11 +468,14 @@ async function verifySuccessfulMerge(
452
468
  async function captureConflictPaths(
453
469
  pi: Pick<ExtensionAPI, "exec">,
454
470
  prepared: PreparedBranchIntegration,
471
+ entryLimit?: number,
472
+ signal?: AbortSignal,
455
473
  ): Promise<CapturedConflictPaths> {
456
474
  const result = await runGit(pi, { cwd: prepared.worktreeRoot }, CONFLICT_PATH_ARGS, {
475
+ signal,
457
476
  timeout: GIT_STATUS_TIMEOUT_MS,
458
477
  });
459
- return parseConflictPaths(result.stdout);
478
+ return parseConflictPaths(result.stdout, entryLimit);
460
479
  }
461
480
 
462
481
  async function abortMerge(
@@ -478,6 +497,7 @@ async function recoverFailedMerge(
478
497
  pi: Pick<ExtensionAPI, "exec">,
479
498
  prepared: PreparedBranchIntegration,
480
499
  mergeFailure: Error,
500
+ keepConflicts = false,
481
501
  ): Promise<IntegrateBranchDetails> {
482
502
  let mergeStatePresent = false;
483
503
  let operationInspectionError: Error | null = null;
@@ -496,6 +516,15 @@ async function recoverFailedMerge(
496
516
  conflictCaptureError = boundedMergeExecutionError(error, prepared);
497
517
  }
498
518
 
519
+ if (
520
+ keepConflicts &&
521
+ mergeStatePresent &&
522
+ conflictCaptureError === null &&
523
+ conflictPaths.paths.length + conflictPaths.omitted > 0
524
+ ) {
525
+ return verifyKeptConflict(pi, prepared, conflictPaths);
526
+ }
527
+
499
528
  const abortAttempted = mergeStatePresent;
500
529
  const abortError = mergeStatePresent ? await abortMerge(pi, prepared) : null;
501
530
 
@@ -519,9 +548,10 @@ async function recoverFailedMerge(
519
548
  throw uncertainIntegrationError(prepared, restorationProblems.join("; "), snapshot.targetHead);
520
549
  }
521
550
  if (conflictCaptureError) {
551
+ const keepNote = keepConflicts ? "keepConflicts could not be honored. " : "";
522
552
  throw new Error(
523
553
  boundedIntegrationText(
524
- `The failed merge was restored, but conflict paths could not be classified safely: ${conflictCaptureError.message}`,
554
+ `${keepNote}The failed merge was restored, but conflict paths could not be classified safely: ${conflictCaptureError.message}`,
525
555
  integrationTokens(prepared),
526
556
  ),
527
557
  );
@@ -561,6 +591,92 @@ async function recoverFailedMerge(
561
591
  throw mergeFailure;
562
592
  }
563
593
 
594
+ function uncertainKeptConflictError(prepared: PreparedBranchIntegration, reason: unknown): Error {
595
+ const rawReason = reason instanceof Error ? reason.message : String(reason);
596
+ const message =
597
+ `The conflicted merge was kept, but its state could not be verified: ${boundedIntegrationText(rawReason, integrationTokens(prepared)) || "verification was inconclusive"}. ` +
598
+ `Captured MERGE_HEAD: ${prepared.sourceHead}; captured HEAD: ${prepared.targetHead}. ` +
599
+ "The merge may still be in progress; inspect the repository, then use conclude_merge with action abort or conclude.";
600
+ return new Error(boundedIntegrationText(message, integrationTokens(prepared)));
601
+ }
602
+
603
+ async function verifyKeptConflict(
604
+ pi: Pick<ExtensionAPI, "exec">,
605
+ prepared: PreparedBranchIntegration,
606
+ conflictPaths: CapturedConflictPaths,
607
+ ): Promise<IntegrateBranchConflictKeptDetails> {
608
+ const rootCtx = { cwd: prepared.worktreeRoot };
609
+ const problems: string[] = [];
610
+ try {
611
+ const [worktreeRoot, commonGitDirectory, current, targetHead, mergeHead, operationState] = await Promise.all([
612
+ getCanonicalGitWorktreeRoot(pi, rootCtx),
613
+ getCanonicalCommonGitDirectory(pi, rootCtx),
614
+ getCurrentBranch(pi, rootCtx),
615
+ getLocalBranchCommit(pi, rootCtx, prepared.targetBranch),
616
+ getMergeHeadCommit(pi, rootCtx),
617
+ getGitOperationState(pi, rootCtx),
618
+ ]);
619
+ if (worktreeRoot !== prepared.worktreeRoot) problems.push("the control worktree root changed");
620
+ if (commonGitDirectory !== prepared.canonicalCommonGitDirectory) problems.push("the canonical common Git directory changed");
621
+ if (current.detached || current.currentBranch !== prepared.targetBranch) problems.push("the current target branch changed");
622
+ if (!sameCommit(targetHead, prepared.targetHead)) problems.push("the target ref moved");
623
+ if (!sameCommit(mergeHead, prepared.sourceHead)) problems.push("MERGE_HEAD is not the captured source commit");
624
+ if (!operationState.mergeHeadPresent || operationState.active.some((operation) => operation !== "merge")) {
625
+ problems.push("the operation state is not a single in-progress merge");
626
+ }
627
+ } catch (error) {
628
+ throw uncertainKeptConflictError(prepared, error);
629
+ }
630
+ if (problems.length > 0) throw uncertainKeptConflictError(prepared, problems.join("; "));
631
+
632
+ return {
633
+ action: "integrate_branch",
634
+ status: "conflict_kept",
635
+ mergeExecuted: true,
636
+ request: { sourceBranch: prepared.sourceBranch, targetBranch: prepared.targetBranch },
637
+ heads: { sourceHead: prepared.sourceHead, targetHead: prepared.targetHead },
638
+ conflict: {
639
+ paths: conflictPaths.paths,
640
+ omitted: conflictPaths.omitted,
641
+ kept: true,
642
+ mergeInProgress: true,
643
+ },
644
+ };
645
+ }
646
+
647
+ function conflictOmissionLine(noun: string, omitted: number): string {
648
+ return `${omitted} ${noun}${omitted === 1 ? "" : "s"} omitted.`;
649
+ }
650
+
651
+ /** Bounded, sanitized "header + one path per line" text shared by every conflict-path report. */
652
+ export function formatConflictPathList(
653
+ header: string,
654
+ conflict: { paths: IntegrateBranchConflictPathEntry[]; omitted: number },
655
+ label = "Conflict paths:",
656
+ noun = "conflict path",
657
+ ): string {
658
+ const lines = [header, label];
659
+ let omitted = conflict.omitted;
660
+
661
+ for (const [index, entry] of conflict.paths.entries()) {
662
+ const line = `- ${safeWorktreeValue(entry.path, CONFLICT_DISPLAY_PATH_LIMIT_CHARS)}`;
663
+ const candidateOmitted = conflict.omitted + conflict.paths.length - index - 1;
664
+ const candidate = [
665
+ ...lines,
666
+ line,
667
+ ...(candidateOmitted > 0 ? [conflictOmissionLine(noun, candidateOmitted)] : []),
668
+ ].join("\n");
669
+ if (candidate.length > GIT_INTEGRATION_SUMMARY_LIMIT_CHARS) {
670
+ omitted += conflict.paths.length - index;
671
+ break;
672
+ }
673
+ lines.push(line);
674
+ }
675
+
676
+ if (omitted > 0) lines.push(conflictOmissionLine(noun, omitted));
677
+ return lines.join("\n");
678
+ }
679
+
564
680
  // Caller holds the mutation queue. Remote sources are internal to update_from_base only.
565
681
  export async function integrateBranchWithinQueue(
566
682
  pi: Pick<ExtensionAPI, "exec">,
@@ -569,6 +685,7 @@ export async function integrateBranchWithinQueue(
569
685
  queuedWorktreeRoot: string,
570
686
  signal?: AbortSignal,
571
687
  remoteSource = false,
688
+ keepConflicts = false,
572
689
  ): Promise<IntegrateBranchDetails> {
573
690
  const prepared = await prepareBranchIntegration(pi, ctx, request, signal, remoteSource);
574
691
  if (prepared.worktreeRoot !== queuedWorktreeRoot) {
@@ -594,7 +711,7 @@ export async function integrateBranchWithinQueue(
594
711
  await requireDefaultTargetMergeOptions(pi, prepared, signal);
595
712
  const mergeFailure = await runMerge(pi, prepared, signal);
596
713
  if (mergeFailure === null) return verifySuccessfulMerge(pi, prepared);
597
- return recoverFailedMerge(pi, prepared, mergeFailure);
714
+ return recoverFailedMerge(pi, prepared, mergeFailure, keepConflicts);
598
715
  }
599
716
 
600
717
  export async function resolveIntegrationWorktreeRoot(
@@ -692,3 +809,268 @@ export async function integrateBranch(
692
809
  );
693
810
  return withRepositoryMutationQueue(worktreeRoot, operation);
694
811
  }
812
+
813
+ function uncertainMergeConclusionError(
814
+ prepared: PreparedBranchIntegration,
815
+ reason: unknown,
816
+ observedHead?: string,
817
+ ): Error {
818
+ const rawReason = reason instanceof Error ? reason.message : String(reason);
819
+ const observed = observedHead && COMMIT_PATTERN.test(observedHead) ? ` Observed HEAD: ${observedHead}.` : "";
820
+ const message =
821
+ "conclude_merge postconditions are uncertain. " +
822
+ `Before the attempt HEAD was ${prepared.targetHead} and MERGE_HEAD was ${prepared.sourceHead}.${observed} ` +
823
+ "The merge may still be in progress or may have been committed; inspect the repository before retrying. " +
824
+ `Diagnostic: ${boundedIntegrationText(rawReason, integrationTokens(prepared)) || "verification was inconclusive"}.`;
825
+ return new Error(boundedIntegrationText(message, integrationTokens(prepared)));
826
+ }
827
+
828
+ async function prepareMergeInProgress(
829
+ pi: Pick<ExtensionAPI, "exec">,
830
+ ctx: GitCommandContext,
831
+ signal?: AbortSignal,
832
+ ): Promise<PreparedBranchIntegration> {
833
+ const worktreeRoot = await getCanonicalGitWorktreeRoot(pi, ctx, signal);
834
+ const rootCtx = { cwd: worktreeRoot };
835
+ const operationState = await getGitOperationState(pi, rootCtx, signal);
836
+ if (!operationState.mergeHeadPresent) {
837
+ throw new Error("conclude_merge: no merge is in progress (MERGE_HEAD is absent); nothing to conclude or abort.");
838
+ }
839
+ if (operationState.active.some((operation) => operation !== "merge")) {
840
+ throw new Error(
841
+ `conclude_merge: a ${operationState.active.join(", ")} operation is in progress alongside the merge; finish or abort it outside BranchMe first.`,
842
+ );
843
+ }
844
+ const current = await getCurrentBranch(pi, rootCtx, signal);
845
+ if (current.detached || current.currentBranch === null) {
846
+ throw new Error("conclude_merge requires a current local branch; HEAD is detached.");
847
+ }
848
+ await requireExistingLocalBranch(pi, rootCtx, current.currentBranch, "Target", signal);
849
+ const prepared: PreparedBranchIntegration = {
850
+ worktreeRoot,
851
+ canonicalCommonGitDirectory: await getCanonicalCommonGitDirectory(pi, rootCtx, signal),
852
+ sourceBranch: "MERGE_HEAD",
853
+ targetBranch: current.currentBranch,
854
+ sourceHead: await getMergeHeadCommit(pi, rootCtx, signal),
855
+ targetHead: await getLocalBranchCommit(pi, rootCtx, current.currentBranch, signal),
856
+ sourceAlreadyIntegrated: false,
857
+ sourceHeadFixed: true,
858
+ };
859
+ requireLosslessIntegrationMetadata(prepared);
860
+ return prepared;
861
+ }
862
+
863
+ async function abortMergeInProgress(
864
+ pi: Pick<ExtensionAPI, "exec">,
865
+ prepared: PreparedBranchIntegration,
866
+ request: ConcludeMergeToolInput,
867
+ ): Promise<ConcludeMergeAbortedDetails> {
868
+ const abortError = await abortMerge(pi, prepared);
869
+ let snapshot: IntegrationStateSnapshot;
870
+ try {
871
+ snapshot = await inspectIntegrationState(pi, prepared);
872
+ } catch (error) {
873
+ throw uncertainMergeConclusionError(prepared, abortError ?? error);
874
+ }
875
+ const problems = integrationStateProblems(prepared, snapshot, prepared.targetHead, false);
876
+ if (abortError) problems.push("git merge --abort did not succeed");
877
+ if (problems.length > 0) throw uncertainMergeConclusionError(prepared, problems.join("; "), snapshot.targetHead);
878
+
879
+ return {
880
+ action: "conclude_merge",
881
+ status: "aborted",
882
+ request,
883
+ repoRoot: prepared.worktreeRoot,
884
+ branch: prepared.targetBranch,
885
+ head: snapshot.targetHead,
886
+ mergeHead: prepared.sourceHead,
887
+ restoration: {
888
+ verified: true,
889
+ headRestored: true,
890
+ branchPreserved: true,
891
+ operationStateCleared: true,
892
+ cleanWorktree: true,
893
+ },
894
+ };
895
+ }
896
+
897
+ function parseMarkerSizeGroups(output: string, paths: readonly string[]): Map<number, string[]> {
898
+ if (Buffer.byteLength(output, "utf8") > GIT_INTEGRATION_CONFLICT_RAW_OUTPUT_LIMIT_BYTES || !output.endsWith("\0")) {
899
+ throw new Error("conclude_merge: conflict-marker-size attributes exceeded the safety limit or were malformed.");
900
+ }
901
+ const fields = output.slice(0, -1).split("\0");
902
+ if (fields.length !== paths.length * 3) throw new Error("conclude_merge: malformed conflict-marker-size attributes.");
903
+ const groups = new Map<number, string[]>();
904
+ for (const [index, path] of paths.entries()) {
905
+ const [reportedPath, attribute, value] = fields.slice(index * 3, index * 3 + 3);
906
+ if (reportedPath !== path || attribute !== "conflict-marker-size") {
907
+ throw new Error("conclude_merge: conflict-marker-size attribute identities did not match.");
908
+ }
909
+ const configured = /^\d+$/u.test(value) ? Number(value) : 7;
910
+ if (!Number.isSafeInteger(configured) || configured > CONFLICT_MARKER_SIZE_LIMIT) {
911
+ throw new Error("conclude_merge: conflict-marker-size exceeds the bounded safety limit.");
912
+ }
913
+ // Git falls back to seven for zero/invalid values. Keep recognizing default markers
914
+ // even if attributes changed after the conflict, plus longer/custom-sized markers.
915
+ const size = configured > 0 ? Math.min(7, configured) : 7;
916
+ const group = groups.get(size) ?? [];
917
+ group.push(path);
918
+ groups.set(size, group);
919
+ }
920
+ return groups;
921
+ }
922
+
923
+ function markerGrepArgs(size: number, paths: readonly string[], cached: boolean): string[] {
924
+ return [
925
+ "--literal-pathspecs", "grep", "--no-color", "--no-ext-grep", "--no-textconv", "-a", "-l", "-z", "-E",
926
+ ...(cached ? ["--cached"] : []),
927
+ "-e", `^[<]{${size},}([[:space:]]|$)`, "-e", `^[=]{${size},}[[:space:]]*$`,
928
+ "-e", `^[>]{${size},}([[:space:]]|$)`, "-e", `^[|]{${size},}([[:space:]]|$)`,
929
+ "--", ...paths,
930
+ ];
931
+ }
932
+
933
+ async function inspectMarkerGroup(
934
+ pi: Pick<ExtensionAPI, "exec">,
935
+ prepared: PreparedBranchIntegration,
936
+ cached: boolean,
937
+ signal: AbortSignal | undefined,
938
+ entry: [number, string[]],
939
+ ): Promise<string> {
940
+ const [size, paths] = entry;
941
+ const tokens = integrationTokens(prepared);
942
+ const args = markerGrepArgs(size, paths, cached);
943
+ const result = await runGit(pi, { cwd: prepared.worktreeRoot }, args, {
944
+ signal, timeout: GIT_INTEGRATION_TIMEOUT_MS, tokens, allowFailure: true,
945
+ });
946
+ if (result.code === 1) return "";
947
+ if (result.code !== 0) throw new Error(boundedIntegrationText(formatGitFailure(args, result, tokens), tokens));
948
+ return result.stdout;
949
+ }
950
+
951
+ async function requireNoConflictMarkers(
952
+ pi: Pick<ExtensionAPI, "exec">,
953
+ prepared: PreparedBranchIntegration,
954
+ paths: readonly string[],
955
+ cached = false,
956
+ signal?: AbortSignal,
957
+ ): Promise<void> {
958
+ if (paths.length === 0) return;
959
+ const tokens = integrationTokens(prepared);
960
+ const options = { signal, timeout: GIT_INTEGRATION_TIMEOUT_MS, tokens };
961
+ const attributes = await runGit(pi, { cwd: prepared.worktreeRoot }, [
962
+ "--literal-pathspecs", "check-attr", ...(cached ? ["--cached"] : []), "-z", "conflict-marker-size", "--", ...paths,
963
+ ], options);
964
+ const groups = parseMarkerSizeGroups(attributes.stdout, paths);
965
+ // There are at most seven size groups; these bounded, read-only checks are independent.
966
+ const outputs = await Promise.all([...groups].map(inspectMarkerGroup.bind(undefined, pi, prepared, cached, signal)));
967
+ const flaggedOutput = outputs.join("");
968
+ if (!flaggedOutput) return;
969
+ const flagged = parseConflictPaths(flaggedOutput);
970
+ const count = flagged.paths.length + flagged.omitted;
971
+ throw new Error(formatConflictPathList(
972
+ `conclude_merge: refused. Conflict markers remain in ${count} path${count === 1 ? "" : "s"}${cached ? " in the index" : ""}; remove every <<<<<<<, |||||||, =======, and >>>>>>> marker line, then retry conclude_merge or abort it.`,
973
+ flagged,
974
+ "Paths with markers:",
975
+ "flagged path",
976
+ ));
977
+ }
978
+
979
+ async function requireMarkerFreeIndex(
980
+ pi: Pick<ExtensionAPI, "exec">,
981
+ prepared: PreparedBranchIntegration,
982
+ signal?: AbortSignal,
983
+ ): Promise<void> {
984
+ const result = await runGit(pi, { cwd: prepared.worktreeRoot }, [
985
+ ...STAGED_MERGE_PATH_ARGS, prepared.targetHead, "--",
986
+ ], { signal, timeout: GIT_STATUS_TIMEOUT_MS });
987
+ const staged = parseConflictPaths(result.stdout, Number.POSITIVE_INFINITY);
988
+ await requireNoConflictMarkers(pi, prepared, staged.paths.map((entry) => entry.path), true, signal);
989
+ }
990
+
991
+ async function commitResolvedMerge(
992
+ pi: Pick<ExtensionAPI, "exec">,
993
+ prepared: PreparedBranchIntegration,
994
+ request: ConcludeMergeToolInput,
995
+ signal?: AbortSignal,
996
+ ): Promise<ConcludeMergeConcludedDetails> {
997
+ const rootCtx = { cwd: prepared.worktreeRoot };
998
+ const tokens = integrationTokens(prepared);
999
+ const unmerged = await captureConflictPaths(pi, prepared, Number.POSITIVE_INFINITY, signal);
1000
+ const paths = unmerged.paths.map((entry) => entry.path);
1001
+ await requireNoConflictMarkers(pi, prepared, paths, false, signal);
1002
+ await requireMarkerFreeIndex(pi, prepared, signal);
1003
+ signal?.throwIfAborted();
1004
+ if (paths.length > 0) {
1005
+ try {
1006
+ await runGit(pi, rootCtx, ["--literal-pathspecs", "add", "--", ...paths], { timeout: GIT_INTEGRATION_TIMEOUT_MS, tokens });
1007
+ } catch (error) {
1008
+ throw uncertainMergeConclusionError(prepared, error);
1009
+ }
1010
+ }
1011
+ // Filters can change staged content; verify the exact candidate index after staging too.
1012
+ await requireMarkerFreeIndex(pi, prepared);
1013
+ try {
1014
+ await runGit(pi, rootCtx, ["commit", "--no-edit"], { timeout: GIT_INTEGRATION_TIMEOUT_MS, tokens });
1015
+ } catch (error) {
1016
+ throw uncertainMergeConclusionError(prepared, error);
1017
+ }
1018
+
1019
+ let snapshot: IntegrationStateSnapshot;
1020
+ let parents: string[];
1021
+ try {
1022
+ snapshot = await inspectIntegrationState(pi, prepared);
1023
+ parents = await getCommitParents(pi, rootCtx, snapshot.targetHead);
1024
+ } catch (error) {
1025
+ throw uncertainMergeConclusionError(prepared, error);
1026
+ }
1027
+ const problems = integrationStateProblems(prepared, snapshot, null, true, false);
1028
+ if (sameCommit(snapshot.targetHead, prepared.targetHead)) problems.push("HEAD did not move");
1029
+ if (parents.length !== 2 || !sameCommit(parents[0], prepared.targetHead) || !sameCommit(parents[1], prepared.sourceHead)) {
1030
+ problems.push("the resulting HEAD was not an exact two-parent merge of the previous HEAD and MERGE_HEAD");
1031
+ }
1032
+ if (problems.length > 0) throw uncertainMergeConclusionError(prepared, problems.join("; "), snapshot.targetHead);
1033
+
1034
+ return {
1035
+ action: "conclude_merge",
1036
+ status: "concluded",
1037
+ request,
1038
+ repoRoot: prepared.worktreeRoot,
1039
+ branch: prepared.targetBranch,
1040
+ heads: { before: prepared.targetHead, after: snapshot.targetHead },
1041
+ parents: { first: parents[0], second: parents[1] },
1042
+ mergeHead: prepared.sourceHead,
1043
+ resolvedPaths: unmerged.paths,
1044
+ };
1045
+ }
1046
+
1047
+ async function concludeMergeWithinQueue(
1048
+ pi: Pick<ExtensionAPI, "exec">,
1049
+ ctx: GitCommandContext,
1050
+ request: ConcludeMergeToolInput,
1051
+ queuedWorktreeRoot: string,
1052
+ signal?: AbortSignal,
1053
+ ): Promise<ConcludeMergeDetails> {
1054
+ const prepared = await prepareMergeInProgress(pi, ctx, signal);
1055
+ if (prepared.worktreeRoot !== queuedWorktreeRoot) {
1056
+ throw new Error("The control worktree changed while preparing merge conclusion.");
1057
+ }
1058
+ signal?.throwIfAborted();
1059
+ return request.action === "abort"
1060
+ ? abortMergeInProgress(pi, prepared, request)
1061
+ : commitResolvedMerge(pi, prepared, request, signal);
1062
+ }
1063
+
1064
+ export async function concludeMerge(
1065
+ pi: Pick<ExtensionAPI, "exec">,
1066
+ ctx: GitCommandContext,
1067
+ request: ConcludeMergeToolInput,
1068
+ signal?: AbortSignal,
1069
+ ): Promise<ConcludeMergeDetails> {
1070
+ if (request.action !== "conclude" && request.action !== "abort") {
1071
+ throw new Error("conclude_merge action must be \"conclude\" or \"abort\".");
1072
+ }
1073
+ const worktreeRoot = await resolveIntegrationWorktreeRoot(pi, ctx, signal);
1074
+ const operation = concludeMergeWithinQueue.bind(undefined, pi, ctx, request, worktreeRoot, signal);
1075
+ return withRepositoryMutationQueue(worktreeRoot, operation);
1076
+ }
@@ -1,6 +1,7 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { GIT_MUTATION_TIMEOUT_MS } from "./constants.ts";
3
3
  import { integrateBranchWithinQueue } from "./git-integration.ts";
4
+ import type { IntegrateBranchDetails } from "./types.ts";
4
5
  import {
5
6
  fetchRemoteBranchWithinQueue, getCanonicalGitWorktreeRoot, getCurrentBranch,
6
7
  getGitOperationState, getLocalBranchCommit, getRemoteTrackingRefCommit, getUpstreamBranch,
@@ -88,6 +89,15 @@ async function trackBranchWithinQueue(pi: GitAPI, ctx: GitCommandContext, input:
88
89
  export interface UpdateFromBaseInput {
89
90
  baseBranch: string;
90
91
  remote?: string;
92
+ /** Leave a conflicted merge in progress for conclude_merge instead of aborting it. */
93
+ keepConflicts?: boolean;
94
+ }
95
+
96
+ export interface UpdateFromBaseDetails {
97
+ action: "update_from_base";
98
+ request: UpdateFromBaseInput;
99
+ status: IntegrateBranchDetails["status"];
100
+ integration: IntegrateBranchDetails;
91
101
  }
92
102
 
93
103
  async function readUpstreamConfigurationValue(
@@ -110,7 +120,7 @@ async function captureUpstreamConfiguration(pi: GitAPI, ctx: GitCommandContext,
110
120
  return JSON.stringify(values);
111
121
  }
112
122
 
113
- async function updateFromBaseWithinQueue(pi: GitAPI, ctx: GitCommandContext, input: UpdateFromBaseInput, signal?: AbortSignal) {
123
+ async function updateFromBaseWithinQueue(pi: GitAPI, ctx: GitCommandContext, input: UpdateFromBaseInput, signal?: AbortSignal): Promise<UpdateFromBaseDetails> {
114
124
  const remote = input.remote ?? "origin";
115
125
  requireLosslessWorktreeIdentity(remote, "branch");
116
126
  requireLosslessWorktreeIdentity(input.baseBranch, "branch");
@@ -124,14 +134,14 @@ async function updateFromBaseWithinQueue(pi: GitAPI, ctx: GitCommandContext, inp
124
134
  await fetchWorkflowBase(pi, ctx, remote, input.baseBranch, signal);
125
135
  const integration = await integrateBranchWithinQueue(pi, ctx, {
126
136
  sourceBranch: `${remote}/${input.baseBranch}`, targetBranch: current.currentBranch,
127
- }, ctx.cwd, signal, true);
137
+ }, ctx.cwd, signal, true, input.keepConflicts === true);
128
138
  if (await captureUpstreamConfiguration(pi, ctx, current.currentBranch) !== upstreamBefore) {
129
139
  throw new Error("Upstream configuration changed during update; inspect the repository before retrying.");
130
140
  }
131
- return { action: "update_from_base" as const, request: input, status: integration.status, integration };
141
+ return { action: "update_from_base", request: input, status: integration.status, integration };
132
142
  }
133
143
 
134
- export async function updateFromBase(pi: GitAPI, ctx: GitCommandContext, input: UpdateFromBaseInput, signal?: AbortSignal) {
144
+ export async function updateFromBase(pi: GitAPI, ctx: GitCommandContext, input: UpdateFromBaseInput, signal?: AbortSignal): Promise<UpdateFromBaseDetails> {
135
145
  const root = await getCanonicalGitWorktreeRoot(pi, ctx, signal);
136
146
  requireLosslessWorktreeIdentity(root, "cwd");
137
147
  return withRepositoryMutationQueue(root, updateFromBaseWithinQueue.bind(undefined, pi, { cwd: root }, input, signal));