@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/CHANGELOG.md +9 -2
- package/README.md +22 -17
- package/SECURITY.md +9 -8
- package/docs/PROJECT_DEFINITION_BRIEF.md +13 -10
- package/docs/SMOKE_TEST.md +2 -2
- package/docs/STRUCTURE.md +9 -8
- package/docs/TUI_CAPTURE.md +3 -2
- package/package.json +9 -4
- package/src/commands/branchme-command.ts +3 -2
- package/src/constants.ts +2 -0
- package/src/git-integration.ts +389 -7
- package/src/git-workflow.ts +30 -12
- package/src/git.ts +69 -18
- package/src/tools/branchme-tools.ts +7 -33
- package/src/tools/workflow-tools.ts +78 -9
- package/src/types.ts +63 -2
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
|
|
982
|
+
const entryPromises: Promise<WorktreeInventoryEntry>[] = [];
|
|
975
983
|
for (const [index, record] of records.entries()) {
|
|
976
|
-
|
|
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
|
|
987
|
-
const parentPath = dirname(worktreePath);
|
|
988
|
-
let parentStats;
|
|
995
|
+
async function requireCanonicalCreationParent(parentPath: string): Promise<string> {
|
|
989
996
|
try {
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
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 (
|
|
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
|
|
1096
|
-
for (const [index,
|
|
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
|
|
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
|
|
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
|
-
|
|
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 {
|
|
4
|
-
|
|
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
|
|
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
|
|
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:
|
|
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
|
|