@senad-d/branchme 0.3.4 → 0.3.5

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 CHANGED
@@ -1,6 +1,11 @@
1
1
  # Changelog
2
2
 
3
- ## 0.3.4 - Unreleased
3
+ ## 0.3.5 - Unreleased
4
+
5
+ - `push_branch` never pushes onto a differently named integration branch: when the current branch's upstream is `main`, `master`, `trunk`, `develop`, or the origin default branch under another name (a feature branch created from `origin/main` with tracking), it publishes to the same-named branch on that remote with `--set-upstream`, reports `mode: "publish"`, and keeps the previous upstream in `upstream`. Previously such a branch was pushed straight onto the integration branch. Other differently named upstreams keep their explicit `HEAD:<upstream-ref>` push.
6
+ - Added optional `remove_worktree.discardChanges` (default `false`, unchanged behavior). When `true`, the user's explicit authorization, a worktree with staged, unstaged, untracked, or unmerged changes is removed with `git worktree remove --force` and the result lists every discarded path in `discardedPaths`; without it a dirty worktree is still refused, now naming the option. Locked, detached, main, current, prunable, missing, and foreign worktrees stay rejected, and the local branch is retained.
7
+
8
+ ## 0.3.4 - Released
4
9
 
5
10
  - Added `fetch_remote` for tool-native discovery of unknown remote branch names: refresh one configured remote's branch cache (default `origin`) with optional, remote-tracking-only pruning (default false). Atomic explicit mappings disable configured refmaps, tags, tag pruning, submodules, and maintenance; symbolic destination checks include dangling loose refs, and unsupported non-files ref backends fail closed.
6
11
  - Extended read-only `list_branches` with optional local/remote-tracking `kind` and bounded Git branch-list glob `patterns`, applied before output limits. Documented the sequential replacement for issue branch discovery via `git fetch origin --prune` and `git branch -r --list`.
package/README.md CHANGED
@@ -262,7 +262,7 @@ Commands are informational only. BranchMe actions are performed by agent-callabl
262
262
  | `branch_status` | `{ "ancestry"?: { "sourceBranch": string, "targetBranch": string } }` | Explicitly refreshes the same bounded context used at agent start. An optional strict ancestry query captures both exact HEADs and reports whether the source commit is an ancestor of the target commit; each endpoint may be an exact local branch or a remote-tracking ref such as `origin/main` (a local branch of the same name takes precedence). It is read-only and never checks out or resets remote-tracking refs; automatic Git context does not run ancestry queries. |
263
263
  | `list_worktrees` | `{}` | Runs a bounded, read-only inventory of the current repository's main and linked worktrees, including path, branch/detached state, `HEAD`, current/main, locked, prunable, and omitted-entry details. Automatic Git context does not include this inventory. |
264
264
  | `create_worktree` | `{ "worktreePath": string, "branchName": string, "branchMode": "new" \| "existing", "baseRef"?: string }` | Creates and verifies a linked worktree at an explicitly approved absolute path. `new` creates a local branch from current `HEAD`, or from the optional read-only `baseRef` (an exact local branch, remote-tracking ref such as `origin/main`, or full commit) regardless of the current checkout's branch, dirt, or staleness; `existing` requires an existing local branch not checked out elsewhere and rejects `baseRef`. It returns a ready handoff with the exact canonical absolute cwd and local branch identity. |
265
- | `remove_worktree` | `{ "worktreePath": string, "deleteIgnored"?: boolean }` | Force-free removal of an explicitly selected, verified clean linked worktree. Ignored residue is refused by default; `deleteIgnored: true` explicitly deletes it and reports its top-level paths. The local branch remains at the same commit. |
265
+ | `remove_worktree` | `{ "worktreePath": string, "deleteIgnored"?: boolean, "discardChanges"?: boolean }` | Force-free removal of an explicitly selected, verified clean linked worktree. Ignored residue is refused by default; `deleteIgnored: true` explicitly deletes it and reports its top-level paths. The local branch remains at the same commit. `discardChanges: true` explicitly discards uncommitted changes and lists them in `discardedPaths`; a dirty worktree is refused without it. |
266
266
  | `retire_branch` | `{ "branchName": string, "expectedHead": string, "targetBranch": string, "force": boolean }` | Deletes only the exact unoccupied local branch ref when its direct ref matches the supplied full commit ID and its relationship to the exact local target has been verified. Unmerged retirement requires explicit `force: true`; remote and remote-tracking refs are untouched. |
