@senad-d/branchme 0.1.8 → 0.2.0

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/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,
@@ -610,6 +629,30 @@ export function parseWorktreePorcelain(output: string): Pick<ListWorktreesDetail
610
629
  return { worktrees, omitted: records.length - returnedCount };
611
630
  }
612
631
 
632
+ export interface LocalBranchWorktreeOccupancy {
633
+ branchName: string;
634
+ completeInventoryInspected: true;
635
+ occupied: boolean;
636
+ matchingWorktreeCount: number;
637
+ }
638
+
639
+ export interface PresentDirectLocalBranchRef {
640
+ status: "present";
641
+ branchName: string;
642
+ fullRef: string;
643
+ objectId: string;
644
+ }
645
+
646
+ export interface AbsentDirectLocalBranchRef {
647
+ status: "absent";
648
+ branchName: string;
649
+ fullRef: string;
650
+ }
651
+
652
+ export type DirectLocalBranchRefInspection =
653
+ | PresentDirectLocalBranchRef
654
+ | AbsentDirectLocalBranchRef;
655
+
613
656
  interface WorktreeInventoryEntry {
614
657
  record: ParsedWorktreeRecord;
615
658
  index: number;
@@ -667,12 +710,16 @@ function safeWorktreeBranchLabel(branchName: string): string {
667
710
  return JSON.stringify(safeWorktreeValue(branchName, GIT_CONTEXT_VALUE_LIMIT_CHARS));
668
711
  }
669
712
 
713
+ export function isLosslessGitMetadata(value: string, limit: number): boolean {
714
+ return safeWorktreeValue(value, limit) === value;
715
+ }
716
+
670
717
  export function requireLosslessWorktreeIdentity(
671
718
  value: string,
672
719
  identity: "cwd" | "branch",
673
720
  ): string {
674
721
  const limit = identity === "cwd" ? GIT_WORKTREE_PATH_LIMIT_CHARS : GIT_CONTEXT_VALUE_LIMIT_CHARS;
675
- if (safeWorktreeValue(value, limit) === value) return value;
722
+ if (isLosslessGitMetadata(value, limit)) return value;
676
723
 
677
724
  const label = identity === "cwd" ? "canonical worktree path" : "local branch name";
678
725
  throw new Error(
@@ -775,13 +822,13 @@ function stripSingleLineTerminator(value: string): string {
775
822
  return value;
776
823
  }
777
824
 
778
- async function getCommonGitDirectory(
825
+ export async function getCanonicalCommonGitDirectory(
779
826
  pi: Pick<ExtensionAPI, "exec">,
780
- repoRoot: string,
827
+ ctx: GitCommandContext,
781
828
  signal?: AbortSignal,
782
829
  ): Promise<string> {
783
830
  const args = ["rev-parse", "--path-format=absolute", "--git-common-dir"];
784
- const result = await runGit(pi, { cwd: repoRoot }, args, {
831
+ const result = await runGit(pi, ctx, args, {
785
832
  signal,
786
833
  timeout: GIT_STATUS_TIMEOUT_MS,
787
834
  });
@@ -791,14 +838,77 @@ async function getCommonGitDirectory(
791
838
  !isAbsolute(commonGitDir) ||
792
839
  /[\p{Cc}\p{Cf}\u2028\u2029]/u.test(commonGitDir)
793
840
  ) {
794
- throw new Error("Unable to validate worktree path: Git returned an invalid common directory.");
841
+ throw new Error("Unable to verify repository identity: Git returned an invalid common directory.");
795
842
  }
796
843
 
797
844
  try {
798
845
  return await realpath(commonGitDir);
799
846
  } catch {
800
- throw new Error("Unable to validate worktree path: Git common directory could not be resolved.");
847
+ throw new Error("Unable to verify repository identity: the common Git directory could not be resolved.");
848
+ }
849
+ }
850
+
851
+ export type GitOperationKind = "merge" | "rebase" | "cherry-pick" | "revert" | "sequencer";
852
+
853
+ export interface GitOperationState {
854
+ active: GitOperationKind[];
855
+ mergeHeadPresent: boolean;
856
+ autoMergePresent: boolean;
857
+ }
858
+
859
+ const GIT_OPERATION_MARKERS: ReadonlyArray<readonly [path: string, operation: GitOperationKind]> = [
860
+ ["MERGE_HEAD", "merge"],
861
+ ["AUTO_MERGE", "merge"],
862
+ ["rebase-merge", "rebase"],
863
+ ["rebase-apply", "rebase"],
864
+ ["CHERRY_PICK_HEAD", "cherry-pick"],
865
+ ["REVERT_HEAD", "revert"],
866
+ ["sequencer", "sequencer"],
867
+ ];
868
+
869
+ async function gitOperationMarkerExists(path: string): Promise<boolean> {
870
+ try {
871
+ await lstat(path);
872
+ return true;
873
+ } catch (error) {
874
+ if (isMissingFilesystemPath(error)) return false;
875
+ throw new Error("Unable to inspect repository operation state: a Git state path could not be inspected.");
876
+ }
877
+ }
878
+
879
+ export async function getGitOperationState(
880
+ pi: Pick<ExtensionAPI, "exec">,
881
+ ctx: GitCommandContext,
882
+ signal?: AbortSignal,
883
+ ): Promise<GitOperationState> {
884
+ const args = ["rev-parse", "--path-format=absolute"];
885
+ for (const [path] of GIT_OPERATION_MARKERS) args.push("--git-path", path);
886
+ const result = await runGit(pi, ctx, args, {
887
+ signal,
888
+ timeout: GIT_STATUS_TIMEOUT_MS,
889
+ });
890
+ const paths = stripSingleLineTerminator(result.stdout).split(/\r?\n/u);
891
+ if (
892
+ paths.length !== GIT_OPERATION_MARKERS.length ||
893
+ paths.some((path) => !isAbsolute(path) || /[\p{Cc}\p{Cf}\u2028\u2029]/u.test(path))
894
+ ) {
895
+ throw new Error("Unable to inspect repository operation state: Git returned invalid state paths.");
801
896
  }
897
+
898
+ const active: GitOperationKind[] = [];
899
+ const presentMarkers: boolean[] = [];
900
+ for (const [index, path] of paths.entries()) {
901
+ const present = await gitOperationMarkerExists(path);
902
+ presentMarkers.push(present);
903
+ if (!present) continue;
904
+ const operation = GIT_OPERATION_MARKERS[index][1];
905
+ if (!active.includes(operation)) active.push(operation);
906
+ }
907
+ return {
908
+ active,
909
+ mergeHeadPresent: presentMarkers[0] === true,
910
+ autoMergePresent: presentMarkers[1] === true,
911
+ };
802
912
  }
803
913
 
804
914
  async function prepareWorktreeCreation(
@@ -820,7 +930,11 @@ async function prepareWorktreeCreation(
820
930
  }
821
931
  }
822
932
 
823
- const commonGitDir = await getCommonGitDirectory(pi, inventory.repoRoot, signal);
933
+ const commonGitDir = await getCanonicalCommonGitDirectory(
934
+ pi,
935
+ { cwd: inventory.repoRoot },
936
+ signal,
937
+ );
824
938
  if (pathIsInsideOrEqual(canonicalPath, commonGitDir)) {
825
939
  throw new Error("worktreePath destination cannot be inside the repository common Git directory.");
826
940
  }
@@ -908,6 +1022,33 @@ export async function listWorktrees(
908
1022
  };
909
1023
  }
910
1024
 
1025
+ export async function inspectLocalBranchWorktreeOccupancy(
1026
+ pi: Pick<ExtensionAPI, "exec">,
1027
+ ctx: GitCommandContext,
1028
+ branchName: string,
1029
+ signal?: AbortSignal,
1030
+ ): Promise<LocalBranchWorktreeOccupancy> {
1031
+ validateBranchNameInput(branchName);
1032
+ requireLosslessWorktreeIdentity(branchName, "branch");
1033
+ const repoRoot = await getGitRoot(pi, ctx, signal);
1034
+ const args = ["worktree", "list", "--porcelain", "-z"];
1035
+ const result = await runGit(pi, { cwd: repoRoot }, args, {
1036
+ signal,
1037
+ timeout: GIT_STATUS_TIMEOUT_MS,
1038
+ });
1039
+ const records = parseWorktreeRecords(result.stdout);
1040
+ let matchingWorktreeCount = 0;
1041
+ for (const record of records) {
1042
+ if (record.branch === branchName) matchingWorktreeCount += 1;
1043
+ }
1044
+ return {
1045
+ branchName,
1046
+ completeInventoryInspected: true,
1047
+ occupied: matchingWorktreeCount > 0,
1048
+ matchingWorktreeCount,
1049
+ };
1050
+ }
1051
+
911
1052
  function validateCreateWorktreeMode(branchMode: unknown): asserts branchMode is CreateWorktreeMode {
912
1053
  if (branchMode !== "new" && branchMode !== "existing") {
913
1054
  throw new Error("branchMode must be either 'new' or 'existing'.");
@@ -1746,6 +1887,113 @@ export async function localBranchExists(
1746
1887
  return result.code === 0;
1747
1888
  }
1748
1889
 
1890
+ function requireLosslessLocalRef(branchName: string): string {
1891
+ requireLosslessWorktreeIdentity(branchName, "branch");
1892
+ const fullRef = `refs/heads/${branchName}`;
1893
+ if (!isLosslessGitMetadata(fullRef, GIT_CONTEXT_VALUE_LIMIT_CHARS)) {
1894
+ throw new Error(
1895
+ "The full local branch ref cannot be returned safely and losslessly. " +
1896
+ `Use a branch name short enough to keep the full ref within ${GIT_CONTEXT_VALUE_LIMIT_CHARS} characters.`,
1897
+ );
1898
+ }
1899
+ return fullRef;
1900
+ }
1901
+
1902
+ interface ExactLocalRefListing {
1903
+ exactObjectId: string | null;
1904
+ exactRefFound: boolean;
1905
+ }
1906
+
1907
+ function parseExactLocalRefListing(
1908
+ branchName: string,
1909
+ fullRef: string,
1910
+ output: string,
1911
+ ): ExactLocalRefListing {
1912
+ const row = stripSingleLineTerminator(output);
1913
+ if (!row) return { exactObjectId: null, exactRefFound: false };
1914
+
1915
+ const fields = row.split(NUL_SEPARATOR);
1916
+ if (fields.length !== 2 || !fields[0] || !fields[1]) {
1917
+ throw new Error(
1918
+ `Unable to inspect local branch ${safeWorktreeBranchLabel(branchName)}: ` +
1919
+ "Git returned a malformed exact-ref listing.",
1920
+ );
1921
+ }
1922
+ const [observedRef, objectId] = fields;
1923
+ if (observedRef !== fullRef) {
1924
+ if (observedRef.startsWith(`${fullRef}/`)) {
1925
+ return { exactObjectId: null, exactRefFound: false };
1926
+ }
1927
+ throw new Error(
1928
+ `Unable to inspect local branch ${safeWorktreeBranchLabel(branchName)}: ` +
1929
+ "Git returned a different ref identity for the requested exact local branch.",
1930
+ );
1931
+ }
1932
+ if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/iu.test(objectId)) {
1933
+ throw new Error(
1934
+ `Unable to inspect local branch ${safeWorktreeBranchLabel(branchName)}: ` +
1935
+ "Git returned a malformed full object identity.",
1936
+ );
1937
+ }
1938
+ return { exactObjectId: objectId, exactRefFound: true };
1939
+ }
1940
+
1941
+ async function rejectSymbolicLocalBranchRef(
1942
+ pi: Pick<ExtensionAPI, "exec">,
1943
+ ctx: GitCommandContext,
1944
+ branchName: string,
1945
+ fullRef: string,
1946
+ signal?: AbortSignal,
1947
+ ): Promise<void> {
1948
+ const symbolicRefArgs = ["symbolic-ref", "--quiet", fullRef];
1949
+ const symbolicRef = await runGit(pi, ctx, symbolicRefArgs, {
1950
+ signal,
1951
+ timeout: GIT_STATUS_TIMEOUT_MS,
1952
+ allowFailure: true,
1953
+ });
1954
+ if (symbolicRef.code === 0) {
1955
+ throw new Error(
1956
+ `Local branch ${safeWorktreeBranchLabel(branchName)} is symbolic; ` +
1957
+ "retirement requires a verified direct local branch ref.",
1958
+ );
1959
+ }
1960
+ if (symbolicRef.code !== 1 || symbolicRef.stdout.length > 0 || symbolicRef.stderr.length > 0) {
1961
+ throw new Error(formatGitFailure(symbolicRefArgs, symbolicRef));
1962
+ }
1963
+ }
1964
+
1965
+ export async function inspectDirectLocalBranchRef(
1966
+ pi: Pick<ExtensionAPI, "exec">,
1967
+ ctx: GitCommandContext,
1968
+ branchName: string,
1969
+ signal?: AbortSignal,
1970
+ ): Promise<DirectLocalBranchRefInspection> {
1971
+ validateBranchNameInput(branchName);
1972
+ const fullRef = requireLosslessLocalRef(branchName);
1973
+ const listArgs = [
1974
+ "for-each-ref",
1975
+ "--count=1",
1976
+ "--sort=refname",
1977
+ "--format=%(refname)%00%(objectname)",
1978
+ fullRef,
1979
+ ];
1980
+ const listing = await runGit(pi, ctx, listArgs, {
1981
+ signal,
1982
+ timeout: GIT_STATUS_TIMEOUT_MS,
1983
+ });
1984
+ const parsed = parseExactLocalRefListing(branchName, fullRef, listing.stdout);
1985
+ await rejectSymbolicLocalBranchRef(pi, ctx, branchName, fullRef, signal);
1986
+ if (!parsed.exactRefFound || parsed.exactObjectId === null) {
1987
+ return { status: "absent", branchName, fullRef };
1988
+ }
1989
+ return {
1990
+ status: "present",
1991
+ branchName,
1992
+ fullRef,
1993
+ objectId: parsed.exactObjectId,
1994
+ };
1995
+ }
1996
+
1749
1997
  export async function getLocalBranchCommit(
1750
1998
  pi: Pick<ExtensionAPI, "exec">,
1751
1999
  ctx: GitCommandContext,
@@ -1764,6 +2012,64 @@ export async function getLocalBranchCommit(
1764
2012
  return commit;
1765
2013
  }
1766
2014
 
2015
+ export async function requireExistingLocalBranch(
2016
+ pi: Pick<ExtensionAPI, "exec">,
2017
+ ctx: GitCommandContext,
2018
+ branchName: string,
2019
+ label: "Source" | "Target",
2020
+ signal?: AbortSignal,
2021
+ ): Promise<void> {
2022
+ if (await localBranchExists(pi, ctx, branchName, signal)) return;
2023
+ throw new Error(`${label} local branch '${safeGitContextValue(branchName)}' does not exist.`);
2024
+ }
2025
+
2026
+ export async function isCommitAncestor(
2027
+ pi: Pick<ExtensionAPI, "exec">,
2028
+ ctx: GitCommandContext,
2029
+ ancestorCommit: string,
2030
+ descendantCommit: string,
2031
+ signal?: AbortSignal,
2032
+ ): Promise<boolean> {
2033
+ if (!/^[0-9a-f]{40,64}$/iu.test(ancestorCommit) || !/^[0-9a-f]{40,64}$/iu.test(descendantCommit)) {
2034
+ throw new Error("Ancestry verification requires two valid full commit identities.");
2035
+ }
2036
+
2037
+ const args = ["merge-base", "--is-ancestor", ancestorCommit, descendantCommit];
2038
+ const result = await runGit(pi, ctx, args, {
2039
+ signal,
2040
+ timeout: GIT_STATUS_TIMEOUT_MS,
2041
+ allowFailure: true,
2042
+ });
2043
+ if (result.code !== 0 && result.code !== 1) throw new Error(formatGitFailure(args, result));
2044
+ return result.code === 0;
2045
+ }
2046
+
2047
+ export async function getLocalBranchAncestry(
2048
+ pi: Pick<ExtensionAPI, "exec">,
2049
+ ctx: GitCommandContext,
2050
+ query: BranchStatusAncestryQuery,
2051
+ signal?: AbortSignal,
2052
+ ): Promise<BranchAncestryDetails> {
2053
+ validateBranchNameInput(query.sourceBranch, "Source branch");
2054
+ validateBranchNameInput(query.targetBranch, "Target branch");
2055
+ await validateBranchName(pi, ctx, query.sourceBranch, signal);
2056
+ await validateBranchName(pi, ctx, query.targetBranch, signal);
2057
+ await requireExistingLocalBranch(pi, ctx, query.sourceBranch, "Source", signal);
2058
+ await requireExistingLocalBranch(pi, ctx, query.targetBranch, "Target", signal);
2059
+
2060
+ const sourceHead = await getLocalBranchCommit(pi, ctx, query.sourceBranch, signal);
2061
+ const targetHead = await getLocalBranchCommit(pi, ctx, query.targetBranch, signal);
2062
+ const isAncestor = await isCommitAncestor(pi, ctx, sourceHead, targetHead, signal);
2063
+
2064
+ return {
2065
+ sourceBranch: query.sourceBranch,
2066
+ targetBranch: query.targetBranch,
2067
+ sourceHead,
2068
+ targetHead,
2069
+ isAncestor,
2070
+ };
2071
+ }
2072
+
1767
2073
  export async function createLocalBranch(
1768
2074
  pi: Pick<ExtensionAPI, "exec">,
1769
2075
  ctx: GitCommandContext,
@@ -7,13 +7,17 @@ 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,
11
+ GIT_RETIREMENT_SUMMARY_LIMIT_CHARS,
10
12
  GIT_WORKTREE_SUMMARY_LIMIT_CHARS,
13
+ INTEGRATE_BRANCH_TOOL_NAME,
11
14
  LIST_WORKTREES_TOOL_NAME,
12
15
  PULL_BRANCH_TOOL_NAME,
13
16
  PULL_REQUEST_TOOL_NAME,
14
17
  PUSH_BRANCH_TOOL_NAME,
15
18
  REBASE_BRANCH_TOOL_NAME,
16
19
  REMOVE_WORKTREE_TOOL_NAME,
20
+ RETIRE_BRANCH_TOOL_NAME,
17
21
  } from "../constants.ts";
18
22
  import {
19
23
  changeExistingLocalBranch,
@@ -35,6 +39,8 @@ import {
35
39
  withRepositoryMutationQueue,
36
40
  } from "../git.ts";
37
41
  import { collectGitContext, formatGitContext } from "../git-context.ts";
42
+ import { integrateBranch } from "../git-integration.ts";
43
+ import { retireBranch } from "../git-retirement.ts";
38
44
  import {
39
45
  createGitHubPullRequest,
40
46
  ensureGitHubBranchExists,
@@ -49,6 +55,7 @@ import type {
49
55
  ChangeBranchDetails,
50
56
  CreateWorktreeDetails,
51
57
  GitContextDetails,
58
+ IntegrateBranchDetails,
52
59
  ListWorktreesDetails,
53
60
  PullRequestDetails,
54
61
  PullRequestInput,
@@ -56,11 +63,48 @@ import type {
56
63
  PullRequestToolDetails,
57
64
  PullRequestToolInput,
58
65
  RemoveWorktreeDetails,
66
+ RetireBranchDetails,
59
67
  WorktreeEntry,
60
68
  } from "../types.ts";
61
69
 
62
70
  const EmptyParametersSchema = Type.Object({}, { additionalProperties: false });
63
71
 
72
+ const BranchStatusParametersSchema = Type.Object(
73
+ {
74
+ ancestry: Type.Optional(
75
+ Type.Object(
76
+ {
77
+ sourceBranch: Type.String({ minLength: 1, description: "Exact existing local source branch to verify." }),
78
+ targetBranch: Type.String({ minLength: 1, description: "Exact existing local target branch to verify." }),
79
+ },
80
+ { additionalProperties: false },
81
+ ),
82
+ ),
83
+ },
84
+ { additionalProperties: false },
85
+ );
86
+
87
+ const IntegrateBranchParametersSchema = Type.Object(
88
+ {
89
+ sourceBranch: Type.String({ minLength: 1, description: "Exact existing local source branch to integrate." }),
90
+ targetBranch: Type.String({ minLength: 1, description: "Exact existing local target branch already checked out in the clean control worktree." }),
91
+ },
92
+ { additionalProperties: false },
93
+ );
94
+
95
+ const RetireBranchParametersSchema = Type.Object(
96
+ {
97
+ branchName: Type.String({ minLength: 1, description: "Exact existing local branch to retire." }),
98
+ expectedHead: Type.String({
99
+ pattern: "^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$",
100
+ description: "Full 40- or 64-character hexadecimal commit identity expected for branchName.",
101
+ }),
102
+ targetBranch: Type.String({ minLength: 1, description: "Exact existing local branch used for ancestry verification." }),
103
+ force: Type.Boolean({ description: "Explicit authorization for unmerged retirement; false for merged retirement." }),
104
+ },
105
+ { additionalProperties: false },
106
+ );
107
+
64
108
  const CreateBranchParametersSchema = Type.Object(
65
109
  {
66
110
  branchName: Type.String({ minLength: 1, description: "Name of the new branch to create from current HEAD." }),
@@ -224,6 +268,68 @@ export function formatRemoveWorktree(details: RemoveWorktreeDetails): string {
224
268
  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
269
  }
226
270
 
271
+ function integrationConflictOmissionLine(omitted: number): string {
272
+ return `${omitted} conflict path${omitted === 1 ? "" : "s"} omitted.`;
273
+ }
274
+
275
+ function formatIntegrationConflict(details: Extract<IntegrateBranchDetails, { status: "conflict" }>): string {
276
+ const source = safeWorktreeFormatValue(details.request.sourceBranch, WORKTREE_FORMAT_BRANCH_LIMIT_CHARS);
277
+ const target = safeWorktreeFormatValue(details.request.targetBranch, WORKTREE_FORMAT_BRANCH_LIMIT_CHARS);
278
+ const lines = [
279
+ `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)}.`,
280
+ "Conflict paths:",
281
+ ];
282
+ let omitted = details.conflict.omitted;
283
+
284
+ for (const [index, entry] of details.conflict.paths.entries()) {
285
+ const path = safeWorktreeFormatValue(entry.path, WORKTREE_FORMAT_PATH_LIMIT_CHARS);
286
+ const line = `- ${path}`;
287
+ const remaining = details.conflict.paths.length - index - 1;
288
+ const candidateOmitted = details.conflict.omitted + remaining;
289
+ const candidate = [
290
+ ...lines,
291
+ line,
292
+ ...(candidateOmitted > 0 ? [integrationConflictOmissionLine(candidateOmitted)] : []),
293
+ ].join("\n");
294
+ if (candidate.length > GIT_INTEGRATION_SUMMARY_LIMIT_CHARS) {
295
+ omitted += details.conflict.paths.length - index;
296
+ break;
297
+ }
298
+ lines.push(line);
299
+ }
300
+
301
+ if (omitted > 0) lines.push(integrationConflictOmissionLine(omitted));
302
+ return lines.join("\n");
303
+ }
304
+
305
+ export function formatIntegrateBranch(details: IntegrateBranchDetails): string {
306
+ if (details.status === "conflict") return formatIntegrationConflict(details);
307
+
308
+ const source = safeWorktreeFormatValue(details.request.sourceBranch, WORKTREE_FORMAT_BRANCH_LIMIT_CHARS);
309
+ const target = safeWorktreeFormatValue(details.request.targetBranch, WORKTREE_FORMAT_BRANCH_LIMIT_CHARS);
310
+ const beforeTarget = shortCommit(details.verified.heads.before.targetHead);
311
+ const afterTarget = shortCommit(details.verified.heads.after.targetHead);
312
+ if (details.status === "already_integrated") {
313
+ return `integrate_branch verified ${source} was already integrated into ${target}; no merge ran and target HEAD remained ${afterTarget}.`;
314
+ }
315
+ if (details.status === "fast_forward") {
316
+ return `integrate_branch fast-forwarded ${target} from ${beforeTarget} to ${afterTarget}, integrating ${source}.`;
317
+ }
318
+ return `integrate_branch created and verified a merge commit ${afterTarget} on ${target} from previous target ${beforeTarget} and source ${source}.`;
319
+ }
320
+
321
+ export function formatRetireBranch(details: RetireBranchDetails): string {
322
+ const branch = safeWorktreeFormatValue(details.request.branchName, WORKTREE_FORMAT_BRANCH_LIMIT_CHARS);
323
+ const target = safeWorktreeFormatValue(details.request.targetBranch, WORKTREE_FORMAT_BRANCH_LIMIT_CHARS);
324
+ const retiredHead = shortCommit(details.verified.refs.before.retiring.head);
325
+ const targetHead = shortCommit(details.verified.refs.after.target.head);
326
+ const localOnly = "No remote or remote-tracking branch was deleted.";
327
+ const text = details.mode === "merged"
328
+ ? `retire_branch retired merged local branch ${branch} at HEAD ${retiredHead} after verifying ancestry to ${target} at ${targetHead}. ${localOnly}`
329
+ : `retire_branch force-retired unmerged local branch ${branch} at HEAD ${retiredHead} against ${target} at ${targetHead}. The retired commit may lose its remaining local branch reference and can eventually become unreachable. ${localOnly}`;
330
+ return text.slice(0, GIT_RETIREMENT_SUMMARY_LIMIT_CHARS);
331
+ }
332
+
227
333
  const PULL_REQUEST_INPUT_FIELDS: PullRequestInputField[] = ["headBranch", "baseBranch", "title", "body", "draft"];
228
334
 
229
335
  interface ResolvedPullRequestBranches {
@@ -381,16 +487,18 @@ export function registerBranchMeTools(pi: Pick<ExtensionAPI, "registerTool" | "e
381
487
  pi.registerTool({
382
488
  name: BRANCH_STATUS_TOOL_NAME,
383
489
  label: "Branch Status",
384
- description: "branch_status explicitly refreshes the current Git repository snapshot, including branch, working tree, unstaged changes, related pull request, and recent commits. branch_status is read-only and never mutates files, Git state, or GitHub state.",
385
- promptSnippet: "branch_status: explicitly refresh current-repository branch, working tree, changes, related PR, and recent commits without mutation",
490
+ 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.",
491
+ promptSnippet: "branch_status: explicitly refresh current-repository Git state and optionally verify targeted local-branch ancestry without mutation",
386
492
  promptGuidelines: [
387
493
  "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
494
  "Use branch_status as a read-only refresh; branch_status never mutates files, Git state, or GitHub state.",
495
+ "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
496
  ],
390
- parameters: EmptyParametersSchema,
391
- async execute(_toolCallId, _params, signal, _onUpdate, ctx) {
497
+ parameters: BranchStatusParametersSchema,
498
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
392
499
  const details = await collectGitContext(pi, ctx, {
393
500
  signal,
501
+ ancestry: params.ancestry,
394
502
  env: options.env,
395
503
  fetchImpl: options.fetchImpl,
396
504
  });
@@ -514,6 +622,53 @@ export function registerBranchMeTools(pi: Pick<ExtensionAPI, "registerTool" | "e
514
622
  },
515
623
  });
516
624
 
625
+ pi.registerTool({
626
+ name: INTEGRATE_BRANCH_TOOL_NAME,
627
+ label: "Integrate Branch",
628
+ 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.",
629
+ promptSnippet: "integrate_branch: merge an exact local source branch into the checked-out clean local target with verified outcomes and automatic conflict abort",
630
+ promptGuidelines: [
631
+ "Use integrate_branch only when the user explicitly wants one exact existing local source branch integrated into one exact existing local target branch.",
632
+ "Before integrate_branch, require the current clean control worktree to already have targetBranch checked out; integrate_branch never switches branches, stashes, or discards changes.",
633
+ "Use integrate_branch only after local refs already contain the commits to integrate; integrate_branch never fetches or pushes.",
634
+ "Call integrate_branch by itself and wait for it to complete; never batch integrate_branch with other Git mutations.",
635
+ "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.",
636
+ "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.",
637
+ ],
638
+ parameters: IntegrateBranchParametersSchema,
639
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
640
+ const details = await integrateBranch(pi, ctx, params, signal);
641
+ return {
642
+ content: [{ type: "text", text: formatIntegrateBranch(details) }],
643
+ details,
644
+ };
645
+ },
646
+ });
647
+
648
+ pi.registerTool({
649
+ name: RETIRE_BRANCH_TOOL_NAME,
650
+ label: "Retire Branch",
651
+ description: "retire_branch deletes one exact unoccupied local branch only when its current commit matches expectedHead and its ancestry to one exact local targetBranch is verified. retire_branch requires force: true for explicitly authorized unmerged retirement, never directly deletes remote or remote-tracking branches, and leaves local branch configuration untouched.",
652
+ promptSnippet: "retire_branch: delete one exact local branch with an expected-HEAD lease, target ancestry verification, worktree-occupancy rejection, and explicit unmerged force authorization",
653
+ promptGuidelines: [
654
+ "Use retire_branch only when the user explicitly intends to delete one exact local branch.",
655
+ "Before retire_branch, obtain a fresh expected commit and ancestry proof with branch_status unless the exact commit was already supplied.",
656
+ "Call branch_status and retire_branch sequentially; never place branch_status and retire_branch in the same parallel tool batch.",
657
+ "Call retire_branch by itself; never run retire_branch beside another Git mutation.",
658
+ "Use force: true with retire_branch only after explicit user authorization for unmerged data loss.",
659
+ "Treat remove_worktree and retire_branch as separate sequential operations: removing a linked worktree retains its branch, which may be retired only afterward.",
660
+ "retire_branch never directly deletes a remote or remote-tracking branch.",
661
+ ],
662
+ parameters: RetireBranchParametersSchema,
663
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
664
+ const details = await retireBranch(pi, ctx, params, signal);
665
+ return {
666
+ content: [{ type: "text", text: formatRetireBranch(details) }],
667
+ details,
668
+ };
669
+ },
670
+ });
671
+
517
672
  pi.registerTool({
518
673
  name: PUSH_BRANCH_TOOL_NAME,
519
674
  label: "Push Branch",