@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.
- package/CHANGELOG.md +7 -5
- package/README.md +49 -12
- package/SECURITY.md +14 -4
- package/docs/PROJECT_DEFINITION_BRIEF.md +34 -17
- package/docs/SMOKE_TEST.md +7 -5
- package/docs/STRUCTURE.md +22 -17
- package/docs/TUI_CAPTURE.md +82 -31
- package/package.json +2 -2
- package/src/commands/branchme-command.ts +7 -1
- package/src/constants.ts +7 -0
- package/src/git-context.ts +81 -16
- package/src/git-integration.ts +673 -0
- package/src/git.ts +155 -7
- package/src/tools/branchme-tools.ts +106 -4
- package/src/types.ts +115 -0
- package/src/ui/branchme-panel.ts +16 -3
package/src/git.ts
CHANGED
|
@@ -24,6 +24,8 @@ import {
|
|
|
24
24
|
import { redactSecrets } from "./redaction.ts";
|
|
25
25
|
import type {
|
|
26
26
|
AheadBehindCount,
|
|
27
|
+
BranchAncestryDetails,
|
|
28
|
+
BranchStatusAncestryQuery,
|
|
27
29
|
BranchStatusDetails,
|
|
28
30
|
ChangeBranchDetails,
|
|
29
31
|
CreateBranchDetails,
|
|
@@ -157,6 +159,23 @@ export async function getGitRoot(
|
|
|
157
159
|
return root;
|
|
158
160
|
}
|
|
159
161
|
|
|
162
|
+
export async function getCanonicalGitWorktreeRoot(
|
|
163
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
164
|
+
ctx: GitCommandContext,
|
|
165
|
+
signal?: AbortSignal,
|
|
166
|
+
): Promise<string> {
|
|
167
|
+
const repoRoot = await getGitRoot(pi, ctx, signal);
|
|
168
|
+
if (!isAbsolute(repoRoot) || /[\p{Cc}\p{Cf}\u2028\u2029]/u.test(repoRoot)) {
|
|
169
|
+
throw new Error("Unable to verify the active worktree root: Git returned an invalid path.");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
return await realpath(repoRoot);
|
|
174
|
+
} catch {
|
|
175
|
+
throw new Error("Unable to verify the active worktree root: the path could not be resolved.");
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
160
179
|
export async function getCurrentBranch(
|
|
161
180
|
pi: Pick<ExtensionAPI, "exec">,
|
|
162
181
|
ctx: GitCommandContext,
|
|
@@ -667,12 +686,16 @@ function safeWorktreeBranchLabel(branchName: string): string {
|
|
|
667
686
|
return JSON.stringify(safeWorktreeValue(branchName, GIT_CONTEXT_VALUE_LIMIT_CHARS));
|
|
668
687
|
}
|
|
669
688
|
|
|
689
|
+
export function isLosslessGitMetadata(value: string, limit: number): boolean {
|
|
690
|
+
return safeWorktreeValue(value, limit) === value;
|
|
691
|
+
}
|
|
692
|
+
|
|
670
693
|
export function requireLosslessWorktreeIdentity(
|
|
671
694
|
value: string,
|
|
672
695
|
identity: "cwd" | "branch",
|
|
673
696
|
): string {
|
|
674
697
|
const limit = identity === "cwd" ? GIT_WORKTREE_PATH_LIMIT_CHARS : GIT_CONTEXT_VALUE_LIMIT_CHARS;
|
|
675
|
-
if (
|
|
698
|
+
if (isLosslessGitMetadata(value, limit)) return value;
|
|
676
699
|
|
|
677
700
|
const label = identity === "cwd" ? "canonical worktree path" : "local branch name";
|
|
678
701
|
throw new Error(
|
|
@@ -775,13 +798,13 @@ function stripSingleLineTerminator(value: string): string {
|
|
|
775
798
|
return value;
|
|
776
799
|
}
|
|
777
800
|
|
|
778
|
-
async function
|
|
801
|
+
export async function getCanonicalCommonGitDirectory(
|
|
779
802
|
pi: Pick<ExtensionAPI, "exec">,
|
|
780
|
-
|
|
803
|
+
ctx: GitCommandContext,
|
|
781
804
|
signal?: AbortSignal,
|
|
782
805
|
): Promise<string> {
|
|
783
806
|
const args = ["rev-parse", "--path-format=absolute", "--git-common-dir"];
|
|
784
|
-
const result = await runGit(pi,
|
|
807
|
+
const result = await runGit(pi, ctx, args, {
|
|
785
808
|
signal,
|
|
786
809
|
timeout: GIT_STATUS_TIMEOUT_MS,
|
|
787
810
|
});
|
|
@@ -791,14 +814,77 @@ async function getCommonGitDirectory(
|
|
|
791
814
|
!isAbsolute(commonGitDir) ||
|
|
792
815
|
/[\p{Cc}\p{Cf}\u2028\u2029]/u.test(commonGitDir)
|
|
793
816
|
) {
|
|
794
|
-
throw new Error("Unable to
|
|
817
|
+
throw new Error("Unable to verify repository identity: Git returned an invalid common directory.");
|
|
795
818
|
}
|
|
796
819
|
|
|
797
820
|
try {
|
|
798
821
|
return await realpath(commonGitDir);
|
|
799
822
|
} catch {
|
|
800
|
-
throw new Error("Unable to
|
|
823
|
+
throw new Error("Unable to verify repository identity: the common Git directory could not be resolved.");
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
export type GitOperationKind = "merge" | "rebase" | "cherry-pick" | "revert" | "sequencer";
|
|
828
|
+
|
|
829
|
+
export interface GitOperationState {
|
|
830
|
+
active: GitOperationKind[];
|
|
831
|
+
mergeHeadPresent: boolean;
|
|
832
|
+
autoMergePresent: boolean;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
const GIT_OPERATION_MARKERS: ReadonlyArray<readonly [path: string, operation: GitOperationKind]> = [
|
|
836
|
+
["MERGE_HEAD", "merge"],
|
|
837
|
+
["AUTO_MERGE", "merge"],
|
|
838
|
+
["rebase-merge", "rebase"],
|
|
839
|
+
["rebase-apply", "rebase"],
|
|
840
|
+
["CHERRY_PICK_HEAD", "cherry-pick"],
|
|
841
|
+
["REVERT_HEAD", "revert"],
|
|
842
|
+
["sequencer", "sequencer"],
|
|
843
|
+
];
|
|
844
|
+
|
|
845
|
+
async function gitOperationMarkerExists(path: string): Promise<boolean> {
|
|
846
|
+
try {
|
|
847
|
+
await lstat(path);
|
|
848
|
+
return true;
|
|
849
|
+
} catch (error) {
|
|
850
|
+
if (isMissingFilesystemPath(error)) return false;
|
|
851
|
+
throw new Error("Unable to inspect repository operation state: a Git state path could not be inspected.");
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
export async function getGitOperationState(
|
|
856
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
857
|
+
ctx: GitCommandContext,
|
|
858
|
+
signal?: AbortSignal,
|
|
859
|
+
): Promise<GitOperationState> {
|
|
860
|
+
const args = ["rev-parse", "--path-format=absolute"];
|
|
861
|
+
for (const [path] of GIT_OPERATION_MARKERS) args.push("--git-path", path);
|
|
862
|
+
const result = await runGit(pi, ctx, args, {
|
|
863
|
+
signal,
|
|
864
|
+
timeout: GIT_STATUS_TIMEOUT_MS,
|
|
865
|
+
});
|
|
866
|
+
const paths = stripSingleLineTerminator(result.stdout).split(/\r?\n/u);
|
|
867
|
+
if (
|
|
868
|
+
paths.length !== GIT_OPERATION_MARKERS.length ||
|
|
869
|
+
paths.some((path) => !isAbsolute(path) || /[\p{Cc}\p{Cf}\u2028\u2029]/u.test(path))
|
|
870
|
+
) {
|
|
871
|
+
throw new Error("Unable to inspect repository operation state: Git returned invalid state paths.");
|
|
801
872
|
}
|
|
873
|
+
|
|
874
|
+
const active: GitOperationKind[] = [];
|
|
875
|
+
const presentMarkers: boolean[] = [];
|
|
876
|
+
for (const [index, path] of paths.entries()) {
|
|
877
|
+
const present = await gitOperationMarkerExists(path);
|
|
878
|
+
presentMarkers.push(present);
|
|
879
|
+
if (!present) continue;
|
|
880
|
+
const operation = GIT_OPERATION_MARKERS[index][1];
|
|
881
|
+
if (!active.includes(operation)) active.push(operation);
|
|
882
|
+
}
|
|
883
|
+
return {
|
|
884
|
+
active,
|
|
885
|
+
mergeHeadPresent: presentMarkers[0] === true,
|
|
886
|
+
autoMergePresent: presentMarkers[1] === true,
|
|
887
|
+
};
|
|
802
888
|
}
|
|
803
889
|
|
|
804
890
|
async function prepareWorktreeCreation(
|
|
@@ -820,7 +906,11 @@ async function prepareWorktreeCreation(
|
|
|
820
906
|
}
|
|
821
907
|
}
|
|
822
908
|
|
|
823
|
-
const commonGitDir = await
|
|
909
|
+
const commonGitDir = await getCanonicalCommonGitDirectory(
|
|
910
|
+
pi,
|
|
911
|
+
{ cwd: inventory.repoRoot },
|
|
912
|
+
signal,
|
|
913
|
+
);
|
|
824
914
|
if (pathIsInsideOrEqual(canonicalPath, commonGitDir)) {
|
|
825
915
|
throw new Error("worktreePath destination cannot be inside the repository common Git directory.");
|
|
826
916
|
}
|
|
@@ -1764,6 +1854,64 @@ export async function getLocalBranchCommit(
|
|
|
1764
1854
|
return commit;
|
|
1765
1855
|
}
|
|
1766
1856
|
|
|
1857
|
+
export async function requireExistingLocalBranch(
|
|
1858
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
1859
|
+
ctx: GitCommandContext,
|
|
1860
|
+
branchName: string,
|
|
1861
|
+
label: "Source" | "Target",
|
|
1862
|
+
signal?: AbortSignal,
|
|
1863
|
+
): Promise<void> {
|
|
1864
|
+
if (await localBranchExists(pi, ctx, branchName, signal)) return;
|
|
1865
|
+
throw new Error(`${label} local branch '${safeGitContextValue(branchName)}' does not exist.`);
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
export async function isCommitAncestor(
|
|
1869
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
1870
|
+
ctx: GitCommandContext,
|
|
1871
|
+
ancestorCommit: string,
|
|
1872
|
+
descendantCommit: string,
|
|
1873
|
+
signal?: AbortSignal,
|
|
1874
|
+
): Promise<boolean> {
|
|
1875
|
+
if (!/^[0-9a-f]{40,64}$/iu.test(ancestorCommit) || !/^[0-9a-f]{40,64}$/iu.test(descendantCommit)) {
|
|
1876
|
+
throw new Error("Ancestry verification requires two valid full commit identities.");
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
const args = ["merge-base", "--is-ancestor", ancestorCommit, descendantCommit];
|
|
1880
|
+
const result = await runGit(pi, ctx, args, {
|
|
1881
|
+
signal,
|
|
1882
|
+
timeout: GIT_STATUS_TIMEOUT_MS,
|
|
1883
|
+
allowFailure: true,
|
|
1884
|
+
});
|
|
1885
|
+
if (result.code !== 0 && result.code !== 1) throw new Error(formatGitFailure(args, result));
|
|
1886
|
+
return result.code === 0;
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
export async function getLocalBranchAncestry(
|
|
1890
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
1891
|
+
ctx: GitCommandContext,
|
|
1892
|
+
query: BranchStatusAncestryQuery,
|
|
1893
|
+
signal?: AbortSignal,
|
|
1894
|
+
): Promise<BranchAncestryDetails> {
|
|
1895
|
+
validateBranchNameInput(query.sourceBranch, "Source branch");
|
|
1896
|
+
validateBranchNameInput(query.targetBranch, "Target branch");
|
|
1897
|
+
await validateBranchName(pi, ctx, query.sourceBranch, signal);
|
|
1898
|
+
await validateBranchName(pi, ctx, query.targetBranch, signal);
|
|
1899
|
+
await requireExistingLocalBranch(pi, ctx, query.sourceBranch, "Source", signal);
|
|
1900
|
+
await requireExistingLocalBranch(pi, ctx, query.targetBranch, "Target", signal);
|
|
1901
|
+
|
|
1902
|
+
const sourceHead = await getLocalBranchCommit(pi, ctx, query.sourceBranch, signal);
|
|
1903
|
+
const targetHead = await getLocalBranchCommit(pi, ctx, query.targetBranch, signal);
|
|
1904
|
+
const isAncestor = await isCommitAncestor(pi, ctx, sourceHead, targetHead, signal);
|
|
1905
|
+
|
|
1906
|
+
return {
|
|
1907
|
+
sourceBranch: query.sourceBranch,
|
|
1908
|
+
targetBranch: query.targetBranch,
|
|
1909
|
+
sourceHead,
|
|
1910
|
+
targetHead,
|
|
1911
|
+
isAncestor,
|
|
1912
|
+
};
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1767
1915
|
export async function createLocalBranch(
|
|
1768
1916
|
pi: Pick<ExtensionAPI, "exec">,
|
|
1769
1917
|
ctx: GitCommandContext,
|
|
@@ -7,7 +7,9 @@ import {
|
|
|
7
7
|
CREATE_BRANCH_TOOL_NAME,
|
|
8
8
|
CREATE_WORKTREE_TOOL_NAME,
|
|
9
9
|
FETCH_BRANCH_TOOL_NAME,
|
|
10
|
+
GIT_INTEGRATION_SUMMARY_LIMIT_CHARS,
|
|
10
11
|
GIT_WORKTREE_SUMMARY_LIMIT_CHARS,
|
|
12
|
+
INTEGRATE_BRANCH_TOOL_NAME,
|
|
11
13
|
LIST_WORKTREES_TOOL_NAME,
|
|
12
14
|
PULL_BRANCH_TOOL_NAME,
|
|
13
15
|
PULL_REQUEST_TOOL_NAME,
|
|
@@ -35,6 +37,7 @@ import {
|
|
|
35
37
|
withRepositoryMutationQueue,
|
|
36
38
|
} from "../git.ts";
|
|
37
39
|
import { collectGitContext, formatGitContext } from "../git-context.ts";
|
|
40
|
+
import { integrateBranch } from "../git-integration.ts";
|
|
38
41
|
import {
|
|
39
42
|
createGitHubPullRequest,
|
|
40
43
|
ensureGitHubBranchExists,
|
|
@@ -49,6 +52,7 @@ import type {
|
|
|
49
52
|
ChangeBranchDetails,
|
|
50
53
|
CreateWorktreeDetails,
|
|
51
54
|
GitContextDetails,
|
|
55
|
+
IntegrateBranchDetails,
|
|
52
56
|
ListWorktreesDetails,
|
|
53
57
|
PullRequestDetails,
|
|
54
58
|
PullRequestInput,
|
|
@@ -61,6 +65,29 @@ import type {
|
|
|
61
65
|
|
|
62
66
|
const EmptyParametersSchema = Type.Object({}, { additionalProperties: false });
|
|
63
67
|
|
|
68
|
+
const BranchStatusParametersSchema = Type.Object(
|
|
69
|
+
{
|
|
70
|
+
ancestry: Type.Optional(
|
|
71
|
+
Type.Object(
|
|
72
|
+
{
|
|
73
|
+
sourceBranch: Type.String({ minLength: 1, description: "Exact existing local source branch to verify." }),
|
|
74
|
+
targetBranch: Type.String({ minLength: 1, description: "Exact existing local target branch to verify." }),
|
|
75
|
+
},
|
|
76
|
+
{ additionalProperties: false },
|
|
77
|
+
),
|
|
78
|
+
),
|
|
79
|
+
},
|
|
80
|
+
{ additionalProperties: false },
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const IntegrateBranchParametersSchema = Type.Object(
|
|
84
|
+
{
|
|
85
|
+
sourceBranch: Type.String({ minLength: 1, description: "Exact existing local source branch to integrate." }),
|
|
86
|
+
targetBranch: Type.String({ minLength: 1, description: "Exact existing local target branch already checked out in the clean control worktree." }),
|
|
87
|
+
},
|
|
88
|
+
{ additionalProperties: false },
|
|
89
|
+
);
|
|
90
|
+
|
|
64
91
|
const CreateBranchParametersSchema = Type.Object(
|
|
65
92
|
{
|
|
66
93
|
branchName: Type.String({ minLength: 1, description: "Name of the new branch to create from current HEAD." }),
|
|
@@ -224,6 +251,56 @@ export function formatRemoveWorktree(details: RemoveWorktreeDetails): string {
|
|
|
224
251
|
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.`;
|
|
225
252
|
}
|
|
226
253
|
|
|
254
|
+
function integrationConflictOmissionLine(omitted: number): string {
|
|
255
|
+
return `${omitted} conflict path${omitted === 1 ? "" : "s"} omitted.`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function formatIntegrationConflict(details: Extract<IntegrateBranchDetails, { status: "conflict" }>): string {
|
|
259
|
+
const source = safeWorktreeFormatValue(details.request.sourceBranch, WORKTREE_FORMAT_BRANCH_LIMIT_CHARS);
|
|
260
|
+
const target = safeWorktreeFormatValue(details.request.targetBranch, WORKTREE_FORMAT_BRANCH_LIMIT_CHARS);
|
|
261
|
+
const lines = [
|
|
262
|
+
`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)}.`,
|
|
263
|
+
"Conflict paths:",
|
|
264
|
+
];
|
|
265
|
+
let omitted = details.conflict.omitted;
|
|
266
|
+
|
|
267
|
+
for (const [index, entry] of details.conflict.paths.entries()) {
|
|
268
|
+
const path = safeWorktreeFormatValue(entry.path, WORKTREE_FORMAT_PATH_LIMIT_CHARS);
|
|
269
|
+
const line = `- ${path}`;
|
|
270
|
+
const remaining = details.conflict.paths.length - index - 1;
|
|
271
|
+
const candidateOmitted = details.conflict.omitted + remaining;
|
|
272
|
+
const candidate = [
|
|
273
|
+
...lines,
|
|
274
|
+
line,
|
|
275
|
+
...(candidateOmitted > 0 ? [integrationConflictOmissionLine(candidateOmitted)] : []),
|
|
276
|
+
].join("\n");
|
|
277
|
+
if (candidate.length > GIT_INTEGRATION_SUMMARY_LIMIT_CHARS) {
|
|
278
|
+
omitted += details.conflict.paths.length - index;
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
lines.push(line);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (omitted > 0) lines.push(integrationConflictOmissionLine(omitted));
|
|
285
|
+
return lines.join("\n");
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function formatIntegrateBranch(details: IntegrateBranchDetails): string {
|
|
289
|
+
if (details.status === "conflict") return formatIntegrationConflict(details);
|
|
290
|
+
|
|
291
|
+
const source = safeWorktreeFormatValue(details.request.sourceBranch, WORKTREE_FORMAT_BRANCH_LIMIT_CHARS);
|
|
292
|
+
const target = safeWorktreeFormatValue(details.request.targetBranch, WORKTREE_FORMAT_BRANCH_LIMIT_CHARS);
|
|
293
|
+
const beforeTarget = shortCommit(details.verified.heads.before.targetHead);
|
|
294
|
+
const afterTarget = shortCommit(details.verified.heads.after.targetHead);
|
|
295
|
+
if (details.status === "already_integrated") {
|
|
296
|
+
return `integrate_branch verified ${source} was already integrated into ${target}; no merge ran and target HEAD remained ${afterTarget}.`;
|
|
297
|
+
}
|
|
298
|
+
if (details.status === "fast_forward") {
|
|
299
|
+
return `integrate_branch fast-forwarded ${target} from ${beforeTarget} to ${afterTarget}, integrating ${source}.`;
|
|
300
|
+
}
|
|
301
|
+
return `integrate_branch created and verified a merge commit ${afterTarget} on ${target} from previous target ${beforeTarget} and source ${source}.`;
|
|
302
|
+
}
|
|
303
|
+
|
|
227
304
|
const PULL_REQUEST_INPUT_FIELDS: PullRequestInputField[] = ["headBranch", "baseBranch", "title", "body", "draft"];
|
|
228
305
|
|
|
229
306
|
interface ResolvedPullRequestBranches {
|
|
@@ -381,16 +458,18 @@ export function registerBranchMeTools(pi: Pick<ExtensionAPI, "registerTool" | "e
|
|
|
381
458
|
pi.registerTool({
|
|
382
459
|
name: BRANCH_STATUS_TOOL_NAME,
|
|
383
460
|
label: "Branch Status",
|
|
384
|
-
description: "branch_status explicitly refreshes the current Git repository snapshot
|
|
385
|
-
promptSnippet: "branch_status: explicitly refresh current-repository
|
|
461
|
+
description: "branch_status explicitly refreshes the current Git repository snapshot and can optionally verify whether one captured local branch commit is an ancestor of another. branch_status is read-only and never mutates files, Git state, or GitHub state.",
|
|
462
|
+
promptSnippet: "branch_status: explicitly refresh current-repository Git state and optionally verify targeted local-branch ancestry without mutation",
|
|
386
463
|
promptGuidelines: [
|
|
387
464
|
"Use the automatic Git context for start-of-run questions; call branch_status only for an explicit refresh or after Git state changes during the current run.",
|
|
388
465
|
"Use branch_status as a read-only refresh; branch_status never mutates files, Git state, or GitHub state.",
|
|
466
|
+
"Use targeted branch_status ancestry verification only after integrate_branch completes; do not issue branch_status in the same parallel tool batch as integrate_branch.",
|
|
389
467
|
],
|
|
390
|
-
parameters:
|
|
391
|
-
async execute(_toolCallId,
|
|
468
|
+
parameters: BranchStatusParametersSchema,
|
|
469
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
392
470
|
const details = await collectGitContext(pi, ctx, {
|
|
393
471
|
signal,
|
|
472
|
+
ancestry: params.ancestry,
|
|
394
473
|
env: options.env,
|
|
395
474
|
fetchImpl: options.fetchImpl,
|
|
396
475
|
});
|
|
@@ -514,6 +593,29 @@ export function registerBranchMeTools(pi: Pick<ExtensionAPI, "registerTool" | "e
|
|
|
514
593
|
},
|
|
515
594
|
});
|
|
516
595
|
|
|
596
|
+
pi.registerTool({
|
|
597
|
+
name: INTEGRATE_BRANCH_TOOL_NAME,
|
|
598
|
+
label: "Integrate Branch",
|
|
599
|
+
description: "integrate_branch verifies and merges one exact existing local source branch into one exact existing local target branch from the current clean control worktree, returning an already-integrated, fast-forward, merge-commit, or automatically aborted conflict result. integrate_branch never fetches or pushes.",
|
|
600
|
+
promptSnippet: "integrate_branch: merge an exact local source branch into the checked-out clean local target with verified outcomes and automatic conflict abort",
|
|
601
|
+
promptGuidelines: [
|
|
602
|
+
"Use integrate_branch only when the user explicitly wants one exact existing local source branch integrated into one exact existing local target branch.",
|
|
603
|
+
"Before integrate_branch, require the current clean control worktree to already have targetBranch checked out; integrate_branch never switches branches, stashes, or discards changes.",
|
|
604
|
+
"Use integrate_branch only after local refs already contain the commits to integrate; integrate_branch never fetches or pushes.",
|
|
605
|
+
"Call integrate_branch by itself and wait for it to complete; never batch integrate_branch with other Git mutations.",
|
|
606
|
+
"After integrate_branch completes, run any targeted branch_status ancestry proof separately; never place integrate_branch and branch_status in the same parallel tool batch.",
|
|
607
|
+
"A conflict result from integrate_branch means the merge was automatically aborted and restoration was verified; semantic conflict analysis belongs to a separate delegated workflow, not integrate_branch.",
|
|
608
|
+
],
|
|
609
|
+
parameters: IntegrateBranchParametersSchema,
|
|
610
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
611
|
+
const details = await integrateBranch(pi, ctx, params, signal);
|
|
612
|
+
return {
|
|
613
|
+
content: [{ type: "text", text: formatIntegrateBranch(details) }],
|
|
614
|
+
details,
|
|
615
|
+
};
|
|
616
|
+
},
|
|
617
|
+
});
|
|
618
|
+
|
|
517
619
|
pi.registerTool({
|
|
518
620
|
name: PUSH_BRANCH_TOOL_NAME,
|
|
519
621
|
label: "Push Branch",
|
package/src/types.ts
CHANGED
|
@@ -20,6 +20,20 @@ export interface WorkingTreeDetails {
|
|
|
20
20
|
untracked: number;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
export interface BranchStatusAncestryQuery {
|
|
24
|
+
sourceBranch: string;
|
|
25
|
+
targetBranch: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface BranchStatusToolInput {
|
|
29
|
+
ancestry?: BranchStatusAncestryQuery;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface IntegrateBranchToolInput {
|
|
33
|
+
sourceBranch: string;
|
|
34
|
+
targetBranch: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
23
37
|
export type CreateWorktreeMode = "new" | "existing";
|
|
24
38
|
|
|
25
39
|
export type ListWorktreesToolInput = Record<string, never>;
|
|
@@ -165,6 +179,12 @@ export type RelatedPullRequest =
|
|
|
165
179
|
| { status: "none" }
|
|
166
180
|
| { status: "unavailable"; reason: string };
|
|
167
181
|
|
|
182
|
+
export interface BranchAncestryDetails extends BranchStatusAncestryQuery {
|
|
183
|
+
sourceHead: string;
|
|
184
|
+
targetHead: string;
|
|
185
|
+
isAncestor: boolean;
|
|
186
|
+
}
|
|
187
|
+
|
|
168
188
|
export interface BranchStatusDetails {
|
|
169
189
|
repoRoot: string;
|
|
170
190
|
currentBranch: string | null;
|
|
@@ -173,6 +193,7 @@ export interface BranchStatusDetails {
|
|
|
173
193
|
hasChanges: boolean;
|
|
174
194
|
ahead: number | null;
|
|
175
195
|
behind: number | null;
|
|
196
|
+
ancestry?: BranchAncestryDetails;
|
|
176
197
|
warnings?: string[];
|
|
177
198
|
githubRepository?: GitHubRepository;
|
|
178
199
|
}
|
|
@@ -239,6 +260,100 @@ export interface PushBranchDetails {
|
|
|
239
260
|
output: string;
|
|
240
261
|
}
|
|
241
262
|
|
|
263
|
+
export type IntegrateBranchStatus = "already_integrated" | "fast_forward" | "merge_commit" | "conflict";
|
|
264
|
+
|
|
265
|
+
export interface IntegrateBranchHeads {
|
|
266
|
+
sourceHead: string;
|
|
267
|
+
targetHead: string;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export interface IntegrateBranchRepositoryVerification {
|
|
271
|
+
worktreeRoot: string;
|
|
272
|
+
canonicalCommonGitDirectory: string;
|
|
273
|
+
identityPreserved: true;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export interface IntegrateBranchControlWorktreeVerification {
|
|
277
|
+
targetBranch: string;
|
|
278
|
+
currentBranchPreserved: true;
|
|
279
|
+
cleanBefore: true;
|
|
280
|
+
cleanAfter: true;
|
|
281
|
+
operationStateAbsentBefore: true;
|
|
282
|
+
operationStateAbsentAfter: true;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export interface IntegrateBranchFinalAncestryProof {
|
|
286
|
+
sourceHead: string;
|
|
287
|
+
previousTargetHead: string;
|
|
288
|
+
targetHead: string;
|
|
289
|
+
sourceIsAncestorOfTarget: boolean;
|
|
290
|
+
previousTargetIsAncestorOfTarget: boolean;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export interface IntegrateBranchVerification {
|
|
294
|
+
repository: IntegrateBranchRepositoryVerification;
|
|
295
|
+
controlWorktree: IntegrateBranchControlWorktreeVerification;
|
|
296
|
+
heads: {
|
|
297
|
+
before: IntegrateBranchHeads;
|
|
298
|
+
after: IntegrateBranchHeads;
|
|
299
|
+
};
|
|
300
|
+
finalAncestry: IntegrateBranchFinalAncestryProof;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export interface IntegrateBranchDetailsBase {
|
|
304
|
+
action: "integrate_branch";
|
|
305
|
+
request: IntegrateBranchToolInput;
|
|
306
|
+
verified: IntegrateBranchVerification;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export interface IntegrateBranchAlreadyIntegratedDetails extends IntegrateBranchDetailsBase {
|
|
310
|
+
status: "already_integrated";
|
|
311
|
+
mergeExecuted: false;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export interface IntegrateBranchFastForwardDetails extends IntegrateBranchDetailsBase {
|
|
315
|
+
status: "fast_forward";
|
|
316
|
+
mergeExecuted: true;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export interface IntegrateBranchMergeCommitDetails extends IntegrateBranchDetailsBase {
|
|
320
|
+
status: "merge_commit";
|
|
321
|
+
mergeExecuted: true;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export interface IntegrateBranchConflictPathEntry {
|
|
325
|
+
/** Exact repository-relative identity; human-readable renderers must sanitize a separate display copy. */
|
|
326
|
+
path: string;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export interface IntegrateBranchConflictDetails extends IntegrateBranchDetailsBase {
|
|
330
|
+
status: "conflict";
|
|
331
|
+
mergeExecuted: true;
|
|
332
|
+
conflict: {
|
|
333
|
+
paths: IntegrateBranchConflictPathEntry[];
|
|
334
|
+
omitted: number;
|
|
335
|
+
abort: {
|
|
336
|
+
attempted: true;
|
|
337
|
+
succeeded: true;
|
|
338
|
+
};
|
|
339
|
+
restoration: {
|
|
340
|
+
verified: true;
|
|
341
|
+
repositoryIdentityPreserved: true;
|
|
342
|
+
controlWorktreeBranchPreserved: true;
|
|
343
|
+
sourceHeadPreserved: true;
|
|
344
|
+
targetHeadRestored: true;
|
|
345
|
+
operationStateCleared: true;
|
|
346
|
+
cleanControlWorktree: true;
|
|
347
|
+
};
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export type IntegrateBranchDetails =
|
|
352
|
+
| IntegrateBranchAlreadyIntegratedDetails
|
|
353
|
+
| IntegrateBranchFastForwardDetails
|
|
354
|
+
| IntegrateBranchMergeCommitDetails
|
|
355
|
+
| IntegrateBranchConflictDetails;
|
|
356
|
+
|
|
242
357
|
export interface PullRequestDetails {
|
|
243
358
|
repository: GitHubRepository;
|
|
244
359
|
number: number;
|
package/src/ui/branchme-panel.ts
CHANGED
|
@@ -24,14 +24,14 @@ type PanelCell =
|
|
|
24
24
|
preserveLeading?: boolean;
|
|
25
25
|
};
|
|
26
26
|
|
|
27
|
-
export type BranchMePanelSection = "status" | "workflow" | "worktrees";
|
|
27
|
+
export type BranchMePanelSection = "status" | "workflow" | "integration" | "worktrees";
|
|
28
28
|
|
|
29
29
|
const NARROW_MIN_WIDTH = 24;
|
|
30
30
|
const WIDE_MIN_WIDTH = 72;
|
|
31
31
|
const MAX_PANEL_WIDTH = 96;
|
|
32
32
|
const MAX_PANEL_HEIGHT = 16;
|
|
33
33
|
const BODY_HEIGHT = 9;
|
|
34
|
-
const PANEL_SECTIONS: BranchMePanelSection[] = ["status", "workflow", "worktrees"];
|
|
34
|
+
const PANEL_SECTIONS: BranchMePanelSection[] = ["status", "workflow", "integration", "worktrees"];
|
|
35
35
|
const ANSI_RESET = "\u001b[0m";
|
|
36
36
|
|
|
37
37
|
function truncateVisible(value: string, width: number, ellipsis = "…", pad = false): string {
|
|
@@ -144,7 +144,7 @@ function statusDetailRow(label: string, value: string): PanelCell {
|
|
|
144
144
|
}
|
|
145
145
|
|
|
146
146
|
function workflowDetailRow(tool: string, description: string): PanelCell {
|
|
147
|
-
return layoutCell(` ${tool.padEnd(
|
|
147
|
+
return layoutCell(` ${tool.padEnd(17, " ")}-> ${sanitize(description)}`);
|
|
148
148
|
}
|
|
149
149
|
|
|
150
150
|
function sectionTitle(section: BranchMePanelSection): string {
|
|
@@ -153,6 +153,8 @@ function sectionTitle(section: BranchMePanelSection): string {
|
|
|
153
153
|
return "STATUS";
|
|
154
154
|
case "workflow":
|
|
155
155
|
return "WORKFLOW";
|
|
156
|
+
case "integration":
|
|
157
|
+
return "INTEGRATION";
|
|
156
158
|
case "worktrees":
|
|
157
159
|
return "WORKTREES";
|
|
158
160
|
}
|
|
@@ -180,6 +182,8 @@ function sectionFooter(section: BranchMePanelSection, data: BranchMePanelData):
|
|
|
180
182
|
return "status • current repository only • tools perform actions";
|
|
181
183
|
case "workflow":
|
|
182
184
|
return "workflow • inspect → change → fetch/pull/rebase → create → push → PR";
|
|
185
|
+
case "integration":
|
|
186
|
+
return "integration • local refs • clean target • conflicts auto-abort";
|
|
183
187
|
case "worktrees":
|
|
184
188
|
return "worktrees • create → handoff cwd → next session • remove retains branch";
|
|
185
189
|
}
|
|
@@ -208,6 +212,15 @@ function sectionRows(section: BranchMePanelSection, data: BranchMePanelData): Pa
|
|
|
208
212
|
workflowDetailRow("push_branch", "current branch"),
|
|
209
213
|
workflowDetailRow("pull_request", "after push"),
|
|
210
214
|
];
|
|
215
|
+
case "integration":
|
|
216
|
+
return [
|
|
217
|
+
heading("INTEGRATION"),
|
|
218
|
+
workflowDetailRow("integrate_branch", "exact local source -> target"),
|
|
219
|
+
workflowDetailRow("control target", "checked out + clean"),
|
|
220
|
+
workflowDetailRow("remote effects", "never fetch or push"),
|
|
221
|
+
workflowDetailRow("conflict", "automatic verified abort"),
|
|
222
|
+
workflowDetailRow("semantic intent", "separate developer workflow"),
|
|
223
|
+
];
|
|
211
224
|
case "worktrees":
|
|
212
225
|
return [
|
|
213
226
|
heading("WORKTREES"),
|