267
267
  | `land_branch` | `{ "sourceBranch": string, "targetBranch": string, "remote"?: string, "worktreePath"?: string, "pullRequestNumber"?: integer }` | Post-merge fetch, ancestry or exact merged-PR proof, linked-worktree removal including ignored residue, leased local source deletion, and independent fast-forward target sync. Default remote `origin`; omitted path finds the source's linked worktree. Returns per-step receipts. |
268
268
  | `change_branch` | `{ "branchName": string }` | Validates `branchName`, requires `refs/heads/<branchName>` to exist locally, rejects dirty worktrees, and runs `git switch <branchName>`. |
@@ -271,7 +271,7 @@ Commands are informational only. BranchMe actions are performed by agent-callabl
271
271
  | `rebase_branch` | `{}` | Requires a clean current branch with a configured upstream and runs `git rebase --no-autostash --no-update-refs <upstream>`; it rewrites local commits and automatically attempts `git rebase --abort` on failure. |
272
272
  | `integrate_branch` | `{ "sourceBranch": string, "targetBranch": string }` | Integrates one exact existing local source branch into one distinct existing local target branch. The clean active control worktree must already have the target checked out. It returns `already_integrated`, `fast_forward`, `merge_commit`, or a `conflict` only after automatic abort and verified restoration. It never fetches or pushes. |
273
273
  | `create_branch` | `{ "branchName": string }` | Validates `branchName`, rejects existing local branches, and runs `git switch -c <branchName>` from current `HEAD`. |
274
- | `push_branch` | `{}` | Pushes the current branch to its configured upstream remote with an explicit `HEAD:<upstream-branch-ref>` refspec, or publishes it with `git push --set-upstream origin <currentBranch>` when no upstream exists. |
274
+ | `push_branch` | `{}` | Pushes the current branch to its configured upstream remote with an explicit `HEAD:<upstream-branch-ref>` refspec, or publishes it with `git push --set-upstream origin <currentBranch>` when no upstream exists. A branch whose upstream is a differently named `main`, `master`, `trunk`, `develop`, or origin default branch is published to its own name instead and tracks it. |
275
275
  | `pull_request` | `{ "headBranch"?: string, "baseBranch"?: string, "title"?: string, "body"?: string, "draft"?: boolean }` | Preflights GitHub branch visibility and verifies the GitHub `headBranch` commit matches the local branch, then reuses an exact matching open PR or creates one in the resolved current repository. Existing PR metadata is preserved. Omitted fields require `BRANCHME_PR_AUTOFILL=true`; branch refs must be distinct, exist locally, and cannot use `owner:branch`. |
276
276
 
277
277
  `conclude_merge` supports only single-head merges and refuses multi-head merges before mutation. Its marker check also covers manually staged resolutions and clean-filter output. Marker-like lines (including Markdown setext underlines) are conservatively refused; configured marker sizes above 4,096 are refused. Git commits the **entire index**, including already-staged merge entries: do not stage unrelated edits during the merge. Unrelated unstaged edits remain outside the commit. A failed/lost commit response reports uncertain postconditions; inspect `HEAD`, `MERGE_HEAD`, and the index before retrying.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@senad-d/branchme",
3
- "version": "0.3.4",
3
+ "version": "0.3.5",
4
4
  "type": "module",
5
5
  "description": "Pi extension for verified Git repository initialization, branch, worktree, integration, retirement, push, and pull request workflows.",
6
6
  "license": "MIT",
