@senad-d/branchme 0.1.8 → 0.1.9

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.
@@ -0,0 +1,673 @@
1
+ import { isAbsolute, posix, win32 } from "node:path";
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import {
4
+ GIT_INTEGRATION_CONFLICT_ENTRY_LIMIT,
5
+ GIT_INTEGRATION_CONFLICT_PATH_LIMIT_CHARS,
6
+ GIT_INTEGRATION_CONFLICT_RAW_OUTPUT_LIMIT_BYTES,
7
+ GIT_INTEGRATION_SUMMARY_LIMIT_CHARS,
8
+ GIT_INTEGRATION_TIMEOUT_MS,
9
+ GIT_STATUS_TIMEOUT_MS,
10
+ GIT_WORKTREE_PATH_LIMIT_CHARS,
11
+ } from "./constants.ts";
12
+ import {
13
+ formatGitFailure,
14
+ getCanonicalCommonGitDirectory,
15
+ getCanonicalGitWorktreeRoot,
16
+ getCurrentBranch,
17
+ getGitOperationState,
18
+ getLocalBranchCommit,
19
+ isCommitAncestor,
20
+ isLosslessGitMetadata,
21
+ parseWorkingTreeStatus,
22
+ requireExistingLocalBranch,
23
+ requireLosslessWorktreeIdentity,
24
+ runGit,
25
+ validateBranchName,
26
+ validateBranchNameInput,
27
+ withRepositoryMutationQueue,
28
+ type GitCommandContext,
29
+ } from "./git.ts";
30
+ import { redactSecrets } from "./redaction.ts";
31
+ import type {
32
+ GitExecResult,
33
+ IntegrateBranchConflictPathEntry,
34
+ IntegrateBranchDetails,
35
+ IntegrateBranchToolInput,
36
+ IntegrateBranchVerification,
37
+ } from "./types.ts";
38
+
39
+ export interface PreparedBranchIntegration {
40
+ worktreeRoot: string;
41
+ canonicalCommonGitDirectory: string;
42
+ sourceBranch: string;
43
+ targetBranch: string;
44
+ sourceHead: string;
45
+ targetHead: string;
46
+ sourceAlreadyIntegrated: boolean;
47
+ }
48
+
49
+ interface IntegrationStateSnapshot {
50
+ worktreeRoot: string;
51
+ canonicalCommonGitDirectory: string;
52
+ currentBranch: string | null;
53
+ detached: boolean;
54
+ sourceHead: string;
55
+ targetHead: string;
56
+ activeOperations: string[];
57
+ clean: boolean;
58
+ sourceIsAncestorOfTarget: boolean;
59
+ previousTargetIsAncestorOfTarget: boolean;
60
+ }
61
+
62
+ interface CapturedConflictPaths {
63
+ paths: IntegrateBranchConflictPathEntry[];
64
+ omitted: number;
65
+ }
66
+
67
+ const INTEGRATION_MERGE_POLICY_ARGS = [
68
+ "-c",
69
+ "rerere.enabled=false",
70
+ "merge",
71
+ "--ff",
72
+ "--no-edit",
73
+ "--no-autostash",
74
+ "--no-rerere-autoupdate",
75
+ "--no-overwrite-ignore",
76
+ ] as const;
77
+
78
+ const INTEGRATION_STATUS_ARGS = ["status", "--porcelain=v1", "-z", "--untracked-files=normal"];
79
+ const CONFLICT_PATH_ARGS = ["diff", "--name-only", "--diff-filter=U", "-z", "--"];
80
+ const COMMIT_PATTERN = /^[0-9a-f]{40,64}$/iu;
81
+
82
+ function sameCommit(left: string, right: string): boolean {
83
+ return left.toLowerCase() === right.toLowerCase();
84
+ }
85
+
86
+ function integrationTokens(prepared: PreparedBranchIntegration): string[] {
87
+ return [
88
+ prepared.worktreeRoot,
89
+ prepared.canonicalCommonGitDirectory,
90
+ prepared.sourceBranch,
91
+ prepared.targetBranch,
92
+ ];
93
+ }
94
+
95
+ function boundedIntegrationText(value: string, tokens: readonly string[]): string {
96
+ const redacted = redactSecrets(value, tokens).replace(
97
+ /[\p{Cc}\p{Cf}\u2028\u2029]/gu,
98
+ (character) => {
99
+ const codePoint = character.codePointAt(0);
100
+ return codePoint === undefined ? "" : String.raw`\u${codePoint.toString(16).padStart(4, "0")}`;
101
+ },
102
+ );
103
+ if (redacted.length <= GIT_INTEGRATION_SUMMARY_LIMIT_CHARS) return redacted;
104
+ return `${redacted.slice(0, GIT_INTEGRATION_SUMMARY_LIMIT_CHARS - 14)}… [truncated]`;
105
+ }
106
+
107
+ function boundedMergeExecutionError(
108
+ error: unknown,
109
+ prepared: PreparedBranchIntegration,
110
+ ): Error {
111
+ const rawMessage = error instanceof Error ? error.message : String(error);
112
+ const message = boundedIntegrationText(rawMessage, integrationTokens(prepared));
113
+ return new Error(message || "git merge failed without a diagnostic.");
114
+ }
115
+
116
+ function uncertainIntegrationError(
117
+ prepared: PreparedBranchIntegration,
118
+ reason: unknown,
119
+ observedTargetHead?: string,
120
+ ): Error {
121
+ const rawReason = reason instanceof Error ? reason.message : String(reason);
122
+ const reasonText = boundedIntegrationText(rawReason, integrationTokens(prepared));
123
+ const observed = observedTargetHead && COMMIT_PATTERN.test(observedTargetHead)
124
+ ? ` Observed target HEAD: ${observedTargetHead}.`
125
+ : "";
126
+ const message =
127
+ `Branch integration postconditions are uncertain: ${reasonText || "verification was inconclusive"}. ` +
128
+ `Before source HEAD: ${prepared.sourceHead}; before target HEAD: ${prepared.targetHead}.${observed} ` +
129
+ "Integration may have completed; inspect the repository before retrying.";
130
+ return new Error(boundedIntegrationText(message, integrationTokens(prepared)));
131
+ }
132
+
133
+ function requireLosslessIntegrationMetadata(prepared: PreparedBranchIntegration): void {
134
+ requireLosslessWorktreeIdentity(prepared.worktreeRoot, "cwd");
135
+ requireLosslessWorktreeIdentity(prepared.sourceBranch, "branch");
136
+ requireLosslessWorktreeIdentity(prepared.targetBranch, "branch");
137
+ if (
138
+ !isLosslessGitMetadata(
139
+ prepared.canonicalCommonGitDirectory,
140
+ GIT_WORKTREE_PATH_LIMIT_CHARS,
141
+ )
142
+ ) {
143
+ throw new Error(
144
+ "The canonical common Git directory cannot be returned safely and losslessly. " +
145
+ "Use a repository path without credential-like token text or control/format characters.",
146
+ );
147
+ }
148
+ }
149
+
150
+ function conflictPathIsRepositoryRelative(path: string): boolean {
151
+ if (!path || path === "." || isAbsolute(path) || win32.isAbsolute(path)) return false;
152
+ if (posix.normalize(path) !== path) return false;
153
+ return path.split("/").every((segment) => segment.length > 0 && segment !== "." && segment !== "..");
154
+ }
155
+
156
+ function requireLosslessConflictPath(path: string): void {
157
+ if (
158
+ !conflictPathIsRepositoryRelative(path) ||
159
+ !isLosslessGitMetadata(path, GIT_INTEGRATION_CONFLICT_PATH_LIMIT_CHARS)
160
+ ) {
161
+ throw new Error(
162
+ "A conflict path cannot be returned safely and losslessly as a bounded repository-relative identity.",
163
+ );
164
+ }
165
+ }
166
+
167
+ function parseConflictPaths(output: string): CapturedConflictPaths {
168
+ if (Buffer.byteLength(output, "utf8") > GIT_INTEGRATION_CONFLICT_RAW_OUTPUT_LIMIT_BYTES) {
169
+ throw new Error("Conflict-path output exceeded the bounded safety limit.");
170
+ }
171
+ if (output.length === 0) return { paths: [], omitted: 0 };
172
+ if (!output.endsWith("\0")) throw new Error("Conflict-path output was malformed.");
173
+
174
+ const rawPaths = output.slice(0, -1).split("\0");
175
+ const seen = new Set<string>();
176
+ for (const path of rawPaths) {
177
+ requireLosslessConflictPath(path);
178
+ if (seen.has(path)) throw new Error("Conflict-path output contained duplicate identities.");
179
+ seen.add(path);
180
+ }
181
+
182
+ const returnedPaths = rawPaths.slice(0, GIT_INTEGRATION_CONFLICT_ENTRY_LIMIT);
183
+ return {
184
+ paths: returnedPaths.map((path) => ({ path })),
185
+ omitted: rawPaths.length - returnedPaths.length,
186
+ };
187
+ }
188
+
189
+ async function inspectIntegrationState(
190
+ pi: Pick<ExtensionAPI, "exec">,
191
+ prepared: PreparedBranchIntegration,
192
+ signal?: AbortSignal,
193
+ ): Promise<IntegrationStateSnapshot> {
194
+ const rootCtx = { cwd: prepared.worktreeRoot };
195
+ const worktreeRoot = await getCanonicalGitWorktreeRoot(pi, rootCtx, signal);
196
+ const canonicalCommonGitDirectory = await getCanonicalCommonGitDirectory(pi, rootCtx, signal);
197
+ const current = await getCurrentBranch(pi, rootCtx, signal);
198
+ const sourceHead = await getLocalBranchCommit(pi, rootCtx, prepared.sourceBranch, signal);
199
+ const targetHead = await getLocalBranchCommit(pi, rootCtx, prepared.targetBranch, signal);
200
+ const operationState = await getGitOperationState(pi, rootCtx, signal);
201
+ const statusResult = await runGit(pi, rootCtx, INTEGRATION_STATUS_ARGS, {
202
+ signal,
203
+ timeout: GIT_STATUS_TIMEOUT_MS,
204
+ });
205
+ const clean = parseWorkingTreeStatus(statusResult.stdout).workingTree.state === "clean";
206
+ const sourceIsAncestorOfTarget = await isCommitAncestor(
207
+ pi,
208
+ rootCtx,
209
+ prepared.sourceHead,
210
+ targetHead,
211
+ signal,
212
+ );
213
+ const previousTargetIsAncestorOfTarget = await isCommitAncestor(
214
+ pi,
215
+ rootCtx,
216
+ prepared.targetHead,
217
+ targetHead,
218
+ signal,
219
+ );
220
+
221
+ return {
222
+ worktreeRoot,
223
+ canonicalCommonGitDirectory,
224
+ currentBranch: current.currentBranch,
225
+ detached: current.detached,
226
+ sourceHead,
227
+ targetHead,
228
+ activeOperations: operationState.active,
229
+ clean,
230
+ sourceIsAncestorOfTarget,
231
+ previousTargetIsAncestorOfTarget,
232
+ };
233
+ }
234
+
235
+ function integrationStateProblems(
236
+ prepared: PreparedBranchIntegration,
237
+ snapshot: IntegrationStateSnapshot,
238
+ expectedTargetHead: string | null,
239
+ requireIntegratedAncestry: boolean,
240
+ ): string[] {
241
+ const problems: string[] = [];
242
+ if (snapshot.worktreeRoot !== prepared.worktreeRoot) problems.push("the control worktree root changed");
243
+ if (snapshot.canonicalCommonGitDirectory !== prepared.canonicalCommonGitDirectory) {
244
+ problems.push("the canonical common Git directory changed");
245
+ }
246
+ if (snapshot.detached || snapshot.currentBranch !== prepared.targetBranch) {
247
+ problems.push("the current target branch changed");
248
+ }
249
+ if (!sameCommit(snapshot.sourceHead, prepared.sourceHead)) problems.push("the source ref moved");
250
+ if (expectedTargetHead !== null && !sameCommit(snapshot.targetHead, expectedTargetHead)) {
251
+ problems.push("the target ref did not have the required commit");
252
+ }
253
+ if (snapshot.activeOperations.length > 0) problems.push("a Git operation remains in progress");
254
+ if (!snapshot.clean) problems.push("the control worktree is not clean");
255
+ if (requireIntegratedAncestry && !snapshot.sourceIsAncestorOfTarget) {
256
+ problems.push("the captured source is not an ancestor of the resulting target");
257
+ }
258
+ if (!snapshot.previousTargetIsAncestorOfTarget) {
259
+ problems.push("the captured previous target is not an ancestor of the resulting target");
260
+ }
261
+ return problems;
262
+ }
263
+
264
+ function integrationVerification(
265
+ prepared: PreparedBranchIntegration,
266
+ snapshot: IntegrationStateSnapshot,
267
+ ): IntegrateBranchVerification {
268
+ return {
269
+ repository: {
270
+ worktreeRoot: prepared.worktreeRoot,
271
+ canonicalCommonGitDirectory: prepared.canonicalCommonGitDirectory,
272
+ identityPreserved: true,
273
+ },
274
+ controlWorktree: {
275
+ targetBranch: prepared.targetBranch,
276
+ currentBranchPreserved: true,
277
+ cleanBefore: true,
278
+ cleanAfter: true,
279
+ operationStateAbsentBefore: true,
280
+ operationStateAbsentAfter: true,
281
+ },
282
+ heads: {
283
+ before: {
284
+ sourceHead: prepared.sourceHead,
285
+ targetHead: prepared.targetHead,
286
+ },
287
+ after: {
288
+ sourceHead: snapshot.sourceHead,
289
+ targetHead: snapshot.targetHead,
290
+ },
291
+ },
292
+ finalAncestry: {
293
+ sourceHead: prepared.sourceHead,
294
+ previousTargetHead: prepared.targetHead,
295
+ targetHead: snapshot.targetHead,
296
+ sourceIsAncestorOfTarget: snapshot.sourceIsAncestorOfTarget,
297
+ previousTargetIsAncestorOfTarget: snapshot.previousTargetIsAncestorOfTarget,
298
+ },
299
+ };
300
+ }
301
+
302
+ async function getCommitParents(
303
+ pi: Pick<ExtensionAPI, "exec">,
304
+ ctx: GitCommandContext,
305
+ commit: string,
306
+ ): Promise<string[]> {
307
+ const args = ["rev-list", "--parents", "-n", "1", commit];
308
+ const result = await runGit(pi, ctx, args, { timeout: GIT_STATUS_TIMEOUT_MS });
309
+ if (Buffer.byteLength(result.stdout, "utf8") > 1_024) {
310
+ throw new Error("Git returned oversized commit-parent output.");
311
+ }
312
+ const fields = result.stdout.trim().split(/\s+/u);
313
+ if (
314
+ fields.length < 1 ||
315
+ !fields.every((field) => COMMIT_PATTERN.test(field)) ||
316
+ !sameCommit(fields[0], commit)
317
+ ) {
318
+ throw new Error("Git returned malformed commit-parent identities.");
319
+ }
320
+ return fields.slice(1);
321
+ }
322
+
323
+ function mergeArgs(sourceBranch: string): string[] {
324
+ return [...INTEGRATION_MERGE_POLICY_ARGS, `refs/heads/${sourceBranch}`];
325
+ }
326
+
327
+ async function requireDefaultTargetMergeOptions(
328
+ pi: Pick<ExtensionAPI, "exec">,
329
+ prepared: PreparedBranchIntegration,
330
+ signal?: AbortSignal,
331
+ ): Promise<void> {
332
+ const args = ["config", "--get-all", `branch.${prepared.targetBranch}.mergeOptions`];
333
+ const result = await runGit(pi, { cwd: prepared.worktreeRoot }, args, {
334
+ signal,
335
+ timeout: GIT_STATUS_TIMEOUT_MS,
336
+ allowFailure: true,
337
+ tokens: integrationTokens(prepared),
338
+ });
339
+ if (result.code === 1) return;
340
+ if (result.code !== 0) {
341
+ throw new Error(
342
+ boundedIntegrationText(
343
+ formatGitFailure(args, result, integrationTokens(prepared)),
344
+ integrationTokens(prepared),
345
+ ),
346
+ );
347
+ }
348
+ if (result.stdout.trim().length === 0) return;
349
+ throw new Error(
350
+ "The target branch has branch-specific merge options configured. " +
351
+ "Clear its branch.<name>.mergeOptions setting before integration so the fixed merge policy cannot be changed.",
352
+ );
353
+ }
354
+
355
+ async function runMerge(
356
+ pi: Pick<ExtensionAPI, "exec">,
357
+ prepared: PreparedBranchIntegration,
358
+ signal?: AbortSignal,
359
+ ): Promise<Error | null> {
360
+ const args = mergeArgs(prepared.sourceBranch);
361
+ let result: GitExecResult;
362
+ try {
363
+ result = await runGit(pi, { cwd: prepared.worktreeRoot }, args, {
364
+ signal,
365
+ timeout: GIT_INTEGRATION_TIMEOUT_MS,
366
+ allowFailure: true,
367
+ tokens: integrationTokens(prepared),
368
+ });
369
+ } catch (error) {
370
+ return boundedMergeExecutionError(error, prepared);
371
+ }
372
+ if (result.code === 0) return null;
373
+ return new Error(
374
+ boundedIntegrationText(
375
+ formatGitFailure(args, result, integrationTokens(prepared)),
376
+ integrationTokens(prepared),
377
+ ),
378
+ );
379
+ }
380
+
381
+ async function verifySuccessfulMerge(
382
+ pi: Pick<ExtensionAPI, "exec">,
383
+ prepared: PreparedBranchIntegration,
384
+ ): Promise<IntegrateBranchDetails> {
385
+ let snapshot: IntegrationStateSnapshot;
386
+ try {
387
+ snapshot = await inspectIntegrationState(pi, prepared);
388
+ } catch (error) {
389
+ throw uncertainIntegrationError(prepared, error);
390
+ }
391
+
392
+ const problems = integrationStateProblems(prepared, snapshot, null, true);
393
+ if (problems.length > 0) {
394
+ throw uncertainIntegrationError(prepared, problems.join("; "), snapshot.targetHead);
395
+ }
396
+
397
+ const verified = integrationVerification(prepared, snapshot);
398
+ if (sameCommit(snapshot.targetHead, prepared.sourceHead)) {
399
+ return {
400
+ action: "integrate_branch",
401
+ status: "fast_forward",
402
+ mergeExecuted: true,
403
+ request: { sourceBranch: prepared.sourceBranch, targetBranch: prepared.targetBranch },
404
+ verified,
405
+ };
406
+ }
407
+
408
+ let parents: string[];
409
+ try {
410
+ parents = await getCommitParents(pi, { cwd: prepared.worktreeRoot }, snapshot.targetHead);
411
+ } catch (error) {
412
+ throw uncertainIntegrationError(prepared, error, snapshot.targetHead);
413
+ }
414
+ if (
415
+ parents.length !== 2 ||
416
+ !sameCommit(parents[0], prepared.targetHead) ||
417
+ !sameCommit(parents[1], prepared.sourceHead)
418
+ ) {
419
+ throw uncertainIntegrationError(
420
+ prepared,
421
+ "the resulting target was not an exact two-parent merge of the captured target and source",
422
+ snapshot.targetHead,
423
+ );
424
+ }
425
+
426
+ return {
427
+ action: "integrate_branch",
428
+ status: "merge_commit",
429
+ mergeExecuted: true,
430
+ request: { sourceBranch: prepared.sourceBranch, targetBranch: prepared.targetBranch },
431
+ verified,
432
+ };
433
+ }
434
+
435
+ async function captureConflictPaths(
436
+ pi: Pick<ExtensionAPI, "exec">,
437
+ prepared: PreparedBranchIntegration,
438
+ ): Promise<CapturedConflictPaths> {
439
+ const result = await runGit(pi, { cwd: prepared.worktreeRoot }, CONFLICT_PATH_ARGS, {
440
+ timeout: GIT_STATUS_TIMEOUT_MS,
441
+ });
442
+ return parseConflictPaths(result.stdout);
443
+ }
444
+
445
+ async function abortMerge(
446
+ pi: Pick<ExtensionAPI, "exec">,
447
+ prepared: PreparedBranchIntegration,
448
+ ): Promise<Error | null> {
449
+ try {
450
+ await runGit(pi, { cwd: prepared.worktreeRoot }, ["merge", "--abort"], {
451
+ timeout: GIT_INTEGRATION_TIMEOUT_MS,
452
+ tokens: integrationTokens(prepared),
453
+ });
454
+ return null;
455
+ } catch (error) {
456
+ return boundedMergeExecutionError(error, prepared);
457
+ }
458
+ }
459
+
460
+ async function recoverFailedMerge(
461
+ pi: Pick<ExtensionAPI, "exec">,
462
+ prepared: PreparedBranchIntegration,
463
+ mergeFailure: Error,
464
+ ): Promise<IntegrateBranchDetails> {
465
+ let mergeStatePresent = false;
466
+ let operationInspectionError: Error | null = null;
467
+ try {
468
+ const operationState = await getGitOperationState(pi, { cwd: prepared.worktreeRoot });
469
+ mergeStatePresent = operationState.mergeHeadPresent;
470
+ } catch (error) {
471
+ operationInspectionError = boundedMergeExecutionError(error, prepared);
472
+ }
473
+
474
+ let conflictPaths: CapturedConflictPaths = { paths: [], omitted: 0 };
475
+ let conflictCaptureError: Error | null = null;
476
+ try {
477
+ conflictPaths = await captureConflictPaths(pi, prepared);
478
+ } catch (error) {
479
+ conflictCaptureError = boundedMergeExecutionError(error, prepared);
480
+ }
481
+
482
+ const abortAttempted = mergeStatePresent;
483
+ const abortError = mergeStatePresent ? await abortMerge(pi, prepared) : null;
484
+
485
+ let snapshot: IntegrationStateSnapshot;
486
+ try {
487
+ snapshot = await inspectIntegrationState(pi, prepared);
488
+ } catch (error) {
489
+ const reason = abortError ?? operationInspectionError ?? conflictCaptureError ?? error;
490
+ throw uncertainIntegrationError(prepared, reason);
491
+ }
492
+
493
+ const restorationProblems = integrationStateProblems(
494
+ prepared,
495
+ snapshot,
496
+ prepared.targetHead,
497
+ false,
498
+ );
499
+ if (operationInspectionError) restorationProblems.push("the failed merge state could not be inspected");
500
+ if (abortError) restorationProblems.push("git merge --abort did not succeed");
501
+ if (restorationProblems.length > 0) {
502
+ throw uncertainIntegrationError(prepared, restorationProblems.join("; "), snapshot.targetHead);
503
+ }
504
+ if (conflictCaptureError) {
505
+ throw new Error(
506
+ boundedIntegrationText(
507
+ `The failed merge was restored, but conflict paths could not be classified safely: ${conflictCaptureError.message}`,
508
+ integrationTokens(prepared),
509
+ ),
510
+ );
511
+ }
512
+
513
+ if (conflictPaths.paths.length + conflictPaths.omitted > 0) {
514
+ if (!abortAttempted) {
515
+ throw uncertainIntegrationError(
516
+ prepared,
517
+ "unmerged paths were present without a verified merge abort",
518
+ snapshot.targetHead,
519
+ );
520
+ }
521
+ return {
522
+ action: "integrate_branch",
523
+ status: "conflict",
524
+ mergeExecuted: true,
525
+ request: { sourceBranch: prepared.sourceBranch, targetBranch: prepared.targetBranch },
526
+ verified: integrationVerification(prepared, snapshot),
527
+ conflict: {
528
+ paths: conflictPaths.paths,
529
+ omitted: conflictPaths.omitted,
530
+ abort: { attempted: true, succeeded: true },
531
+ restoration: {
532
+ verified: true,
533
+ repositoryIdentityPreserved: true,
534
+ controlWorktreeBranchPreserved: true,
535
+ sourceHeadPreserved: true,
536
+ targetHeadRestored: true,
537
+ operationStateCleared: true,
538
+ cleanControlWorktree: true,
539
+ },
540
+ },
541
+ };
542
+ }
543
+
544
+ throw mergeFailure;
545
+ }
546
+
547
+ async function integrateBranchWithinQueue(
548
+ pi: Pick<ExtensionAPI, "exec">,
549
+ ctx: GitCommandContext,
550
+ request: IntegrateBranchToolInput,
551
+ queuedWorktreeRoot: string,
552
+ signal?: AbortSignal,
553
+ ): Promise<IntegrateBranchDetails> {
554
+ const prepared = await prepareBranchIntegration(pi, ctx, request, signal);
555
+ if (prepared.worktreeRoot !== queuedWorktreeRoot) {
556
+ throw new Error("The control worktree changed while preparing branch integration.");
557
+ }
558
+ requireLosslessIntegrationMetadata(prepared);
559
+
560
+ if (prepared.sourceAlreadyIntegrated) {
561
+ const snapshot = await inspectIntegrationState(pi, prepared, signal);
562
+ const problems = integrationStateProblems(prepared, snapshot, prepared.targetHead, true);
563
+ if (problems.length > 0) {
564
+ throw new Error(`Branch integration no-op verification failed: ${problems.join("; ")}.`);
565
+ }
566
+ return {
567
+ action: "integrate_branch",
568
+ status: "already_integrated",
569
+ mergeExecuted: false,
570
+ request: { sourceBranch: prepared.sourceBranch, targetBranch: prepared.targetBranch },
571
+ verified: integrationVerification(prepared, snapshot),
572
+ };
573
+ }
574
+
575
+ await requireDefaultTargetMergeOptions(pi, prepared, signal);
576
+ const mergeFailure = await runMerge(pi, prepared, signal);
577
+ if (mergeFailure === null) return verifySuccessfulMerge(pi, prepared);
578
+ return recoverFailedMerge(pi, prepared, mergeFailure);
579
+ }
580
+
581
+ export async function resolveIntegrationWorktreeRoot(
582
+ pi: Pick<ExtensionAPI, "exec">,
583
+ ctx: GitCommandContext,
584
+ signal?: AbortSignal,
585
+ ): Promise<string> {
586
+ return getCanonicalGitWorktreeRoot(pi, ctx, signal);
587
+ }
588
+
589
+ export async function prepareBranchIntegration(
590
+ pi: Pick<ExtensionAPI, "exec">,
591
+ ctx: GitCommandContext,
592
+ request: IntegrateBranchToolInput,
593
+ signal?: AbortSignal,
594
+ ): Promise<PreparedBranchIntegration> {
595
+ validateBranchNameInput(request.sourceBranch, "Source branch");
596
+ validateBranchNameInput(request.targetBranch, "Target branch");
597
+ if (request.sourceBranch === request.targetBranch) {
598
+ throw new Error("Source branch and target branch must be distinct local branches.");
599
+ }
600
+
601
+ const worktreeRoot = await resolveIntegrationWorktreeRoot(pi, ctx, signal);
602
+ const rootCtx = { cwd: worktreeRoot };
603
+ const canonicalCommonGitDirectory = await getCanonicalCommonGitDirectory(pi, rootCtx, signal);
604
+
605
+ await validateBranchName(pi, rootCtx, request.sourceBranch, signal);
606
+ await validateBranchName(pi, rootCtx, request.targetBranch, signal);
607
+ await requireExistingLocalBranch(pi, rootCtx, request.sourceBranch, "Source", signal);
608
+ await requireExistingLocalBranch(pi, rootCtx, request.targetBranch, "Target", signal);
609
+ const sourceHead = await getLocalBranchCommit(pi, rootCtx, request.sourceBranch, signal);
610
+ const targetHead = await getLocalBranchCommit(pi, rootCtx, request.targetBranch, signal);
611
+
612
+ const current = await getCurrentBranch(pi, rootCtx, signal);
613
+ if (current.detached || current.currentBranch === null) {
614
+ throw new Error("The control worktree must have the target branch checked out; HEAD is detached.");
615
+ }
616
+ if (current.currentBranch !== request.targetBranch) {
617
+ throw new Error("The control worktree must already have the requested target branch checked out.");
618
+ }
619
+
620
+ const operationState = await getGitOperationState(pi, rootCtx, signal);
621
+ if (operationState.active.length > 0) {
622
+ throw new Error(
623
+ `The control worktree has an existing ${operationState.active.join(", ")} operation in progress. ` +
624
+ "Finish or abort it before integrating a branch.",
625
+ );
626
+ }
627
+
628
+ const statusResult = await runGit(pi, rootCtx, INTEGRATION_STATUS_ARGS, {
629
+ signal,
630
+ timeout: GIT_STATUS_TIMEOUT_MS,
631
+ });
632
+ const workingTree = parseWorkingTreeStatus(statusResult.stdout).workingTree;
633
+ if (workingTree.state !== "clean") {
634
+ throw new Error(
635
+ "The control worktree must be clean, with no staged, unstaged, untracked, or unmerged changes, before integration.",
636
+ );
637
+ }
638
+
639
+ const sourceAlreadyIntegrated = await isCommitAncestor(
640
+ pi,
641
+ rootCtx,
642
+ sourceHead,
643
+ targetHead,
644
+ signal,
645
+ );
646
+ return {
647
+ worktreeRoot,
648
+ canonicalCommonGitDirectory,
649
+ sourceBranch: request.sourceBranch,
650
+ targetBranch: request.targetBranch,
651
+ sourceHead,
652
+ targetHead,
653
+ sourceAlreadyIntegrated,
654
+ };
655
+ }
656
+
657
+ export async function integrateBranch(
658
+ pi: Pick<ExtensionAPI, "exec">,
659
+ ctx: GitCommandContext,
660
+ request: IntegrateBranchToolInput,
661
+ signal?: AbortSignal,
662
+ ): Promise<IntegrateBranchDetails> {
663
+ const worktreeRoot = await resolveIntegrationWorktreeRoot(pi, ctx, signal);
664
+ const operation = integrateBranchWithinQueue.bind(
665
+ undefined,
666
+ pi,
667
+ ctx,
668
+ request,
669
+ worktreeRoot,
670
+ signal,
671
+ );
672
+ return withRepositoryMutationQueue(worktreeRoot, operation);
673
+ }