@senad-d/branchme 0.3.2 → 0.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -2
- package/README.md +28 -12
- package/SECURITY.md +8 -5
- package/docs/PROJECT_DEFINITION_BRIEF.md +11 -8
- package/docs/SMOKE_TEST.md +2 -2
- package/docs/STRUCTURE.md +12 -7
- package/docs/TUI_CAPTURE.md +7 -3
- package/package.json +1 -1
- package/src/commands/branchme-command.ts +7 -3
- package/src/constants.ts +5 -0
- package/src/git-discovery.ts +231 -0
- package/src/git-integration.ts +389 -7
- package/src/git-workflow.ts +14 -4
- package/src/git.ts +98 -7
- package/src/tools/branchme-tools.ts +59 -37
- package/src/tools/workflow-tools.ts +78 -9
- package/src/types.ts +82 -3
package/src/git-integration.ts
CHANGED
|
@@ -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,
|
|
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 =
|
|
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
|
-
|
|
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
|
+
}
|
package/src/git-workflow.ts
CHANGED
|
@@ -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"
|
|
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));
|