package/src/git.ts CHANGED
@@ -534,6 +534,26 @@ async function resolvePushTarget(
534
534
  };
535
535
  }
536
536
 
537
+ const sameNameRef = `refs/heads/${currentBranch}`;
538
+ if (
539
+ upstreamTarget.remoteRef !== sameNameRef &&
540
+ await isIntegrationBranch(pi, ctx, upstreamTarget.remote, upstreamTarget.remoteRef, signal)
541
+ ) {
542
+ // A feature branch tracking the default or an integration branch (created
543
+ // from origin/main with tracking) would push straight onto it. Publish to
544
+ // the same-named branch instead and track that; the result reports the
545
+ // previous upstream.
546
+ const refspec = `HEAD:${sameNameRef}`;
547
+ return {
548
+ upstream: upstreamTarget.upstream,
549
+ mode: "publish",
550
+ remote: upstreamTarget.remote,
551
+ remoteRef: sameNameRef,
552
+ refspec,
553
+ args: ["push", "--set-upstream", upstreamTarget.remote, refspec],
554
+ };
555
+ }
556
+
537
557
  const refspec = `HEAD:${upstreamTarget.remoteRef}`;
538
558
  return {
539
559
  ...upstreamTarget,
@@ -543,6 +563,20 @@ async function resolvePushTarget(
543
563
  };
544
564
  }
545
565
 
