@senad-d/branchme 0.1.9 → 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
@@ -629,6 +629,30 @@ export function parseWorktreePorcelain(output: string): Pick<ListWorktreesDetail
629
629
  return { worktrees, omitted: records.length - returnedCount };
630
630
  }
631
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
+
632
656
  interface WorktreeInventoryEntry {
633
657
  record: ParsedWorktreeRecord;
634
658
  index: number;
@@ -998,6 +1022,33 @@ export async function listWorktrees(
998
1022
  };
999
1023
  }
1000
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
+
1001
1052
  function validateCreateWorktreeMode(branchMode: unknown): asserts branchMode is CreateWorktreeMode {
1002
1053
  if (branchMode !== "new" && branchMode !== "existing") {
1003
1054
  throw new Error("branchMode must be either 'new' or 'existing'.");
@@ -1836,6 +1887,113 @@ export async function localBranchExists(
1836
1887
  return result.code === 0;
1837
1888
  }
1838
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
+
1839
1997
  export async function getLocalBranchCommit(
1840
1998
  pi: Pick<ExtensionAPI, "exec">,
1841
1999
  ctx: GitCommandContext,
@@ -8,6 +8,7 @@ import {
8
8
  CREATE_WORKTREE_TOOL_NAME,
9
9
  FETCH_BRANCH_TOOL_NAME,
10
10
  GIT_INTEGRATION_SUMMARY_LIMIT_CHARS,
11
+ GIT_RETIREMENT_SUMMARY_LIMIT_CHARS,
11
12
  GIT_WORKTREE_SUMMARY_LIMIT_CHARS,
12
13
  INTEGRATE_BRANCH_TOOL_NAME,
13
14
  LIST_WORKTREES_TOOL_NAME,
@@ -16,6 +17,7 @@ import {
16
17
  PUSH_BRANCH_TOOL_NAME,
17
18
  REBASE_BRANCH_TOOL_NAME,
18
19
  REMOVE_WORKTREE_TOOL_NAME,
20
+ RETIRE_BRANCH_TOOL_NAME,
19
21
  } from "../constants.ts";
20
22
  import {
21
23
  changeExistingLocalBranch,
@@ -38,6 +40,7 @@ import {
38
40
  } from "../git.ts";
39
41
  import { collectGitContext, formatGitContext } from "../git-context.ts";
40
42
  import { integrateBranch } from "../git-integration.ts";
43
+ import { retireBranch } from "../git-retirement.ts";
41
44
  import {
42
45
  createGitHubPullRequest,
43
46
  ensureGitHubBranchExists,
@@ -60,6 +63,7 @@ import type {
60
63
  PullRequestToolDetails,
61
64
  PullRequestToolInput,
62
65
  RemoveWorktreeDetails,
66
+ RetireBranchDetails,
63
67
  WorktreeEntry,
64
68
  } from "../types.ts";
65
69
 
@@ -88,6 +92,19 @@ const IntegrateBranchParametersSchema = Type.Object(
88
92
  { additionalProperties: false },
89
93
  );
90
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
+
91
108
  const CreateBranchParametersSchema = Type.Object(
92
109
  {
93
110
  branchName: Type.String({ minLength: 1, description: "Name of the new branch to create from current HEAD." }),
@@ -301,6 +318,18 @@ export function formatIntegrateBranch(details: IntegrateBranchDetails): string {
301
318
  return `integrate_branch created and verified a merge commit ${afterTarget} on ${target} from previous target ${beforeTarget} and source ${source}.`;
302
319
  }
303
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
+
304
333
  const PULL_REQUEST_INPUT_FIELDS: PullRequestInputField[] = ["headBranch", "baseBranch", "title", "body", "draft"];
305
334
 
306
335
  interface ResolvedPullRequestBranches {
@@ -616,6 +645,30 @@ export function registerBranchMeTools(pi: Pick<ExtensionAPI, "registerTool" | "e
616
645
  },
617
646
  });
618
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
+
619
672
  pi.registerTool({
620
673
  name: PUSH_BRANCH_TOOL_NAME,
621
674
  label: "Push Branch",
package/src/types.ts CHANGED
@@ -34,6 +34,13 @@ export interface IntegrateBranchToolInput {
34
34
  targetBranch: string;
35
35
  }
36
36
 
37
+ export interface RetireBranchToolInput {
38
+ branchName: string;
39
+ expectedHead: string;
40
+ targetBranch: string;
41
+ force: boolean;
42
+ }
43
+
37
44
  export type CreateWorktreeMode = "new" | "existing";
38
45
 
39
46
  export type ListWorktreesToolInput = Record<string, never>;
@@ -354,6 +361,75 @@ export type IntegrateBranchDetails =
354
361
  | IntegrateBranchMergeCommitDetails
355
362
  | IntegrateBranchConflictDetails;
356
363
 
364
+ export type RetireBranchMode = "merged" | "forced_unmerged";
365
+
366
+ export interface RetireBranchRepositoryIdentity {
367
+ worktreeRoot: string;
368
+ canonicalCommonGitDirectory: string;
369
+ }
370
+
371
+ export interface RetireBranchRefIdentity {
372
+ branchName: string;
373
+ fullRef: string;
374
+ head: string;
375
+ }
376
+
377
+ export interface RetireBranchAbsentRefProof {
378
+ branchName: string;
379
+ fullRef: string;
380
+ absent: true;
381
+ }
382
+
383
+ export interface RetireBranchWorktreeOccupancyProof {
384
+ branchName: string;
385
+ completeInventoryInspected: true;
386
+ occupied: false;
387
+ matchingWorktreeCount: 0;
388
+ }
389
+
390
+ export interface RetireBranchDetails {
391
+ action: "retire_branch";
392
+ status: "retired";
393
+ mode: RetireBranchMode;
394
+ /** Exact normalized request values used for the verified retirement. */
395
+ request: RetireBranchToolInput;
396
+ verified: {
397
+ repository: {
398
+ before: RetireBranchRepositoryIdentity;
399
+ after: RetireBranchRepositoryIdentity;
400
+ identityPreserved: true;
401
+ };
402
+ refs: {
403
+ before: {
404
+ retiring: RetireBranchRefIdentity;
405
+ target: RetireBranchRefIdentity;
406
+ expectedHead: string;
407
+ expectedHeadMatches: true;
408
+ };
409
+ after: {
410
+ retiring: RetireBranchAbsentRefProof;
411
+ target: RetireBranchRefIdentity;
412
+ targetHeadPreserved: true;
413
+ };
414
+ };
415
+ ancestry: {
416
+ retiringHead: string;
417
+ targetHead: string;
418
+ retiringIsAncestorOfTarget: boolean;
419
+ };
420
+ worktreeOccupancy: {
421
+ before: RetireBranchWorktreeOccupancyProof;
422
+ after: RetireBranchWorktreeOccupancyProof;
423
+ };
424
+ mutation: {
425
+ exactLocalRefDeletionAttempted: true;
426
+ localBranchAbsentAfterDeletion: true;
427
+ directRemoteDeletionAttempted: false;
428
+ remoteTrackingRefDeletionAttempted: false;
429
+ };
430
+ };
431
+ }
432
+
357
433
  export interface PullRequestDetails {
358
434
  repository: GitHubRepository;
359
435
  number: number;
@@ -154,7 +154,7 @@ function sectionTitle(section: BranchMePanelSection): string {
154
154
  case "workflow":
155
155
  return "WORKFLOW";
156
156
  case "integration":
157
- return "INTEGRATION";
157
+ return "LIFECYCLE";
158
158
  case "worktrees":
159
159
  return "WORKTREES";
160
160
  }
@@ -183,7 +183,7 @@ function sectionFooter(section: BranchMePanelSection, data: BranchMePanelData):
183
183
  case "workflow":
184
184
  return "workflow • inspect → change → fetch/pull/rebase → create → push → PR";
185
185
  case "integration":
186
- return "integration • local refs • clean target • conflicts auto-abort";
186
+ return "lifecycle • integrate → remove worktree separately → retire local ref";
187
187
  case "worktrees":
188
188
  return "worktrees • create → handoff cwd → next session • remove retains branch";
189
189
  }
@@ -214,12 +214,15 @@ function sectionRows(section: BranchMePanelSection, data: BranchMePanelData): Pa
214
214
  ];
215
215
  case "integration":
216
216
  return [
217
- heading("INTEGRATION"),
217
+ heading("LIFECYCLE"),
218
218
  workflowDetailRow("integrate_branch", "exact local source -> target"),
219
219
  workflowDetailRow("control target", "checked out + clean"),
220
- workflowDetailRow("remote effects", "never fetch or push"),
221
220
  workflowDetailRow("conflict", "automatic verified abort"),
222
221
  workflowDetailRow("semantic intent", "separate developer workflow"),
222
+ workflowDetailRow("remove_worktree", "separate; branch retained"),
223
+ workflowDetailRow("retire_branch", "leased local ref deletion"),
224
+ workflowDetailRow("retirement guard", "unoccupied + target ancestry"),
225
+ workflowDetailRow("remote effects", "never delete remote refs"),
223
226
  ];
224
227
  case "worktrees":
225
228
  return [