566
+ const INTEGRATION_BRANCH_NAMES = ["main", "master", "trunk", "develop"];
567
+
568
+ async function isIntegrationBranch(
569
+ pi: Pick<ExtensionAPI, "exec">,
570
+ ctx: GitCommandContext,
571
+ remote: string,
572
+ remoteRef: string,
573
+ signal?: AbortSignal,
574
+ ): Promise<boolean> {
575
+ const branch = remoteRef.slice("refs/heads/".length);
576
+ if (INTEGRATION_BRANCH_NAMES.includes(branch)) return true;
577
+ return remote === "origin" && branch === await getOriginDefaultBranch(pi, ctx, signal);
578
+ }
579
+
546
580
  export async function hasWorkingTreeChanges(
547
581
  pi: Pick<ExtensionAPI, "exec">,
548
582
  ctx: GitCommandContext,
@@ -1762,6 +1796,7 @@ async function requirePresentWorktreeDirectory(canonicalPath: string): Promise<v
1762
1796
  interface WorktreeRemovalStatus {
1763
1797
  workingTree: WorkingTreeDetails;
1764
1798
  ignoredPaths: string[];
1799
+ changedPaths: string[];
1765
1800
  }
1766
1801
 
1767
1802
  function compareIgnoredPaths(left: string, right: string): number {
@@ -1775,6 +1810,7 @@ function parseWorktreeRemovalStatus(output: string): WorktreeRemovalStatus {
1775
1810
 
1776
1811
  const records = output.split(NUL_SEPARATOR);
1777
1812
  const ignoredPaths = new Set<string>();
1813
+ const changedPaths: string[] = [];
1778
1814
  let skipNextRecord = false;
1779
1815
  for (const [index, record] of records.entries()) {
1780
1816
  if (skipNextRecord) {
@@ -1786,11 +1822,13 @@ function parseWorktreeRemovalStatus(output: string): WorktreeRemovalStatus {
1786
1822
  const parsed = parsePorcelainChange(records, index);
1787
1823
  skipNextRecord = parsed.nextIndex > index;
1788
1824
  if (parsed.status === "!!") ignoredPaths.add(parsed.path.split("/")[0]);
1825
+ else changedPaths.push(parsed.path);
1789
1826
  }
1790
1827
 
1791
1828
  return {
1792
1829
  workingTree: parseWorkingTreeStatus(output).workingTree,
1793
1830
  ignoredPaths: [...ignoredPaths].sort(compareIgnoredPaths),
1831
+ changedPaths: changedPaths.sort(compareIgnoredPaths),
1794
1832
  };
1795
1833
  }
1796
1834
 
@@ -1799,10 +1837,13 @@ async function inspectRemovableWorktree(
1799
1837
  canonicalPath: string,
1800
1838
  signal?: AbortSignal,
1801
1839
  allowIgnored = false,
1840
+ allowDirty = false,
1802
1841
  ): Promise<WorktreeRemovalStatus> {
1803
1842
  const workingTree = await inspectWorktreeState(pi, canonicalPath, signal);
1804
- if (workingTree.state !== "clean") {
1805
- throw new Error("The target worktree has staged, unstaged, untracked, or unmerged changes; clean it before removal.");
1843
+ if (workingTree.state !== "clean" && !allowDirty) {
1844
+ throw new Error(
1845
+ "The target worktree has staged, unstaged, untracked, or unmerged changes; clean it before removal, or pass discardChanges: true when the user explicitly authorized discarding them.",
1846
+ );
1806
1847
  }
1807
1848
 
1808
1849
  const ignoredResult = await runGit(pi, { cwd: canonicalPath }, GIT_WORKTREE_REMOVAL_IGNORED_STATUS_ARGS, {
@@ -1810,7 +1851,7 @@ async function inspectRemovableWorktree(
1810
1851
  timeout: GIT_STATUS_TIMEOUT_MS,
1811
1852
  });
1812
1853
  const removalStatus = parseWorktreeRemovalStatus(ignoredResult.stdout);
1813
- if (removalStatus.workingTree.state !== "clean") {
1854
+ if (removalStatus.workingTree.state !== "clean" && !allowDirty) {
1814
1855
  throw new Error("The target worktree has staged, unstaged, untracked, or unmerged changes; clean it before removal.");
1815
1856
  }
1816
1857
  if (!allowIgnored && removalStatus.ignoredPaths.length > 0) {
@@ -1850,6 +1891,7 @@ export async function removeWorktree(
1850
1891
  worktreePath: unknown,
1851
1892
  signal?: AbortSignal,
1852
1893
  deleteIgnored = false,
1894
+ discardChanges = false,
1853
1895
  ): Promise<RemoveWorktreeDetails> {
1854
1896
  const requestedWorktreePath = validateWorktreePathInput(worktreePath);
1855
1897
  const repoRoot = await getGitRoot(pi, ctx, signal);
@@ -1857,11 +1899,21 @@ export async function removeWorktree(
1857
1899
 
1858
1900
  return withRepositoryMutationQueue(
1859
1901
  repoRoot,
1860
- removeWorktreeWithinQueue.bind(undefined, pi, rootCtx, requestedWorktreePath, signal, deleteIgnored),
1902
+ removeWorktreeWithinQueue.bind(
1903
+ undefined,
1904
+ pi,
1905
+ rootCtx,
1906
+ requestedWorktreePath,
1907
+ signal,
1908
+ deleteIgnored,
1909
+ undefined,
1910
+ discardChanges,
1911
+ ),
1861
1912
  );
1862
1913
  }
1863
1914
 
1864
- // Caller must hold the repository mutation queue. Ignored residue requires explicit authorization.
1915
+ // Caller must hold the repository mutation queue. Ignored residue and discarded
1916
+ // changes each require explicit authorization.
1865
1917
  export async function removeWorktreeWithinQueue(
1866
1918
  pi: Pick<ExtensionAPI, "exec">,
1867
1919
  rootCtx: GitCommandContext,
@@ -1869,6 +1921,7 @@ export async function removeWorktreeWithinQueue(
1869
1921
  signal?: AbortSignal,
1870
1922
  allowIgnored = false,
1871
1923
  expectedIdentity?: { branch: string; head: string },
1924
+ allowDirty = false,
1872
1925
  ): Promise<RemoveWorktreeDetails & { deletedIgnoredPaths: string[] }> {
1873
1926
  const repoRoot = rootCtx.cwd;
1874
1927
  const resolved = await resolveWorktreeRemovalTarget(
@@ -1893,13 +1946,23 @@ export async function removeWorktreeWithinQueue(
1893
1946
  const verifiedCanonicalPath = requireLosslessWorktreeIdentity(resolved.canonicalPath, "cwd");
1894
1947
  const retainedBranch = requireLosslessWorktreeIdentity(branchName, "branch");
1895
1948
  await requirePresentWorktreeDirectory(verifiedCanonicalPath);
1896
- const { workingTree, ignoredPaths } = await inspectRemovableWorktree(pi, verifiedCanonicalPath, signal, allowIgnored);
1949
+ const { workingTree, ignoredPaths, changedPaths } = await inspectRemovableWorktree(
1950
+ pi,
1951
+ verifiedCanonicalPath,
1952
+ signal,
1953
+ allowIgnored,
1954
+ allowDirty,
1955
+ );
1897
1956
  const branchHeadBefore = await getLocalBranchCommit(pi, rootCtx, retainedBranch, signal);
1898
1957
  if (branchHeadBefore.toLowerCase() !== head.toLowerCase()) {
1899
1958
  throw new Error("The target local branch did not match the worktree HEAD before removal.");
1900
1959
  }
1901
1960
 
1902
- const args = ["worktree", "remove", verifiedCanonicalPath];
1961
+ // --force only when the user authorized discarding changes; a locked worktree
1962
+ // is rejected before this and would need a second --force anyway.
1963
+ const args = changedPaths.length > 0
1964
+ ? ["worktree", "remove", "--force", verifiedCanonicalPath]
1965
+ : ["worktree", "remove", verifiedCanonicalPath];
1903
1966
  try {
1904
1967
  await runGit(pi, rootCtx, args, {
1905
1968
  signal,
@@ -1927,10 +1990,12 @@ export async function removeWorktreeWithinQueue(
1927
1990
  return {
1928
1991
  action: "remove_worktree",
1929
1992
  deletedIgnoredPaths: ignoredPaths.map((path) => redactSecrets(path)),
1993
+ discardedPaths: changedPaths.map((path) => redactSecrets(path)),
1930
1994
  repoRoot: safeWorktreeValue(repoRoot, GIT_WORKTREE_PATH_LIMIT_CHARS),
1931
1995
  request: {
1932
1996
  worktreePath: safeWorktreeValue(requestedWorktreePath, GIT_WORKTREE_PATH_LIMIT_CHARS),
1933
1997
  ...(allowIgnored ? { deleteIgnored: true } : {}),
1998
+ ...(allowDirty ? { discardChanges: true } : {}),
1934
1999
  },
1935
2000
  verified: {
1936
2001
  before: {
@@ -209,6 +209,9 @@ const RemoveWorktreeParametersSchema = Type.Object(
209
209
  deleteIgnored: Type.Optional(Type.Boolean({
210
210
  description: "Explicit authorization to delete ignored files and directories with the worktree; defaults to false.",
211
211
  })),
212
+ discardChanges: Type.Optional(Type.Boolean({
213
+ description: "Explicit authorization to discard staged, unstaged, untracked and unmerged changes with the worktree; defaults to false. The result lists every discarded path.",
214
+ })),
212
215
  },
213
216
  { additionalProperties: false },
214
217
  );
@@ -832,7 +835,7 @@ export function registerBranchMeTools(pi: Pick<ExtensionAPI, "registerTool" | "e
832
835
  pi.registerTool({
833
836
  name: PUSH_BRANCH_TOOL_NAME,
834
837
  label: "Push Branch",
835
- description: "push_branch pushes the current branch to its configured upstream remote with an explicit refspec. If the current branch has no upstream, push_branch publishes it to origin with --set-upstream. push_branch never commits, stages, or edits files.",
838
+ description: "push_branch pushes the current branch to its configured upstream remote with an explicit refspec. If the current branch has no upstream, push_branch publishes it to origin with --set-upstream. A branch whose upstream is a differently named main, master, trunk, develop or origin default branch is never pushed onto it: push_branch publishes it to its own name on that remote, tracks it, and reports the previous upstream. push_branch never commits, stages, or edits files.",
836
839
  promptSnippet: "push_branch: push or publish the current branch only with an explicit target, without committing or staging",
837
840
  promptGuidelines: [
838
841
  "Use push_branch only after commits already exist; push_branch never commits, stages, or edits files.",
@@ -957,17 +960,25 @@ export function registerBranchMeTools(pi: Pick<ExtensionAPI, "registerTool" | "e
957
960
  pi.registerTool({
958
961
  name: REMOVE_WORKTREE_TOOL_NAME,
959
962
  label: "Remove Worktree",
960
- description: "remove_worktree force-free removes one verified clean linked worktree at an explicit absolute worktreePath while retaining and returning its exact local branch identity. Ignored residue is refused by default; deleteIgnored: true explicitly authorizes deleting it with the worktree and reports its top-level paths. remove_worktree rejects main, current, dirty, detached, locked, prunable, missing, and foreign worktrees and never deletes branches.",
963
+ description: "remove_worktree force-free removes one verified clean linked worktree at an explicit absolute worktreePath while retaining and returning its exact local branch identity. Ignored residue is refused by default; deleteIgnored: true explicitly authorizes deleting it with the worktree and reports its top-level paths. discardChanges: true explicitly authorizes discarding uncommitted changes and reports every discarded path. remove_worktree rejects main, current, detached, locked, prunable, missing, and foreign worktrees, rejects dirty ones without discardChanges, and never deletes branches.",
961
964
  promptSnippet: "remove_worktree: force-free removal of an explicitly selected clean linked worktree, with optional explicit ignored-residue deletion, while retaining its branch",
962
965
  promptGuidelines: [
963
966
  "Use remove_worktree only when the user explicitly requests worktree removal and provides or approves the exact absolute worktreePath; remove_worktree must never infer a filesystem path silently.",
964
967
  "Use remove_worktree with worktreePath and optional deleteIgnored; set deleteIgnored: true only after the user explicitly authorizes deleting all ignored files and directories, including possible .env, .pi/, dependency, and build residue.",
968
+ "Set remove_worktree discardChanges: true only after the user explicitly authorizes discarding the worktree's uncommitted changes; never infer it from a dirty worktree.",
965
969
  "remove_worktree never accepts force, move, prune, repair, lock, unlock, branch deletion, remote, or refspec parameters.",
966
970
  "Do not batch remove_worktree with dependent worktree mutations; wait for remove_worktree to complete and verify its non-ready handoff before continuing.",
967
971
  ],
968
972
  parameters: RemoveWorktreeParametersSchema,
969
973
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
970
- const details = await removeWorktree(pi, ctx, params.worktreePath, signal, params.deleteIgnored === true);
974
+ const details = await removeWorktree(
975
+ pi,
976
+ ctx,
977
+ params.worktreePath,
978
+ signal,
979
+ params.deleteIgnored === true,
980
+ params.discardChanges === true,
981
+ );
971
982
  return {
972
983
  content: [{ type: "text", text: formatRemoveWorktree(details) }],
973
984
  details,
package/src/types.ts CHANGED
@@ -111,6 +111,8 @@ export interface RemoveWorktreeToolInput {
111
111
  worktreePath: string;
112
112
  /** Explicitly authorizes deleting ignored files and directories with the worktree. */
113
113
  deleteIgnored?: boolean;
114
+ /** Explicitly authorizes discarding staged, unstaged, untracked and unmerged changes with the worktree. */
115
+ discardChanges?: boolean;
114
116
  }
115
117
 
116
118
  export interface WorktreeEntry {
@@ -194,6 +196,8 @@ export interface CreateWorktreeDetails {
194
196
  export interface RemoveWorktreeDetails {
195
197
  action: "remove_worktree";
196
198
  deletedIgnoredPaths: string[];
199
+ /** Changed paths discarded under discardChanges; empty for a clean worktree. */
200
+ discardedPaths: string[];
197
201
  repoRoot: string;
198
202
  request: RemoveWorktreeToolInput;
199
203
  verified: {