@senad-d/branchme 0.3.3 → 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 +10 -2
- package/README.md +19 -8
- package/SECURITY.md +4 -2
- package/docs/PROJECT_DEFINITION_BRIEF.md +7 -5
- package/docs/SMOKE_TEST.md +2 -2
- package/docs/STRUCTURE.md +6 -2
- package/docs/TUI_CAPTURE.md +4 -1
- package/package.json +1 -1
- package/src/commands/branchme-command.ts +4 -1
- package/src/constants.ts +3 -0
- package/src/git-discovery.ts +231 -0
- package/src/git.ts +141 -12
- package/src/tools/branchme-tools.ts +66 -7
- package/src/types.ts +23 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.3.
|
|
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
|
|
9
|
+
|
|
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.
|
|
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`.
|
|
4
12
|
|
|
5
13
|
- `update_from_base` now lists every conflicting path in its text result (bounded, with an omitted count) and states whether the merge was aborted or kept.
|
|
6
14
|
- Added optional `update_from_base.keepConflicts` (default `false`, unchanged behavior). When `true` and the merge conflicts, the merge is verified and left in progress (status `conflict_kept`, MERGE_HEAD equal to the captured base commit, HEAD unchanged, conflicted paths unmerged) instead of being aborted; if the conflict paths cannot be captured, the verified abort runs and the error says `keepConflicts` could not be honored.
|
|
@@ -25,7 +33,7 @@
|
|
|
25
33
|
- Added optional `remote`/`branch` parameters to `fetch_branch` for a targeted fetch of one exact remote branch into its remote-tracking ref (default remote `origin`; `remote` requires `branch`) without touching local branches, the working tree, or the current branch's upstream configuration; the no-argument behavior is unchanged.
|
|
26
34
|
- Added an optional read-only `baseRef` parameter to `create_worktree` for `branchMode: "new"`, starting the new branch from an exact local branch, remote-tracking ref, or full commit regardless of the current checkout's branch, dirt, or staleness; the default from-`HEAD` behavior is unchanged.
|
|
27
35
|
- Implemented the `branchme` informational slash command with help aliases.
|
|
28
|
-
- Registered twenty strict BranchMe tools, including `list_branches`, `track_branch`, `update_from_base`, `conclude_merge`, and `pull_request_status`, alongside: `init_repository`, `branch_status`, `change_branch`, `fetch_branch`, `pull_branch`, `rebase_branch`, `integrate_branch`, `retire_branch`, `land_branch`, `create_branch`, `push_branch`, `pull_request`, `list_worktrees`, `create_worktree`, and `remove_worktree`; generic `continue_merge`/`abort_merge` tools are intentionally absent.
|
|
36
|
+
- Registered twenty-one strict BranchMe tools, including `fetch_remote`, `list_branches`, `track_branch`, `update_from_base`, `conclude_merge`, and `pull_request_status`, alongside: `init_repository`, `branch_status`, `change_branch`, `fetch_branch`, `pull_branch`, `rebase_branch`, `integrate_branch`, `retire_branch`, `land_branch`, `create_branch`, `push_branch`, `pull_request`, `list_worktrees`, `create_worktree`, and `remove_worktree`; generic `continue_merge`/`abort_merge` tools are intentionally absent.
|
|
29
37
|
- Added argv-style git helpers for repository status, branch validation/creation/switching, clean-worktree preflight, upstream detection, configured-upstream fetch, fast-forward-only current-branch pull, current-branch rebase with automatic abort on failure, verified local branch integration, current-branch push/publish, and bounded NUL-delimited worktree discovery.
|
|
30
38
|
- Added `integrate_branch` for exact local source-to-target integration from a clean already-current target control worktree. It rejects branch-specific target merge options that could alter the fixed policy and supports already-integrated, fast-forward, verified normal merge-commit, and automatically aborted/restored conflict outcomes with before/after ref and ancestry proofs, while exposing no fetch, push, reset, merge-message, or continuation controls.
|
|
31
39
|
- Added `retire_branch` for explicit deletion of one exact unoccupied local branch ref after full expected-`HEAD` matching and captured target-ancestry verification. It uses `git update-ref --no-deref -d` with an expected-old-value lease, requires explicit force authorization for unmerged history, leaves branch configuration and remote/remote-tracking refs untouched, and reports uncertain postconditions for manual inspection without reset rollback.
|
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
---
|
|
18
18
|
|
|
19
|
-
BranchMe is a Pi extension for safe Git repository, branch, and worktree workflow automation. Before each agent run, it appends a bounded, read-only snapshot of the current Git repository to the system prompt. It also adds an informational `/branchme` command and twenty agent-callable tools that initialize a repository, refresh state, manage, integrate, and retire local branches, conclude a kept merge, inspect/create/remove linked worktrees, push the current branch, and create GitHub pull requests.
|
|
19
|
+
BranchMe is a Pi extension for safe Git repository, branch, and worktree workflow automation. Before each agent run, it appends a bounded, read-only snapshot of the current Git repository to the system prompt. It also adds an informational `/branchme` command and twenty-one agent-callable tools that initialize a repository, refresh state, manage, integrate, and retire local branches, conclude a kept merge, inspect/create/remove linked worktrees, push the current branch, and create GitHub pull requests.
|
|
20
20
|
|
|
21
21
|
<table align="center">
|
|
22
22
|
<tr>
|
|
@@ -81,7 +81,16 @@ Refresh the repository state with branch_status, then create a branch named feat
|
|
|
81
81
|
|
|
82
82
|
When pi starts in a directory that is not already inside a Git repository, explicitly ask it to call `init_repository`. The tool defaults to an unborn `main` branch, or accepts an optional `initialBranch`; it creates no commit, README, `.gitignore`, remote, or user configuration.
|
|
83
83
|
|
|
84
|
-
Use `list_branches` to discover local and cached remote-tracking branches, upstream ahead/behind counts, and worktree occupancy. To join an existing remote branch in the active checkout, use `track_branch` rather than creating from the wrong `HEAD`.
|
|
84
|
+
Use `list_branches` to discover local and cached remote-tracking branches, upstream ahead/behind counts, and worktree occupancy. Optional `kind` and `patterns` filter before the 200-entry limit. For fresh discovery of unknown branch names, first run `fetch_remote`; `fetch_branch` remains the narrow refresh for a known branch. To join an existing remote branch in the active checkout, use `track_branch` rather than creating from the wrong `HEAD`.
|
|
85
|
+
|
|
86
|
+
For example, replace `git fetch origin --prune` followed by `git branch -r --list 'origin/feat/23' 'origin/feat/23-*'` with these **sequential** calls:
|
|
87
|
+
|
|
88
|
+
```text
|
|
89
|
+
fetch_remote({ remote: "origin", prune: true })
|
|
90
|
+
list_branches({ kind: "remote-tracking", patterns: ["origin/feat/23", "origin/feat/23-*"] })
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Wait for the fetch to succeed before listing. `fetch_remote` defaults to `origin` and **does not prune** unless `prune: true` is requested. Pruning removes only stale cached branch refs for that remote, never local branches, tags, other remotes, or branches on the server. It fetches remote heads using an internal refspec, ignoring configured fetch mappings and tag-pruning settings. Dirty and detached checkouts are supported without changing files, HEAD, or upstream configuration. Rewritten remote tips are refreshed too. Conventional same-remote `HEAD` aliases are preserved; the remote branch literally named `HEAD` is excluded to prevent alias writes. Unsafe symbolic destinations (including dangling aliases), overlapping configured remote namespaces (such as `origin` and `origin/team`), and non-files ref backends such as reftable are refused before fetching.
|
|
85
94
|
|
|
86
95
|
A typical BranchMe flow is:
|
|
87
96
|
|
|
@@ -193,7 +202,7 @@ $EDITOR .env
|
|
|
193
202
|
pi
|
|
194
203
|
```
|
|
195
204
|
|
|
196
|
-
`fetch_branch`, `pull_branch`, and `push_branch` use your normal Git remote credentials. BranchMe does not inject `GITHUB_TOKEN` into `git fetch`, `git pull`, or `git push`. `rebase_branch` operates on the locally available configured upstream ref and makes no network request itself.
|
|
205
|
+
`fetch_remote`, `fetch_branch`, `pull_branch`, and `push_branch` use your normal Git remote credentials. BranchMe does not inject `GITHUB_TOKEN` into `git fetch`, `git pull`, or `git push`. `rebase_branch` operates on the locally available configured upstream ref and makes no network request itself.
|
|
197
206
|
When the current branch already has an upstream, BranchMe pushes an explicit `HEAD:<upstream-branch-ref>` refspec to the configured upstream remote instead of relying on a bare `git push`.
|
|
198
207
|
Run `pull_request` only after `push_branch` has completed; `pull_request` preflights the GitHub `headBranch` and `baseBranch` before creating the PR and fails with retry guidance if a branch is not visible yet or the GitHub `headBranch` commit does not match the local branch.
|
|
199
208
|
|
|
@@ -243,7 +252,8 @@ Commands are informational only. BranchMe actions are performed by agent-callabl
|
|
|
243
252
|
|
|
244
253
|
| Tool | Schema | Behavior |
|
|
245
254
|
| --- | --- | --- |
|
|
246
|
-
| `list_branches` | `{}` | Read up to 200 local
|
|
255
|
+
| `list_branches` | `{ "kind"?: "local" \| "remote-tracking", "patterns"?: string[] }` | Read up to 200 matching local/cached remote-tracking refs with commits, upstream counts, symbolic refs, and occupancy. Patterns use Git branch-list globs on names (`origin/topic` for remote refs), match any supplied pattern, and filter before limits. Omit filters for the original full inventory. Accepts 1–25 nonblank patterns of at most 512 characters. No fetch. Raw refs are limited to 128 KiB and text to 4,000 characters; omitted entries are reported. Names/paths are display metadata, not guaranteed executable identities. |
|
|
256
|
+
| `fetch_remote` | `{ "remote"?: string, "prune"?: boolean }` | Refresh all heads for one configured remote (default `origin`) into its remote-tracking namespace. Prune defaults to false; true deletes only that remote's stale cached branch refs. Atomic ref updates, no configured refmap, tags, tag pruning, or submodules. Never changes local refs, checkout, upstream settings, or working-tree files. Files ref backend required for dangling-alias safety. |
|
|
247
257
|
| `track_branch` | `{ "branchName": string, "remote"?: string, "remoteBranch"?: string }` | Fetch one exact remote branch and create/check out a new local tracking branch. Remote defaults to `origin`; remoteBranch defaults to branchName. Requires a clean idle checkout; verifies HEAD and upstream. |
|
|
248
258
|
| `update_from_base` | `{ "baseBranch": string, "remote"?: string, "keepConflicts"?: boolean }` | Fetch the exact remote base (remote defaults to `origin`) and merge its captured commit into the clean current feature. Uses verified normal-merge policy; a `conflict` is automatically aborted with its paths listed, or with `keepConflicts: true` reported as `conflict_kept` and left in progress (MERGE_HEAD set, conflicted paths unmerged) for `conclude_merge`. Never rebases, pushes, or changes upstream configuration. |
|
|
249
259
|
| `conclude_merge` | `{ "action": "conclude" \| "abort" }` | Finish or abandon the in-progress merge on the current checkout. `conclude` refuses default/custom-sized `<<<<<<<`, `|||||||`, `=======`, or `>>>>>>>` marker lines in unmerged working-tree paths and staged blobs (including CRLF and binary content, naming the paths), stages only the currently unmerged paths, rechecks the candidate index, commits with `git commit --no-edit`, and verifies MERGE_HEAD is gone and HEAD is a two-parent merge of the previous HEAD and MERGE_HEAD; unrelated unstaged changes stay unstaged. `abort` runs `git merge --abort` and verifies the restored HEAD, cleared operation state, and clean tree. Refuses when no merge is in progress. |
|
|
@@ -252,7 +262,7 @@ Commands are informational only. BranchMe actions are performed by agent-callabl
|
|
|
252
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. |
|
|
253
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. |
|
|
254
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. |
|
|
255
|
-
| `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. |
|
|
256
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. |
|
|
257
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. |
|
|
258
268
|
| `change_branch` | `{ "branchName": string }` | Validates `branchName`, requires `refs/heads/<branchName>` to exist locally, rejects dirty worktrees, and runs `git switch <branchName>`. |
|
|
@@ -261,12 +271,12 @@ Commands are informational only. BranchMe actions are performed by agent-callabl
|
|
|
261
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. |
|
|
262
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. |
|
|
263
273
|
| `create_branch` | `{ "branchName": string }` | Validates `branchName`, rejects existing local branches, and runs `git switch -c <branchName>` from current `HEAD`. |
|
|
264
|
-
| `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. |
|
|
265
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`. |
|
|
266
276
|
|
|
267
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.
|
|
268
278
|
|
|
269
|
-
All schemas reject additional properties. `init_repository` accepts only optional `initialBranch`, never a path or repository-mode controls. `change_branch` never accepts `baseRef`, `force`, `stash`, `discard`, `create`, `owner`, `repo`, or path inputs. `fetch_branch` accepts only the optional `remote` and `branch` pair for a targeted remote-tracking refresh (`remote` requires `branch`) and never accepts refspec, tags, prune, or force controls. `pull_branch` and `rebase_branch` have strict empty schemas and never accept a branch, remote, refspec, force, autostash, or arbitrary rebase target. `integrate_branch` requires exactly `sourceBranch` and `targetBranch`; it accepts no repository, path, remote, strategy, message, squash, signing, commit, continuation, abort, force, fetch, push, deletion, or worktree controls. `conclude_merge` requires exactly `action` (`conclude` or `abort`) and accepts no path list, commit message, strategy, `--ours`/`--theirs`, or force controls. `create_worktree` requires exactly `worktreePath`, `branchName`, and `branchMode`, plus an optional read-only `baseRef` start point for `branchMode: "new"`; `remove_worktree` requires `worktreePath` and accepts optional boolean `deleteIgnored`. No worktree tool accepts force, move, prune, repair, lock, unlock, detached, orphan, remote, or refspec controls. `retire_branch` requires exactly `branchName`, a full 40- or 64-hex-character `expectedHead`, a distinct local `targetBranch`, and the boolean `force` decision; it accepts no repository, path, remote, refspec, pattern, branch list, prune, remote-delete, worktree-removal, or inferred-target control. `pull_request` never accepts `owner`, `repo`, or owner-prefixed branch refs; BranchMe resolves the repository from local `origin` and/or matching `GITHUB_REPOSITORY`. Generic `continue_merge` and `abort_merge` tools are not available; `conclude_merge` is the only merge-continuation surface and only commits paths whose markers are already gone.
|
|
279
|
+
All schemas reject additional properties. `fetch_remote` accepts only optional `remote` and `prune`, never arbitrary refspecs or tag/force/checkout controls. `list_branches` accepts only optional `kind` and bounded `patterns`; it remains read-only. `init_repository` accepts only optional `initialBranch`, never a path or repository-mode controls. `change_branch` never accepts `baseRef`, `force`, `stash`, `discard`, `create`, `owner`, `repo`, or path inputs. `fetch_branch` accepts only the optional `remote` and `branch` pair for a targeted remote-tracking refresh (`remote` requires `branch`) and never accepts refspec, tags, prune, or force controls. `pull_branch` and `rebase_branch` have strict empty schemas and never accept a branch, remote, refspec, force, autostash, or arbitrary rebase target. `integrate_branch` requires exactly `sourceBranch` and `targetBranch`; it accepts no repository, path, remote, strategy, message, squash, signing, commit, continuation, abort, force, fetch, push, deletion, or worktree controls. `conclude_merge` requires exactly `action` (`conclude` or `abort`) and accepts no path list, commit message, strategy, `--ours`/`--theirs`, or force controls. `create_worktree` requires exactly `worktreePath`, `branchName`, and `branchMode`, plus an optional read-only `baseRef` start point for `branchMode: "new"`; `remove_worktree` requires `worktreePath` and accepts optional boolean `deleteIgnored`. No worktree tool accepts force, move, prune, repair, lock, unlock, detached, orphan, remote, or refspec controls. `retire_branch` requires exactly `branchName`, a full 40- or 64-hex-character `expectedHead`, a distinct local `targetBranch`, and the boolean `force` decision; it accepts no repository, path, remote, refspec, pattern, branch list, prune, remote-delete, worktree-removal, or inferred-target control. `pull_request` never accepts `owner`, `repo`, or owner-prefixed branch refs; BranchMe resolves the repository from local `origin` and/or matching `GITHUB_REPOSITORY`. Generic `continue_merge` and `abort_merge` tools are not available; `conclude_merge` is the only merge-continuation surface and only commits paths whose markers are already gone.
|
|
270
280
|
|
|
271
281
|
---
|
|
272
282
|
|
|
@@ -518,6 +528,7 @@ Ensure the token and Git credentials have permission for the branch and pull req
|
|
|
518
528
|
| Linked-worktree agent cannot find credentials | Pass credentials through the process environment. BranchMe does not copy repository-root `.env` or other ignored/untracked files. |
|
|
519
529
|
| Dirty worktree before branch switch, pull, rebase, or integration | Commit, stash, or discard changes outside BranchMe before using `change_branch`, `pull_branch`, `rebase_branch`, or a target control worktree for `integrate_branch`. |
|
|
520
530
|
| Fetch, pull, or rebase has no upstream | Configure the current branch upstream outside BranchMe, then retry the tool. |
|
|
531
|
+
| Unknown remote issue branch or stale branch listing | Run `fetch_remote` (optionally `prune: true`), wait for success, then `list_branches` with `kind: "remote-tracking"` and issue-name patterns. For reftable or unsafe symbolic destinations, inspect repository refs outside BranchMe. |
|
|
521
532
|
| Pull is not a fast-forward | Run `fetch_branch`, wait for it to complete, then explicitly run `rebase_branch` if rewriting local commits is intended; otherwise reconcile outside BranchMe. |
|
|
522
533
|
| Rebase fails or conflicts | `rebase_branch` automatically attempts `git rebase --abort`. Inspect repository state before continuing if automatic cleanup also fails. |
|
|
523
534
|
| Integration target mismatch or missing local branch | Check out the exact local `targetBranch` in the active control worktree and ensure both distinct branch refs already exist locally; `integrate_branch` never switches, fetches, or accepts remote-only refs. |
|
|
@@ -553,7 +564,7 @@ npm run check:pack
|
|
|
553
564
|
printf '/branchme help\n/quit\n' | pi --no-extensions -e .
|
|
554
565
|
```
|
|
555
566
|
|
|
556
|
-
Validation covers TypeScript typechecking, formatting checks, automatic context collection and prompt injection, mocked GitHub lookup, isolated real-Git worktree, branch-integration, and leased branch-retirement lifecycle tests, package checks, checkout Pi runtime smoke, and package-content verification. The checkout smoke loads BranchMe through Pi, then uses a temporary verifier command to confirm all twenty BranchMe tools are visible through `pi.getAllTools()` with strict schemas and prompt metadata, including `integrate_branch`, `retire_branch`, `conclude_merge`, and targeted `branch_status.ancestry`, with no generic `continue_merge` or `abort_merge` tool. Runtime smoke inspects retirement and landing registration/schema but never executes either cleanup tool. Smoke-test notes are recorded in [`docs/SMOKE_TEST.md`](docs/SMOKE_TEST.md), and TUI/help captures are stored in [`docs/TUI_CAPTURE.md`](docs/TUI_CAPTURE.md).
|
|
567
|
+
Validation covers TypeScript typechecking, formatting checks, automatic context collection and prompt injection, mocked GitHub lookup, isolated real-Git worktree, branch-integration, and leased branch-retirement lifecycle tests, package checks, checkout Pi runtime smoke, and package-content verification. The checkout smoke loads BranchMe through Pi, then uses a temporary verifier command to confirm all twenty-one BranchMe tools are visible through `pi.getAllTools()` with strict schemas and prompt metadata, including `integrate_branch`, `retire_branch`, `conclude_merge`, and targeted `branch_status.ancestry`, with no generic `continue_merge` or `abort_merge` tool. Runtime smoke inspects retirement and landing registration/schema but never executes either cleanup tool. Smoke-test notes are recorded in [`docs/SMOKE_TEST.md`](docs/SMOKE_TEST.md), and TUI/help captures are stored in [`docs/TUI_CAPTURE.md`](docs/TUI_CAPTURE.md).
|
|
557
568
|
|
|
558
569
|
Refresh TUI captures intentionally with:
|
|
559
570
|
|
package/SECURITY.md
CHANGED
|
@@ -21,6 +21,7 @@ Implemented git mutations are limited to:
|
|
|
21
21
|
- `track_branch`: narrow fetch followed by `git switch --no-overwrite-ignore --track=direct -c <branchName> refs/remotes/<remote>/<remoteBranch>`. Requires a new local branch, clean idle checkout, direct fetched commit ref, and verified final HEAD/upstream.
|
|
22
22
|
- `update_from_base`: narrow fetch followed by the fixed integration merge policy against a captured remote-base commit. Reuses verified outcomes and automatic conflict abort; with explicit `keepConflicts: true` a conflicted merge is instead verified and left in progress (MERGE_HEAD equals the captured base commit, HEAD unchanged) for `conclude_merge`. Does not rewrite published history or change upstream. Both new fetch workflows explicitly disable pruning, extra ref mappings, tags, and submodule recursion, and reject symbolic remote-tracking destinations before fetching so Git cannot dereference them into unrelated refs. Base updates compare stored upstream configuration, not whether cached upstream refs happen to resolve.
|
|
23
23
|
- `conclude_merge`: on the current checkout with MERGE_HEAD present, `action: "conclude"` lists unmerged paths with `git diff --name-only --diff-filter=U -z`, refuses if `git --literal-pathspecs grep` finds a default/custom-sized conflict marker in those working-tree files or staged changed blobs (including CRLF and binary content), then runs `git --literal-pathspecs add -- <those paths>` and checks the candidate index again before `git commit --no-edit`, then verifies MERGE_HEAD is gone and HEAD is an exact two-parent merge of the previous HEAD and MERGE_HEAD on the same branch. It never uses `commit -a`, never stages other paths, accepts no message, and runs repository hooks as Git normally would. `action: "abort"` runs `git merge --abort` and verifies the same restoration as the automatic abort path. It refuses multi-head merges before mutation, honors cancellation until staging begins, and reports lost commit responses as uncertain postconditions. The commit includes the entire index, including previously staged entries; callers must not stage unrelated edits during a merge. Marker-like lines are conservatively refused, and configured marker sizes above 4,096 are rejected.
|
|
24
|
+
- `fetch_remote`: atomic fetch of `+refs/heads/*:refs/remotes/<remote>/*` for one validated configured remote, with `--refmap=`, `--no-tags`, `--no-prune-tags`, `--no-recurse-submodules`, and `--no-auto-maintenance`. Explicit `--no-prune` is the default even when Git configuration enables pruning; optional `prune: true` adds `--prune` only within that remote's heads mapping. Configured extra fetch mappings, tag options, and tag-pruning settings cannot expand this scope. Rewritten tips may update cached remote-tracking refs; no local branch, tag, checkout, or upstream configuration is changed. A negative `^refs/heads/HEAD` refspec prevents writes through conventional remote HEAD aliases. Preflight rejects unsafe symbolic destinations, symlinked/malformed loose-ref namespaces, and overlapping configured remote names such as `origin` and `origin/team` so pruning cannot consume another remote's namespace. Since Git silently omits dangling symrefs from `for-each-ref`, bounded loose-ref inspection is also required, and non-files ref backends such as reftable are refused. The common-Git-directory queue serializes these fetches across linked worktrees; external processes can still race preflight. Fetch failure/cancellation is not a rollback guarantee: inspect refs before retrying.
|
|
24
25
|
- `fetch_branch`: `git fetch --no-tags --no-recurse-submodules <remote> <remoteBranchRef>:<remoteTrackingRef>`. With no arguments it validates and fetches the current branch's configured upstream; with explicit `branch` and optional configured `remote` (default `origin`) it fetches that exact remote branch. The explicit destination is limited to the selected remote-tracking ref, so local branches and working-tree files are not changed.
|
|
25
26
|
- `pull_branch`: `git pull --ff-only --no-rebase --no-autostash <upstreamRemote> <upstreamBranchRef>` for the clean current branch after validating its configured upstream target.
|
|
26
27
|
- `rebase_branch`: `git rebase --no-autostash --no-update-refs <upstream>` for the clean current branch after validating its configured upstream target. It rewrites local commits and automatically attempts `git rebase --abort` without the cancelled caller signal if the rebase fails or is killed.
|
|
@@ -41,7 +42,7 @@ BranchMe does not force checkout/removal, stash, create user-authored commits, a
|
|
|
41
42
|
|
|
42
43
|
## Network behavior
|
|
43
44
|
|
|
44
|
-
`track_branch`, `update_from_base`, `fetch_branch`, `pull_branch`, `land_branch`, and `push_branch` contact the configured Git remote through the user's normal Git transport and credentials. They do not use or inject `GITHUB_TOKEN` or `GH_TOKEN`. `rebase_branch`, `integrate_branch`, and `retire_branch` use locally available refs; BranchMe's direct retirement argv never fetches, pulls, pushes, or names a remote or remote-tracking ref for deletion.
|
|
45
|
+
`track_branch`, `update_from_base`, `fetch_remote`, `fetch_branch`, `pull_branch`, `land_branch`, and `push_branch` contact the configured Git remote through the user's normal Git transport and credentials. They do not use or inject `GITHUB_TOKEN` or `GH_TOKEN`. `rebase_branch`, `integrate_branch`, and `retire_branch` use locally available refs; BranchMe's direct retirement argv never fetches, pulls, pushes, or names a remote or remote-tracking ref for deletion.
|
|
45
46
|
|
|
46
47
|
Git extension points are a separate trust boundary. `integrate_branch` preserves repository-configured hooks, custom merge drivers, clean/smudge filters, and signature policy; it does not pass `--no-verify`. Retirement's `git update-ref` can invoke repository-configured `reference-transaction` hooks. Those configurations may execute arbitrary local commands, mutate other state, or contact networks under the user's identity. BranchMe's fixed argv, direct no-network contract, and direct no-remote-delete guarantees cannot constrain hook behavior.
|
|
47
48
|
|
|
@@ -58,7 +59,7 @@ GET https://api.github.com/repos/{owner}/{repo}/pulls?state={open|all}&head={ow
|
|
|
58
59
|
|
|
59
60
|
Explicit `pull_request_status`, idempotent `pull_request` lookup, and PR-aware `land_branch` use the last two read-only endpoints. Each request has a 10-second transport/body deadline and 64 KiB response limit. Exact PR number, repository, branch identities, and commit IDs are validated. Status does not certify CI checks or review approvals. Idempotent creation preserves existing PR fields and reuses only an exact head-SHA/base match; HTTP 422 races get one read-only recheck. All of these operations require `GITHUB_TOKEN` or `GH_TOKEN` from the process environment or verified-root `.env` fallback.
|
|
60
61
|
|
|
61
|
-
`list_branches` is an explicit read-only local inventory, limited to 200 returned refs, 128 KiB raw ref output, and 4,000 characters of text. Paths and names are display-safe metadata, not executable handoffs. Upstream counts reflect cached refs, not a fresh remote fetch.
|
|
62
|
+
`list_branches` is an explicit read-only local inventory, limited to 200 returned refs, 128 KiB raw ref output, and 4,000 characters of text. Optional `kind` selects local or remote-tracking refs; optional `patterns` accepts 1–25 nonblank, control-free Git branch-list glob patterns of at most 512 characters, matched before output limits and display redaction. Patterns are argv values after `--`, never shell code or refspecs. Paths and names are display-safe metadata, not executable handoffs. Upstream counts reflect cached refs, not a fresh remote fetch; call `fetch_remote` separately when fresh discovery is needed.
|
|
62
63
|
|
|
63
64
|
The first request is an automatic network boundary: it can run before each agent run and whenever `branch_status` explicitly refreshes context. It is a bounded, read-only lookup for one open pull request whose head is the current local branch (`per_page=1`), with a default 4-second timeout and a 64 KiB response-body limit. It is skipped when repository/branch resolution or authentication is unavailable, so BranchMe never makes an unauthenticated fallback request. Timeout, HTTP, network, malformed, and oversized-response failures become a safe unavailable state without exposing response bodies or raw network errors. Git alone is not used or claimed to provide PR metadata.
|
|
64
65
|
|
|
@@ -76,6 +77,7 @@ The branch preflight requests have no body. BranchMe uses the resolved `headBran
|
|
|
76
77
|
- `remove_worktree` requires `worktreePath`, accepts optional boolean `deleteIgnored`, requires an exact canonical match in a fresh current-repository inventory, removes no branch, and does not accept force.
|
|
77
78
|
- `retire_branch` accepts exactly `branchName`, a full 40- or 64-hex-character `expectedHead`, a distinct exact local `targetBranch`, and boolean `force`. Both names must resolve to direct local refs. Paths, repositories, remotes, remote-only or full refs, refspecs, patterns, arrays, inferred targets, worktree controls, and bulk deletion are rejected.
|
|
78
79
|
- `change_branch` accepts only `branchName` and never creates branches, checks out remote branches, forces, stashes, or discards changes.
|
|
80
|
+
- `fetch_remote` accepts only optional configured `remote` and boolean `prune`, not URLs, arbitrary refspecs, force, tag, or checkout controls. Only explicit `prune: true` deletes stale remote-tracking branch refs; local branches, tags, and other remotes remain untouched.
|
|
79
81
|
- `fetch_branch` accepts optional `branch` and `remote` (remote requires branch), otherwise resolves the current branch's configured upstream. It constructs the source-to-remote-tracking refspec internally and disables tag fetching and submodule recursion; no arbitrary refspec or prune controls are exposed.
|
|
80
82
|
- `pull_branch` accepts no parameters, updates only the clean current branch from its configured upstream, and uses fast-forward-only semantics.
|
|
81
83
|
- `rebase_branch` accepts no parameters, rebases only the clean current branch onto its configured upstream, disables autostash and multi-ref updates, never pushes, and attempts to abort on failure.
|
|
@@ -15,7 +15,7 @@ Originally approved on 2026-06-30. Updated to describe the implemented `0.3.1` p
|
|
|
15
15
|
- Exported extension function: `branchMeExtension`
|
|
16
16
|
- Repository URL: `https://github.com/senad-d/branchme`
|
|
17
17
|
- One-sentence pitch: Verified Pi tools for Git repository initialization plus current-repository branch, integration, retirement, linked-worktree, push, and GitHub pull request workflows.
|
|
18
|
-
- Tool count: twenty strict agent-callable tools.
|
|
18
|
+
- Tool count: twenty-one strict agent-callable tools.
|
|
19
19
|
|
|
20
20
|
## 3. Users and use cases
|
|
21
21
|
|
|
@@ -25,7 +25,7 @@ Originally approved on 2026-06-30. Updated to describe the implemented `0.3.1` p
|
|
|
25
25
|
- Inspect bounded current-repository branch, upstream, working-tree, related-PR, and recent-commit state, with an optional explicit local source/target ancestry proof.
|
|
26
26
|
- Integrate one exact existing local source branch into the already-current clean local target, returning verified no-op, fast-forward, merge-commit, or restored-conflict details.
|
|
27
27
|
- List the current repository's main and linked worktrees explicitly.
|
|
28
|
-
-
|
|
28
|
+
- Refresh unknown remote branch names with `fetch_remote` and optional remote-tracking-only pruning, then discover issue branches, upstream counts, and worktree occupancy with filtered `list_branches`.
|
|
29
29
|
- Join an existing remote branch with verified `track_branch`, or merge a fresh remote base into the current feature with `update_from_base`; keep a conflicted merge in progress with `keepConflicts: true` and commit or abort it with `conclude_merge` after a file-editing step removes the markers.
|
|
30
30
|
- Create and verify a linked worktree from current `HEAD`, optional read-only `baseRef`, or an unoccupied existing local branch.
|
|
31
31
|
- Return an exact, absolute, machine-readable worktree handoff for a caller-managed separate Pi session or subagent.
|
|
@@ -40,7 +40,7 @@ Originally approved on 2026-06-30. Updated to describe the implemented `0.3.1` p
|
|
|
40
40
|
- Non-goals:
|
|
41
41
|
- No staging, direct working-tree edits, user-authored commits, commit-message input/generation, diff generation, stashing, resets, or force pushes. Explicit `integrate_branch` may let Git create its standard merge commit for divergent histories.
|
|
42
42
|
- No automatic Pi cwd changes, process/session creation, or copying of `.env` and other ignored/untracked files into linked worktrees.
|
|
43
|
-
- No bulk or pattern branch deletion, inferred retirement targets, remote
|
|
43
|
+
- No bulk or pattern local-branch deletion, inferred retirement targets, server-side remote deletion, arbitrary remote-tracking deletion, automatic worktree removal during retirement, or reset-based retirement rollback. Explicit `fetch_remote.prune` only removes stale cached branch refs for one selected remote.
|
|
44
44
|
- No GitHub CLI dependency or cross-repository pull requests.
|
|
45
45
|
- No labels, reviewers, projects, or issue-linking behavior.
|
|
46
46
|
|
|
@@ -50,7 +50,8 @@ Originally approved on 2026-06-30. Updated to describe the implemented `0.3.1` p
|
|
|
50
50
|
| --- | --- | --- | --- |
|
|
51
51
|
| Command | `/branchme` | Compact TUI status and workflow panel | Informational; no Git or GitHub mutations |
|
|
52
52
|
| Command | `/branchme help` | Runtime requirements and workflow guidance | Informational; no actions |
|
|
53
|
-
| Tool | `list_branches` | Discover local and cached remote-tracking refs | Bounded, read-only; includes upstream counts and
|
|
53
|
+
| Tool | `list_branches` | Discover local and cached remote-tracking refs | Bounded, read-only; optional kind/glob patterns filter before limits; includes upstream counts and occupancy |
|
|
54
|
+
| Tool | `fetch_remote` | Refresh one remote's full branch cache | Optional stale tracking-ref pruning; no local-ref, tag, checkout, or upstream changes; files ref backend required |
|
|
54
55
|
| Tool | `track_branch` | Join an existing remote branch | Narrow fetch, clean idle checkout, verified HEAD/upstream |
|
|
55
56
|
| Tool | `update_from_base` | Fetch and merge an explicit remote base | Preserves published history; fixed merge policy, listed conflict paths, automatic abort unless `keepConflicts` |
|
|
56
57
|
| Tool | `conclude_merge` | Commit or abort the in-progress kept merge | Refuses remaining markers; stages only formerly unmerged paths; `commit --no-edit`; verified two-parent result or verified abort |
|
|
@@ -86,6 +87,7 @@ Originally approved on 2026-06-30. Updated to describe the implemented `0.3.1` p
|
|
|
86
87
|
- `src/tools/branchme-tools.ts`
|
|
87
88
|
- `src/tools/workflow-tools.ts`
|
|
88
89
|
- `src/git.ts`
|
|
90
|
+
- `src/git-discovery.ts`
|
|
89
91
|
- `src/git-workflow.ts`
|
|
90
92
|
- `src/git-integration.ts`
|
|
91
93
|
- `src/git-retirement.ts`
|
|
@@ -93,7 +95,7 @@ Originally approved on 2026-06-30. Updated to describe the implemented `0.3.1` p
|
|
|
93
95
|
- `src/github.ts`
|
|
94
96
|
- `src/ui/branchme-panel.ts`
|
|
95
97
|
- Module boundaries:
|
|
96
|
-
- The extension entry point registers the informational command, twenty tools, and automatic context hook.
|
|
98
|
+
- The extension entry point registers the informational command, twenty-one tools, and automatic context hook.
|
|
97
99
|
- `src/git-workflow.ts` and `src/tools/workflow-tools.ts` own verified remote tracking, base updates, and the new PR status registration.
|
|
98
100
|
- The context module owns bounded read-only collection, prompt formatting, targeted ancestry rendering, and the `before_agent_start` hook; automatic context never runs ancestry queries.
|
|
99
101
|
- The command and UI modules own mode-safe informational help/status behavior and never invoke mutations.
|
package/docs/SMOKE_TEST.md
CHANGED
|
@@ -23,9 +23,9 @@ pi --no-extensions -e .
|
|
|
23
23
|
## Automated smoke behavior
|
|
24
24
|
|
|
25
25
|
- `npm run smoke:pi` first runs isolated checkout Pi processes from a temporary non-Git working directory: one with `pi --no-extensions -e <package> -e <temporary verifier>` and `/branchmeverify verify`, then one with `pi --no-extensions -e <package>` and `/branchme help`.
|
|
26
|
-
- The temporary command verifier calls `pi.getAllTools()` after BranchMe loads and confirms exactly twenty tools—`list_branches`, `track_branch`, `update_from_base`, `conclude_merge`, `pull_request_status`, `branch_status`, `init_repository`, `change_branch`, `create_branch`, `create_worktree`, `fetch_branch`, `integrate_branch`, `land_branch`, `list_worktrees`, `pull_branch`, `pull_request`, `push_branch`, `rebase_branch`, `remove_worktree`, and `retire_branch`—are each registered exactly once and active with strict schemas, named prompt guidelines, descriptions, and extension source metadata. It also proves `continue_merge` and `abort_merge` are absent.
|
|
26
|
+
- The temporary command verifier calls `pi.getAllTools()` after BranchMe loads and confirms exactly twenty-one tools—`fetch_remote`, `list_branches`, `track_branch`, `update_from_base`, `conclude_merge`, `pull_request_status`, `branch_status`, `init_repository`, `change_branch`, `create_branch`, `create_worktree`, `fetch_branch`, `integrate_branch`, `land_branch`, `list_worktrees`, `pull_branch`, `pull_request`, `push_branch`, `rebase_branch`, `remove_worktree`, and `retire_branch`—are each registered exactly once and active with strict schemas, named prompt guidelines, descriptions, and extension source metadata. It also proves `continue_merge` and `abort_merge` are absent.
|
|
27
27
|
- Runtime schema inspection verifies that `integrate_branch` has exactly the required `sourceBranch` and `targetBranch` fields and that `retire_branch` has exactly the required `branchName`, `expectedHead`, `targetBranch`, and `force` fields with `additionalProperties: false`. It also verifies that `branch_status` has only an optional top-level `ancestry` object whose required nested `sourceBranch` and `targetBranch` fields reject additional nested or top-level properties.
|
|
28
|
-
- A second temporary verifier registers a deterministic local smoke model, blocks `fetch`, and runs normal prompts through real Pi lifecycle handling. It verifies automatic no-tool Git context in a temporary repository, one real `branch_status` tool refresh after a verifier-created local change, safe credential-free related-PR status with no request, and non-Git startup fallback. It expects all twenty tools but forbids `land_branch`, `retire_branch`, `integrate_branch`, `conclude_merge`, and every remote or worktree mutation tool from executing.
|
|
28
|
+
- A second temporary verifier registers a deterministic local smoke model, blocks `fetch`, and runs normal prompts through real Pi lifecycle handling. It verifies automatic no-tool Git context in a temporary repository, one real `branch_status` tool refresh after a verifier-created local change, safe credential-free related-PR status with no request, and non-Git startup fallback. It expects all twenty-one tools but forbids `land_branch`, `retire_branch`, `integrate_branch`, `conclude_merge`, and every remote or worktree mutation tool (including `fetch_remote`) from executing.
|
|
29
29
|
- The Pi runtime smoke validates worktree, integration, and retirement tool registration, strict schemas, and prompt contracts only; it never creates or removes a worktree, runs a merge, or retires a branch. Real-Git lifecycle coverage runs under `npm run test` in isolated temporary local repositories with no remote contact.
|
|
30
30
|
- Isolated real-Git integration tests cover `already_integrated`, `fast_forward`, exact two-parent `merge_commit`, and conflict-path capture followed by verified automatic abort/restoration. They also cover target mismatch, dirty control state, rejection of branch-specific target merge options, unrelated histories, ignored-file overwrite protection, preserved repository hooks, and a committed source ref checked out in another dirty linked worktree. The recorded merge argv proves autostash and rerere are disabled, ignored-file protection is enabled, and `--no-verify` is absent.
|
|
31
31
|
- Isolated real-Git retirement tests cover merged and explicitly forced-unmerged local-ref deletion, stale expected-`HEAD` rejection, current and linked-worktree occupancy, dirty unrelated worktrees, non-current targets, and preservation of target refs, remote-tracking refs, worktrees, working-tree files, and `branch.<name>.*` configuration without contacting a remote. Recorded argv proves retirement uses `update-ref --no-deref -d` with the captured expected commit and never uses `git branch -d/-D`, fetch, push, or a remote ref target.
|
package/docs/STRUCTURE.md
CHANGED
|
@@ -16,6 +16,7 @@ src/
|
|
|
16
16
|
│ ├── branchme-tools.ts # main registration, including branch discovery
|
|
17
17
|
│ └── workflow-tools.ts # tracking, base update, and PR status registration
|
|
18
18
|
├── git.ts # shared argv-style Git primitives and per-repo mutation queue
|
|
19
|
+
├── git-discovery.ts # bounded remote cache refresh, optional pruning, symbolic-ref safety
|
|
19
20
|
├── git-workflow.ts # narrow fetch, tracking checkout, and feature-base update
|
|
20
21
|
├── git-integration.ts # integration preflight, merge, cleanup, and verification state machine
|
|
21
22
|
├── git-retirement.ts # leased local-ref retirement and postcondition state machine
|
|
@@ -27,7 +28,7 @@ src/
|
|
|
27
28
|
|
|
28
29
|
## Module boundaries
|
|
29
30
|
|
|
30
|
-
1. `src/extension.ts` stays small and registers the command, twenty tools, and one `before_agent_start` context hook.
|
|
31
|
+
1. `src/extension.ts` stays small and registers the command, twenty-one tools, and one `before_agent_start` context hook.
|
|
31
32
|
2. `src/git-context.ts` owns the shared read-only collector, escaped/bounded formatter, automatic system-prompt append, and the current-state output used by `branch_status`.
|
|
32
33
|
3. `src/commands/branchme-command.ts` parses `/branchme`, `/branchme help`, `--help`, and `-h`; it never performs git or GitHub mutations and avoids raw stdout in JSON mode.
|
|
33
34
|
4. `src/tools/branchme-tools.ts` owns strict TypeBox schemas, prompt metadata, bounded tool content, and safe structured details. `branch_status` delegates to the shared context collector and optional ancestry verifier; `integrate_branch` and `retire_branch` delegate to focused mutation state machines; worktree tools expose explicit inventory and verified handoff operations.
|
|
@@ -41,6 +42,8 @@ src/
|
|
|
41
42
|
12. `src/git-workflow.ts` owns tracking and base-update preflight, narrow fetching, and verified checkout/integration including the `keepConflicts` pass-through. `src/tools/workflow-tools.ts` registers those tools, `conclude_merge`, and explicit PR status.
|
|
42
43
|
13. `src/git-landing.ts` owns execution-only `land_branch` orchestration and receipt types. It resolves the primary checkout, routes every Git call with `-C`, and reuses queue-free targeted fetch/removal/retirement helpers under one primary-root mutation window. Remote ancestry or exact merged-PR evidence gates cleanup; final target sync independently reports exact ref observations. No startup work or persistent landing state is added.
|
|
43
44
|
|
|
45
|
+
14. `src/git-discovery.ts` owns configured-remote-wide branch-cache refresh and optional stale tracking-ref pruning with explicit internal mappings, atomic ref updates, bounded files-backend destination inspection (including dangling symrefs), and active-root/common-directory queues. It never checks out or changes local refs or upstream settings.
|
|
46
|
+
|
|
44
47
|
## Pi extension conventions
|
|
45
48
|
|
|
46
49
|
- No long-lived processes, watchers, timers, sockets, or background jobs start in the extension factory.
|
|
@@ -62,6 +65,7 @@ src/
|
|
|
62
65
|
- The start-of-run snapshot may be stale after a mutation in that same run; `branch_status` is the explicit read-only refresh. Its optional targeted ancestry proof accepts exact local branches or remote-tracking refs such as `origin/main` as read-only endpoints and must run after `integrate_branch` or `fetch_branch`, not in the same parallel tool batch.
|
|
63
66
|
- `init_repository` canonicalizes pi's exact current directory, rejects filesystem root, existing `.git` entries, reinitialization, and nesting inside another repository, then runs `git init --no-template --initial-branch <name>`. It verifies the exact non-bare repository root, in-place `.git` directory, requested unborn branch, and absence of a commit. It accepts no path or repository-mode controls and performs no automatic cleanup after an uncertain failure.
|
|
64
67
|
- `change_branch` mutates local HEAD and working-tree files only through `git switch <branchName>` for existing local branches after a clean-worktree preflight.
|
|
68
|
+
- `fetch_remote` refreshes all heads for one configured remote into only its tracking namespace. Pruning is opt-in, and configured refmaps, tags, tag pruning, submodules, and maintenance are disabled. Conventional same-namespace HEAD aliases are protected by excluding the remote branch named HEAD; unsafe or dangling symbolic destinations are refused, and non-files ref backends fail closed. Active-root locking interoperates with existing same-checkout tools; a common-directory lock additionally serializes `fetch_remote` calls across linked trees. External Git processes can still race preflight.
|
|
65
69
|
- `fetch_branch` runs `git fetch --no-tags --no-recurse-submodules <remote> <remote-ref>:<remote-tracking-ref>`; its explicit refspec updates only that tracking ref without changing local branches or working-tree files. Without arguments it requires a configured upstream; with an explicit `branch` (and optional configured `remote`, default `origin`) it refreshes only `refs/remotes/<remote>/<branch>` and never touches upstream configuration.
|
|
66
70
|
- `pull_branch` requires a clean worktree and configured upstream, then updates only the current branch with an explicit `git pull --ff-only --no-rebase --no-autostash <remote> <remote-ref>` command; divergence fails without a rebase or merge commit.
|
|
67
71
|
- `rebase_branch` requires a clean worktree and configured upstream, then rebases only the current branch with `git rebase --no-autostash --no-update-refs <upstream>`; it rewrites local commits and automatically attempts `git rebase --abort` on failure.
|
|
@@ -82,7 +86,7 @@ src/
|
|
|
82
86
|
- `pull_request` reads `GITHUB_TOKEN` or `GH_TOKEN` from process environment first; only when neither process token is set does it read those token keys from a small regular `.env` file in the verified git root as a fallback. `BRANCHME_PR_AUTOFILL` uses the same process-first, `.env`-fallback precedence and defaults off.
|
|
83
87
|
- BranchMe does not force checkout/removal, move/prune/repair/lock/unlock worktrees, create detached/orphan worktrees, infer remote worktree branches, copy ignored/untracked files such as `.env`, delete ignored worktree residue through standalone `remove_worktree` without explicit `deleteIgnored: true` authorization, delete retained worktree branches during standalone removal, change Pi's cwd, or start Pi sessions. It also does not stash, create user-authored commits, accept commit messages, reset, force-push, directly edit project files, read unsupported `.env` keys, follow unsafe `.env` file types, depend on GitHub CLI, or collect telemetry. Explicit `integrate_branch`, `update_from_base`, and `conclude_merge` may let Git create a standard merge commit for divergent histories; `conclude_merge` is the only tool that stages, and only the formerly unmerged paths; only explicit leased `retire_branch` or merged-only `land_branch` may delete one exact local branch ref. Retirement has no bulk, inferred-target, remote, remote-tracking, rollback, or automatic worktree deletion. Git documents submodule worktree support as incomplete; BranchMe adds no force-based submodule cleanup.
|
|
84
88
|
|
|
85
|
-
`list_branches` returns bounded display metadata, including cached upstream counts and occupancy; automatic context is unchanged. `pull_request_status` reads exact-number or latest-head PR state, not CI/review requirements. `pull_request` reuses matching open PRs without overwriting their metadata. PR-aware landing verifies repository/remote identity, exact merged head/base, and merge-commit containment before allowing non-ancestor local retirement; it retains the expected-HEAD lease and reports graph ancestry separately from host evidence.
|
|
89
|
+
`list_branches` returns bounded display metadata, including cached upstream counts and occupancy; optional kind and Git branch-list patterns filter before raw/entry limits and display redaction. `{}` preserves the full cached inventory, and automatic context is unchanged. For unknown issue branches, run `fetch_remote` separately and wait before listing. `pull_request_status` reads exact-number or latest-head PR state, not CI/review requirements. `pull_request` reuses matching open PRs without overwriting their metadata. PR-aware landing verifies repository/remote identity, exact merged head/base, and merge-commit containment before allowing non-ancestor local retirement; it retains the expected-HEAD lease and reports graph ancestry separately from host evidence.
|
|
86
90
|
|
|
87
91
|
## Documentation
|
|
88
92
|
|
package/docs/TUI_CAPTURE.md
CHANGED
|
@@ -34,7 +34,10 @@ Commands only show info; BranchMe tools perform actions.
|
|
|
34
34
|
|
|
35
35
|
## Discovery and PR lifecycle
|
|
36
36
|
|
|
37
|
-
- `list_branches` — discover local/cached remote refs, upstream counts, and
|
|
37
|
+
- `list_branches` — discover local/cached remote refs, upstream counts, and occupancy; optional `kind` and Git glob `patterns` filter before limits.
|
|
38
|
+
- `fetch_remote` — refresh all branch refs for one configured remote (default origin); optional `prune: true` deletes only its stale cached branch refs. Wait before `list_branches`.
|
|
39
|
+
- Issue discovery: `fetch_remote({ remote: "origin", prune: true })`, then `list_branches({ kind: "remote-tracking", patterns: ["origin/feat/23", "origin/feat/23-*"] })`.
|
|
40
|
+
- `fetch_remote` preserves local branches, tags, files, and upstream settings; requires the files ref backend and refuses unsafe symbolic destinations.
|
|
38
41
|
- `track_branch` — fetch an existing remote branch and verify a new clean local tracking checkout.
|
|
39
42
|
- `update_from_base` — fetch and merge an explicit base into the current clean feature, preserving published history and upstream; a conflict is aborted unless `keepConflicts: true` leaves it in progress.
|
|
40
43
|
- `conclude_merge` — `action: "conclude"` commits the kept merge once every conflict marker is removed; `action: "abort"` restores the branch.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@senad-d/branchme",
|
|
3
|
-
"version": "0.3.
|
|
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",
|
|
@@ -43,7 +43,10 @@ export function getBranchMeHelpText(): string {
|
|
|
43
43
|
"",
|
|
44
44
|
"## Discovery and PR lifecycle",
|
|
45
45
|
"",
|
|
46
|
-
"- `list_branches` — discover local/cached remote refs, upstream counts, and
|
|
46
|
+
"- `list_branches` — discover local/cached remote refs, upstream counts, and occupancy; optional `kind` and Git glob `patterns` filter before limits.",
|
|
47
|
+
"- `fetch_remote` — refresh all branch refs for one configured remote (default origin); optional `prune: true` deletes only its stale cached branch refs. Wait before `list_branches`.",
|
|
48
|
+
"- Issue discovery: `fetch_remote({ remote: \"origin\", prune: true })`, then `list_branches({ kind: \"remote-tracking\", patterns: [\"origin/feat/23\", \"origin/feat/23-*\"] })`.",
|
|
49
|
+
"- `fetch_remote` preserves local branches, tags, files, and upstream settings; requires the files ref backend and refuses unsafe symbolic destinations.",
|
|
47
50
|
"- `track_branch` — fetch an existing remote branch and verify a new clean local tracking checkout.",
|
|
48
51
|
"- `update_from_base` — fetch and merge an explicit base into the current clean feature, preserving published history and upstream; a conflict is aborted unless `keepConflicts: true` leaves it in progress.",
|
|
49
52
|
"- `conclude_merge` — `action: \"conclude\"` commits the kept merge once every conflict marker is removed; `action: \"abort\"` restores the branch.",
|
package/src/constants.ts
CHANGED
|
@@ -10,6 +10,7 @@ export const INIT_REPOSITORY_TOOL_NAME = "init_repository";
|
|
|
10
10
|
export const CREATE_BRANCH_TOOL_NAME = "create_branch";
|
|
11
11
|
export const CHANGE_BRANCH_TOOL_NAME = "change_branch";
|
|
12
12
|
export const FETCH_BRANCH_TOOL_NAME = "fetch_branch";
|
|
13
|
+
export const FETCH_REMOTE_TOOL_NAME = "fetch_remote";
|
|
13
14
|
export const PULL_BRANCH_TOOL_NAME = "pull_branch";
|
|
14
15
|
export const REBASE_BRANCH_TOOL_NAME = "rebase_branch";
|
|
15
16
|
export const PUSH_BRANCH_TOOL_NAME = "push_branch";
|
|
@@ -33,6 +34,7 @@ export const BRANCHME_TOOL_NAMES = [
|
|
|
33
34
|
CREATE_BRANCH_TOOL_NAME,
|
|
34
35
|
CHANGE_BRANCH_TOOL_NAME,
|
|
35
36
|
FETCH_BRANCH_TOOL_NAME,
|
|
37
|
+
FETCH_REMOTE_TOOL_NAME,
|
|
36
38
|
PULL_BRANCH_TOOL_NAME,
|
|
37
39
|
REBASE_BRANCH_TOOL_NAME,
|
|
38
40
|
INTEGRATE_BRANCH_TOOL_NAME,
|
|
@@ -65,6 +67,7 @@ export const PULL_REQUEST_AUTOFILL_COMMIT_LIMIT = 20;
|
|
|
65
67
|
export const PULL_REQUEST_AUTOFILL_SUBJECT_LIMIT_CHARS = 256;
|
|
66
68
|
export const GIT_BRANCH_RAW_OUTPUT_LIMIT_BYTES = 128 * 1024;
|
|
67
69
|
export const GIT_BRANCH_ENTRY_LIMIT = 200;
|
|
70
|
+
export const GIT_BRANCH_PATTERN_LIMIT = 25;
|
|
68
71
|
export const GIT_BRANCH_SUMMARY_LIMIT_CHARS = 4_000;
|
|
69
72
|
export const GIT_WORKTREE_RAW_OUTPUT_LIMIT_BYTES = 128 * 1024;
|
|
70
73
|
export const GIT_WORKTREE_ENTRY_LIMIT = 100;
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import type { Dirent } from "node:fs";
|
|
2
|
+
import { lstat, readdir, readFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import {
|
|
6
|
+
GIT_BRANCH_RAW_OUTPUT_LIMIT_BYTES,
|
|
7
|
+
GIT_CONTEXT_VALUE_LIMIT_CHARS,
|
|
8
|
+
GIT_FETCH_TIMEOUT_MS,
|
|
9
|
+
GIT_WORKTREE_PATH_LIMIT_CHARS,
|
|
10
|
+
MAX_SUMMARY_OUTPUT_CHARS,
|
|
11
|
+
} from "./constants.ts";
|
|
12
|
+
import {
|
|
13
|
+
getCanonicalCommonGitDirectory,
|
|
14
|
+
getGitRoot,
|
|
15
|
+
isLosslessGitMetadata,
|
|
16
|
+
runGit,
|
|
17
|
+
safeWorktreeValue,
|
|
18
|
+
withRepositoryMutationQueue,
|
|
19
|
+
type GitCommandContext,
|
|
20
|
+
} from "./git.ts";
|
|
21
|
+
import type { FetchRemoteDetails } from "./types.ts";
|
|
22
|
+
|
|
23
|
+
function validateRemoteDiscoveryInput(remote: unknown, prune: unknown): asserts remote is string {
|
|
24
|
+
if (typeof remote !== "string" || !remote || remote === "." || remote.startsWith("-") ||
|
|
25
|
+
/[\s:@\p{Cc}\p{Cf}]/u.test(remote) ||
|
|
26
|
+
!isLosslessGitMetadata(remote, GIT_CONTEXT_VALUE_LIMIT_CHARS)) {
|
|
27
|
+
throw new TypeError("fetch_remote remote must be a safe configured remote name, not a URL, option, or local repository.");
|
|
28
|
+
}
|
|
29
|
+
if (typeof prune !== "boolean") throw new TypeError("fetch_remote prune must be a boolean.");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function requireSafeSymbolicDestination(ref: string, target: string, prefix: string): void {
|
|
33
|
+
// Clones commonly have origin/HEAD -> origin/main. All other symbolic
|
|
34
|
+
// destinations are refused to prevent wildcard fetch/prune following aliases.
|
|
35
|
+
if (target && (ref !== `${prefix}HEAD` || !target.startsWith(prefix) ||
|
|
36
|
+
target.length === prefix.length || target === `${prefix}HEAD`)) {
|
|
37
|
+
throw new Error("fetch_remote refuses unsafe symbolic remote-tracking destinations; no fetch ran.");
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function findLooseRemoteDirectory(commonGitDir: string, remote: string): Promise<string | undefined> {
|
|
42
|
+
return inspectRemoteAncestors(commonGitDir, ["refs", "remotes", ...remote.split("/")]);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function inspectRemoteAncestors(parent: string, segments: string[]): Promise<string | undefined> {
|
|
46
|
+
if (segments.length === 0) return parent;
|
|
47
|
+
const directory = join(parent, segments[0]);
|
|
48
|
+
let info;
|
|
49
|
+
try {
|
|
50
|
+
info = await lstat(directory);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
|
|
53
|
+
throw new Error("fetch_remote could not inspect its loose ref namespace; no fetch ran.");
|
|
54
|
+
}
|
|
55
|
+
if (!info.isDirectory() || info.isSymbolicLink()) {
|
|
56
|
+
throw new Error("fetch_remote refuses non-directory or symlinked ref namespaces; no fetch ran.");
|
|
57
|
+
}
|
|
58
|
+
return inspectRemoteAncestors(directory, segments.slice(1));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function inspectLooseRemoteFile(path: string, ref: string, prefix: string): Promise<void> {
|
|
62
|
+
if ((await lstat(path)).size > GIT_CONTEXT_VALUE_LIMIT_CHARS + 64) {
|
|
63
|
+
throw new Error("fetch_remote refuses unsafe loose ref files; no fetch ran.");
|
|
64
|
+
}
|
|
65
|
+
const content = (await readFile(path, "utf8")).trimEnd();
|
|
66
|
+
if (content.startsWith("ref: ")) {
|
|
67
|
+
requireSafeSymbolicDestination(ref, content.slice(5), prefix);
|
|
68
|
+
} else if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/iu.test(content)) {
|
|
69
|
+
throw new Error("fetch_remote received malformed loose ref metadata; no fetch ran.");
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function* looseRemoteEntries(
|
|
74
|
+
directory: string,
|
|
75
|
+
prefix: string,
|
|
76
|
+
): AsyncGenerator<{ entry: Dirent; ref: string; path: string }> {
|
|
77
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
78
|
+
for (const entry of entries) {
|
|
79
|
+
const ref = `${prefix}${entry.name}`;
|
|
80
|
+
const path = join(directory, entry.name);
|
|
81
|
+
yield { entry, ref, path };
|
|
82
|
+
// Traverse only after the caller has checked the inventory budget.
|
|
83
|
+
if (entry.isDirectory()) yield* looseRemoteEntries(path, `${ref}/`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function inspectLooseRemoteRefs(commonGitDir: string, remote: string): Promise<void> {
|
|
88
|
+
const prefix = `refs/remotes/${remote}/`;
|
|
89
|
+
const directory = await findLooseRemoteDirectory(commonGitDir, remote);
|
|
90
|
+
if (directory === undefined) return;
|
|
91
|
+
// for-each-ref silently omits dangling symrefs. Inspect the files backend too,
|
|
92
|
+
// so even an alias to an absent local branch cannot become a fetch destination.
|
|
93
|
+
let inventoryBytes = 0;
|
|
94
|
+
for await (const { entry, ref, path } of looseRemoteEntries(directory, prefix)) {
|
|
95
|
+
inventoryBytes += Buffer.byteLength(ref, "utf8");
|
|
96
|
+
if (inventoryBytes > GIT_BRANCH_RAW_OUTPUT_LIMIT_BYTES) {
|
|
97
|
+
throw new Error("fetch_remote loose ref inventory exceeded the safety limit; no fetch ran.");
|
|
98
|
+
}
|
|
99
|
+
if (entry.isDirectory()) continue;
|
|
100
|
+
if (!entry.isFile()) {
|
|
101
|
+
throw new Error("fetch_remote refuses unsafe loose ref files; no fetch ran.");
|
|
102
|
+
}
|
|
103
|
+
await inspectLooseRemoteFile(path, ref, prefix);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function requireUnambiguousRemoteNamespace(
|
|
108
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
109
|
+
ctx: GitCommandContext,
|
|
110
|
+
remote: string,
|
|
111
|
+
signal?: AbortSignal,
|
|
112
|
+
): Promise<void> {
|
|
113
|
+
const result = await runGit(pi, ctx, ["remote"], { signal });
|
|
114
|
+
if (Buffer.byteLength(result.stdout, "utf8") > GIT_BRANCH_RAW_OUTPUT_LIMIT_BYTES) {
|
|
115
|
+
throw new Error("fetch_remote configured remote inventory exceeded the safety limit; no fetch ran.");
|
|
116
|
+
}
|
|
117
|
+
for (const other of result.stdout.trimEnd().split("\n")) {
|
|
118
|
+
if (other !== remote && (other.startsWith(`${remote}/`) || remote.startsWith(`${other}/`))) {
|
|
119
|
+
throw new Error("fetch_remote refuses overlapping configured remote namespaces; no fetch ran.");
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function requireSafeRemoteDestinations(
|
|
125
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
126
|
+
ctx: GitCommandContext,
|
|
127
|
+
remote: string,
|
|
128
|
+
commonGitDir: string,
|
|
129
|
+
signal?: AbortSignal,
|
|
130
|
+
): Promise<void> {
|
|
131
|
+
const prefix = `refs/remotes/${remote}/`;
|
|
132
|
+
await runGit(pi, ctx, ["check-ref-format", `${prefix}branchme-validation`], { signal });
|
|
133
|
+
const configured = await runGit(pi, ctx, ["remote", "get-url", remote], { signal, allowFailure: true });
|
|
134
|
+
if (configured.code !== 0) throw new Error("fetch_remote remote is not a configured Git remote.");
|
|
135
|
+
await requireUnambiguousRemoteNamespace(pi, ctx, remote, signal);
|
|
136
|
+
const storage = await runGit(pi, ctx, ["config", "--get", "extensions.refStorage"], { signal, allowFailure: true });
|
|
137
|
+
if ((storage.code !== 0 && storage.code !== 1) ||
|
|
138
|
+
(storage.code === 0 && storage.stdout.trimEnd() !== "files")) {
|
|
139
|
+
throw new Error("fetch_remote requires Git's files ref backend to verify dangling symbolic destinations; no fetch ran.");
|
|
140
|
+
}
|
|
141
|
+
await inspectLooseRemoteRefs(commonGitDir, remote);
|
|
142
|
+
|
|
143
|
+
const result = await runGit(pi, ctx, [
|
|
144
|
+
"for-each-ref", "--format=%(refname)%00%(symref)", prefix,
|
|
145
|
+
], { signal });
|
|
146
|
+
if (Buffer.byteLength(result.stdout, "utf8") > GIT_BRANCH_RAW_OUTPUT_LIMIT_BYTES) {
|
|
147
|
+
throw new Error("fetch_remote destination inventory exceeded the safety limit; no fetch ran.");
|
|
148
|
+
}
|
|
149
|
+
const records = result.stdout === "" ? [] : result.stdout.trimEnd().split("\n");
|
|
150
|
+
for (const record of records) {
|
|
151
|
+
const fields = record.split("\0");
|
|
152
|
+
const [ref, target] = fields;
|
|
153
|
+
if (fields.length !== 2 || !ref.startsWith(prefix) || ref.length === prefix.length) {
|
|
154
|
+
throw new Error("fetch_remote received malformed destination metadata; no fetch ran.");
|
|
155
|
+
}
|
|
156
|
+
requireSafeSymbolicDestination(ref, target, prefix);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function fetchRemoteWithinQueue(
|
|
161
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
162
|
+
ctx: GitCommandContext,
|
|
163
|
+
remote: string,
|
|
164
|
+
prune: boolean,
|
|
165
|
+
commonGitDir: string,
|
|
166
|
+
signal?: AbortSignal,
|
|
167
|
+
): Promise<FetchRemoteDetails> {
|
|
168
|
+
try {
|
|
169
|
+
await requireSafeRemoteDestinations(pi, ctx, remote, commonGitDir, signal);
|
|
170
|
+
} catch (error) {
|
|
171
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
172
|
+
throw new Error(safeWorktreeValue(reason, MAX_SUMMARY_OUTPUT_CHARS));
|
|
173
|
+
}
|
|
174
|
+
const refspec = `+refs/heads/*:refs/remotes/${remote}/*`;
|
|
175
|
+
let result;
|
|
176
|
+
try {
|
|
177
|
+
result = await runGit(pi, ctx, [
|
|
178
|
+
"fetch", "--atomic", "--no-tags", "--no-prune-tags", "--no-recurse-submodules",
|
|
179
|
+
"--no-auto-maintenance", "--refmap=", prune ? "--prune" : "--no-prune",
|
|
180
|
+
"--", remote, refspec, "^refs/heads/HEAD",
|
|
181
|
+
], { signal, timeout: GIT_FETCH_TIMEOUT_MS });
|
|
182
|
+
} catch (error) {
|
|
183
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
184
|
+
const diagnostic = safeWorktreeValue(reason, MAX_SUMMARY_OUTPUT_CHARS - 200);
|
|
185
|
+
throw new Error(
|
|
186
|
+
`fetch_remote did not complete: ${diagnostic}. Remote-tracking refs may have changed; inspect before retrying. No rollback was attempted.`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
action: "fetch_remote",
|
|
191
|
+
repoRoot: safeWorktreeValue(ctx.cwd, GIT_WORKTREE_PATH_LIMIT_CHARS),
|
|
192
|
+
remote,
|
|
193
|
+
prune,
|
|
194
|
+
refspec,
|
|
195
|
+
output: safeWorktreeValue(result.stdout || result.stderr, MAX_SUMMARY_OUTPUT_CHARS),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function queueCommonRemoteFetch(
|
|
200
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
201
|
+
ctx: GitCommandContext,
|
|
202
|
+
remote: string,
|
|
203
|
+
prune: boolean,
|
|
204
|
+
commonGitDir: string,
|
|
205
|
+
signal?: AbortSignal,
|
|
206
|
+
): Promise<FetchRemoteDetails> {
|
|
207
|
+
if (commonGitDir === ctx.cwd) return fetchRemoteWithinQueue(pi, ctx, remote, prune, commonGitDir, signal);
|
|
208
|
+
return withRepositoryMutationQueue(
|
|
209
|
+
commonGitDir,
|
|
210
|
+
fetchRemoteWithinQueue.bind(undefined, pi, ctx, remote, prune, commonGitDir, signal),
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export async function fetchRemote(
|
|
215
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
216
|
+
ctx: GitCommandContext,
|
|
217
|
+
remote: string = "origin",
|
|
218
|
+
prune: boolean = false,
|
|
219
|
+
signal?: AbortSignal,
|
|
220
|
+
): Promise<FetchRemoteDetails> {
|
|
221
|
+
validateRemoteDiscoveryInput(remote, prune);
|
|
222
|
+
const repoRoot = await getGitRoot(pi, ctx, signal);
|
|
223
|
+
const rootCtx = { cwd: repoRoot };
|
|
224
|
+
const commonGitDir = await getCanonicalCommonGitDirectory(pi, rootCtx, signal);
|
|
225
|
+
// Retain the existing active-root lock for sibling BranchMe operations as well
|
|
226
|
+
// as a shared ref-cache lock for concurrent fetch_remote calls in linked trees.
|
|
227
|
+
return withRepositoryMutationQueue(
|
|
228
|
+
repoRoot,
|
|
229
|
+
queueCommonRemoteFetch.bind(undefined, pi, rootCtx, remote, prune, commonGitDir, signal),
|
|
230
|
+
);
|
|
231
|
+
}
|
package/src/git.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { basename, dirname, isAbsolute, join, normalize, relative, sep } from "n
|
|
|
3
3
|
import { withFileMutationQueue, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import {
|
|
5
5
|
GIT_BRANCH_ENTRY_LIMIT,
|
|
6
|
+
GIT_BRANCH_PATTERN_LIMIT,
|
|
6
7
|
GIT_BRANCH_RAW_OUTPUT_LIMIT_BYTES,
|
|
7
8
|
GIT_CONTEXT_CHANGE_LIMIT,
|
|
8
9
|
GIT_CONTEXT_RECENT_COMMIT_LIMIT,
|
|
@@ -42,6 +43,7 @@ import type {
|
|
|
42
43
|
GitFileChangeSummary,
|
|
43
44
|
InitRepositoryDetails,
|
|
44
45
|
ListBranchesDetails,
|
|
46
|
+
ListBranchesToolInput,
|
|
45
47
|
ListWorktreesDetails,
|
|
46
48
|
PullBranchDetails,
|
|
47
49
|
PushBranchDetails,
|
|
@@ -532,6 +534,26 @@ async function resolvePushTarget(
|
|
|
532
534
|
};
|
|
533
535
|
}
|
|
534
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
|
+
|
|
535
557
|
const refspec = `HEAD:${upstreamTarget.remoteRef}`;
|
|
536
558
|
return {
|
|
537
559
|
...upstreamTarget,
|
|
@@ -541,6 +563,20 @@ async function resolvePushTarget(
|
|
|
541
563
|
};
|
|
542
564
|
}
|
|
543
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
|
+
|
|
544
580
|
export async function hasWorkingTreeChanges(
|
|
545
581
|
pi: Pick<ExtensionAPI, "exec">,
|
|
546
582
|
ctx: GitCommandContext,
|
|
@@ -1273,19 +1309,81 @@ export function parseBranchRefs(output: string): Pick<ListBranchesDetails, "bran
|
|
|
1273
1309
|
return { branches, omitted: records.length - branches.length };
|
|
1274
1310
|
}
|
|
1275
1311
|
|
|
1312
|
+
function validateBranchFilters(filters: ListBranchesToolInput): void {
|
|
1313
|
+
if (filters.kind !== undefined && filters.kind !== "local" && filters.kind !== "remote-tracking") {
|
|
1314
|
+
throw new TypeError("list_branches kind must be 'local' or 'remote-tracking'.");
|
|
1315
|
+
}
|
|
1316
|
+
if (filters.patterns === undefined) return;
|
|
1317
|
+
if (!Array.isArray(filters.patterns) || filters.patterns.length === 0 || filters.patterns.length > GIT_BRANCH_PATTERN_LIMIT) {
|
|
1318
|
+
throw new TypeError(`list_branches patterns must contain 1 to ${GIT_BRANCH_PATTERN_LIMIT} strings.`);
|
|
1319
|
+
}
|
|
1320
|
+
for (const pattern of filters.patterns) {
|
|
1321
|
+
if (typeof pattern !== "string" || !pattern.trim() || pattern.length > GIT_CONTEXT_VALUE_LIMIT_CHARS ||
|
|
1322
|
+
/[\p{Cc}\p{Cf}\u2028\u2029]/u.test(pattern) || pattern.startsWith("-")) {
|
|
1323
|
+
throw new TypeError("list_branches patterns must be nonblank, bounded strings without leading '-' or control characters.");
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
async function readBranchScope(
|
|
1329
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
1330
|
+
ctx: GitCommandContext,
|
|
1331
|
+
patterns: string[],
|
|
1332
|
+
signal: AbortSignal | undefined,
|
|
1333
|
+
kind: string,
|
|
1334
|
+
): Promise<string> {
|
|
1335
|
+
const result = await runGit(pi, ctx, [
|
|
1336
|
+
"branch", "--list", "--no-color", "--no-column", "--sort=refname",
|
|
1337
|
+
// Detached/rebasing pseudo-branches are not refs; render them as empty lines.
|
|
1338
|
+
`--format=%(if:equals=refs)%(refname:rstrip=-1)%(then)${GIT_BRANCH_FORMAT}%(end)`,
|
|
1339
|
+
...(kind === "remote-tracking" ? ["--remotes"] : []), "--", ...patterns,
|
|
1340
|
+
], { signal });
|
|
1341
|
+
return result.stdout;
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
async function readFilteredBranchRefs(
|
|
1345
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
1346
|
+
ctx: GitCommandContext,
|
|
1347
|
+
filters: ListBranchesToolInput,
|
|
1348
|
+
signal?: AbortSignal,
|
|
1349
|
+
): Promise<string> {
|
|
1350
|
+
const kinds = filters.kind === undefined ? ["local", "remote-tracking"] : [filters.kind];
|
|
1351
|
+
if (filters.patterns === undefined) {
|
|
1352
|
+
const prefixes = kinds.map((kind) => kind === "local" ? "refs/heads/" : "refs/remotes/");
|
|
1353
|
+
const result = await runGit(pi, ctx, [
|
|
1354
|
+
"for-each-ref", "--sort=refname", `--format=${GIT_BRANCH_FORMAT}`, ...prefixes,
|
|
1355
|
+
], { signal });
|
|
1356
|
+
return result.stdout;
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
// Git applies branch-list glob semantics before the raw-output and entry limits.
|
|
1360
|
+
// Separate scopes keep remote names as origin/topic, not remotes/origin/topic.
|
|
1361
|
+
const outputs = await Promise.all(kinds.map(readBranchScope.bind(undefined, pi, ctx, filters.patterns, signal)));
|
|
1362
|
+
let output = "";
|
|
1363
|
+
for (const scopeOutput of outputs) {
|
|
1364
|
+
if (Buffer.byteLength(output, "utf8") + Buffer.byteLength(scopeOutput, "utf8") > GIT_BRANCH_RAW_OUTPUT_LIMIT_BYTES) {
|
|
1365
|
+
throw new TypeError("Unable to parse branches: ref output exceeded the safety limit.");
|
|
1366
|
+
}
|
|
1367
|
+
for (const record of scopeOutput.split("\n")) {
|
|
1368
|
+
if (record !== "") output += `${record}\n`;
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
return output;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1276
1374
|
export async function listBranches(
|
|
1277
1375
|
pi: Pick<ExtensionAPI, "exec">,
|
|
1278
1376
|
ctx: GitCommandContext,
|
|
1279
1377
|
signal?: AbortSignal,
|
|
1378
|
+
filters: ListBranchesToolInput = {},
|
|
1280
1379
|
): Promise<ListBranchesDetails> {
|
|
1380
|
+
validateBranchFilters(filters);
|
|
1281
1381
|
const repoRoot = await getGitRoot(pi, ctx, signal);
|
|
1282
1382
|
const rootCtx = { cwd: repoRoot };
|
|
1283
|
-
const
|
|
1284
|
-
|
|
1285
|
-
], { signal });
|
|
1286
|
-
const parsed = parseBranchRefs(result.stdout);
|
|
1383
|
+
const output = await readFilteredBranchRefs(pi, rootCtx, filters, signal);
|
|
1384
|
+
const parsed = parseBranchRefs(output);
|
|
1287
1385
|
const inventory = await collectWorktreeInventory(pi, rootCtx, signal);
|
|
1288
|
-
const rawRecords =
|
|
1386
|
+
const rawRecords = output.split("\n");
|
|
1289
1387
|
for (const [index, branch] of parsed.branches.entries()) {
|
|
1290
1388
|
if (branch.kind !== "local") continue;
|
|
1291
1389
|
const rawName = rawRecords[index].split(NUL_SEPARATOR, 1)[0].slice("refs/heads/".length);
|
|
@@ -1698,6 +1796,7 @@ async function requirePresentWorktreeDirectory(canonicalPath: string): Promise<v
|
|
|
1698
1796
|
interface WorktreeRemovalStatus {
|
|
1699
1797
|
workingTree: WorkingTreeDetails;
|
|
1700
1798
|
ignoredPaths: string[];
|
|
1799
|
+
changedPaths: string[];
|
|
1701
1800
|
}
|
|
1702
1801
|
|
|
1703
1802
|
function compareIgnoredPaths(left: string, right: string): number {
|
|
@@ -1711,6 +1810,7 @@ function parseWorktreeRemovalStatus(output: string): WorktreeRemovalStatus {
|
|
|
1711
1810
|
|
|
1712
1811
|
const records = output.split(NUL_SEPARATOR);
|
|
1713
1812
|
const ignoredPaths = new Set<string>();
|
|
1813
|
+
const changedPaths: string[] = [];
|
|
1714
1814
|
let skipNextRecord = false;
|
|
1715
1815
|
for (const [index, record] of records.entries()) {
|
|
1716
1816
|
if (skipNextRecord) {
|
|
@@ -1722,11 +1822,13 @@ function parseWorktreeRemovalStatus(output: string): WorktreeRemovalStatus {
|
|
|
1722
1822
|
const parsed = parsePorcelainChange(records, index);
|
|
1723
1823
|
skipNextRecord = parsed.nextIndex > index;
|
|
1724
1824
|
if (parsed.status === "!!") ignoredPaths.add(parsed.path.split("/")[0]);
|
|
1825
|
+
else changedPaths.push(parsed.path);
|
|
1725
1826
|
}
|
|
1726
1827
|
|
|
1727
1828
|
return {
|
|
1728
1829
|
workingTree: parseWorkingTreeStatus(output).workingTree,
|
|
1729
1830
|
ignoredPaths: [...ignoredPaths].sort(compareIgnoredPaths),
|
|
1831
|
+
changedPaths: changedPaths.sort(compareIgnoredPaths),
|
|
1730
1832
|
};
|
|
1731
1833
|
}
|
|
1732
1834
|
|
|
@@ -1735,10 +1837,13 @@ async function inspectRemovableWorktree(
|
|
|
1735
1837
|
canonicalPath: string,
|
|
1736
1838
|
signal?: AbortSignal,
|
|
1737
1839
|
allowIgnored = false,
|
|
1840
|
+
allowDirty = false,
|
|
1738
1841
|
): Promise<WorktreeRemovalStatus> {
|
|
1739
1842
|
const workingTree = await inspectWorktreeState(pi, canonicalPath, signal);
|
|
1740
|
-
if (workingTree.state !== "clean") {
|
|
1741
|
-
throw new Error(
|
|
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
|
+
);
|
|
1742
1847
|
}
|
|
1743
1848
|
|
|
1744
1849
|
const ignoredResult = await runGit(pi, { cwd: canonicalPath }, GIT_WORKTREE_REMOVAL_IGNORED_STATUS_ARGS, {
|
|
@@ -1746,7 +1851,7 @@ async function inspectRemovableWorktree(
|
|
|
1746
1851
|
timeout: GIT_STATUS_TIMEOUT_MS,
|
|
1747
1852
|
});
|
|
1748
1853
|
const removalStatus = parseWorktreeRemovalStatus(ignoredResult.stdout);
|
|
1749
|
-
if (removalStatus.workingTree.state !== "clean") {
|
|
1854
|
+
if (removalStatus.workingTree.state !== "clean" && !allowDirty) {
|
|
1750
1855
|
throw new Error("The target worktree has staged, unstaged, untracked, or unmerged changes; clean it before removal.");
|
|
1751
1856
|
}
|
|
1752
1857
|
if (!allowIgnored && removalStatus.ignoredPaths.length > 0) {
|
|
@@ -1786,6 +1891,7 @@ export async function removeWorktree(
|
|
|
1786
1891
|
worktreePath: unknown,
|
|
1787
1892
|
signal?: AbortSignal,
|
|
1788
1893
|
deleteIgnored = false,
|
|
1894
|
+
discardChanges = false,
|
|
1789
1895
|
): Promise<RemoveWorktreeDetails> {
|
|
1790
1896
|
const requestedWorktreePath = validateWorktreePathInput(worktreePath);
|
|
1791
1897
|
const repoRoot = await getGitRoot(pi, ctx, signal);
|
|
@@ -1793,11 +1899,21 @@ export async function removeWorktree(
|
|
|
1793
1899
|
|
|
1794
1900
|
return withRepositoryMutationQueue(
|
|
1795
1901
|
repoRoot,
|
|
1796
|
-
removeWorktreeWithinQueue.bind(
|
|
1902
|
+
removeWorktreeWithinQueue.bind(
|
|
1903
|
+
undefined,
|
|
1904
|
+
pi,
|
|
1905
|
+
rootCtx,
|
|
1906
|
+
requestedWorktreePath,
|
|
1907
|
+
signal,
|
|
1908
|
+
deleteIgnored,
|
|
1909
|
+
undefined,
|
|
1910
|
+
discardChanges,
|
|
1911
|
+
),
|
|
1797
1912
|
);
|
|
1798
1913
|
}
|
|
1799
1914
|
|
|
1800
|
-
// Caller must hold the repository mutation queue. Ignored residue
|
|
1915
|
+
// Caller must hold the repository mutation queue. Ignored residue and discarded
|
|
1916
|
+
// changes each require explicit authorization.
|
|
1801
1917
|
export async function removeWorktreeWithinQueue(
|
|
1802
1918
|
pi: Pick<ExtensionAPI, "exec">,
|
|
1803
1919
|
rootCtx: GitCommandContext,
|
|
@@ -1805,6 +1921,7 @@ export async function removeWorktreeWithinQueue(
|
|
|
1805
1921
|
signal?: AbortSignal,
|
|
1806
1922
|
allowIgnored = false,
|
|
1807
1923
|
expectedIdentity?: { branch: string; head: string },
|
|
1924
|
+
allowDirty = false,
|
|
1808
1925
|
): Promise<RemoveWorktreeDetails & { deletedIgnoredPaths: string[] }> {
|
|
1809
1926
|
const repoRoot = rootCtx.cwd;
|
|
1810
1927
|
const resolved = await resolveWorktreeRemovalTarget(
|
|
@@ -1829,13 +1946,23 @@ export async function removeWorktreeWithinQueue(
|
|
|
1829
1946
|
const verifiedCanonicalPath = requireLosslessWorktreeIdentity(resolved.canonicalPath, "cwd");
|
|
1830
1947
|
const retainedBranch = requireLosslessWorktreeIdentity(branchName, "branch");
|
|
1831
1948
|
await requirePresentWorktreeDirectory(verifiedCanonicalPath);
|
|
1832
|
-
const { workingTree, ignoredPaths } = await inspectRemovableWorktree(
|
|
1949
|
+
const { workingTree, ignoredPaths, changedPaths } = await inspectRemovableWorktree(
|
|
1950
|
+
pi,
|
|
1951
|
+
verifiedCanonicalPath,
|
|
1952
|
+
signal,
|
|
1953
|
+
allowIgnored,
|
|
1954
|
+
allowDirty,
|
|
1955
|
+
);
|
|
1833
1956
|
const branchHeadBefore = await getLocalBranchCommit(pi, rootCtx, retainedBranch, signal);
|
|
1834
1957
|
if (branchHeadBefore.toLowerCase() !== head.toLowerCase()) {
|
|
1835
1958
|
throw new Error("The target local branch did not match the worktree HEAD before removal.");
|
|
1836
1959
|
}
|
|
1837
1960
|
|
|
1838
|
-
|
|
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];
|
|
1839
1966
|
try {
|
|
1840
1967
|
await runGit(pi, rootCtx, args, {
|
|
1841
1968
|
signal,
|
|
@@ -1863,10 +1990,12 @@ export async function removeWorktreeWithinQueue(
|
|
|
1863
1990
|
return {
|
|
1864
1991
|
action: "remove_worktree",
|
|
1865
1992
|
deletedIgnoredPaths: ignoredPaths.map((path) => redactSecrets(path)),
|
|
1993
|
+
discardedPaths: changedPaths.map((path) => redactSecrets(path)),
|
|
1866
1994
|
repoRoot: safeWorktreeValue(repoRoot, GIT_WORKTREE_PATH_LIMIT_CHARS),
|
|
1867
1995
|
request: {
|
|
1868
1996
|
worktreePath: safeWorktreeValue(requestedWorktreePath, GIT_WORKTREE_PATH_LIMIT_CHARS),
|
|
1869
1997
|
...(allowIgnored ? { deleteIgnored: true } : {}),
|
|
1998
|
+
...(allowDirty ? { discardChanges: true } : {}),
|
|
1870
1999
|
},
|
|
1871
2000
|
verified: {
|
|
1872
2001
|
before: {
|
|
@@ -8,7 +8,10 @@ import {
|
|
|
8
8
|
CREATE_BRANCH_TOOL_NAME,
|
|
9
9
|
CREATE_WORKTREE_TOOL_NAME,
|
|
10
10
|
FETCH_BRANCH_TOOL_NAME,
|
|
11
|
+
FETCH_REMOTE_TOOL_NAME,
|
|
12
|
+
GIT_BRANCH_PATTERN_LIMIT,
|
|
11
13
|
GIT_BRANCH_SUMMARY_LIMIT_CHARS,
|
|
14
|
+
GIT_CONTEXT_VALUE_LIMIT_CHARS,
|
|
12
15
|
GIT_RETIREMENT_SUMMARY_LIMIT_CHARS,
|
|
13
16
|
GIT_WORKTREE_SUMMARY_LIMIT_CHARS,
|
|
14
17
|
INIT_REPOSITORY_TOOL_NAME,
|
|
@@ -46,6 +49,7 @@ import {
|
|
|
46
49
|
withRepositoryMutationQueue,
|
|
47
50
|
} from "../git.ts";
|
|
48
51
|
import { collectGitContext, formatGitContext } from "../git-context.ts";
|
|
52
|
+
import { fetchRemote } from "../git-discovery.ts";
|
|
49
53
|
import { formatConflictPathList, integrateBranch } from "../git-integration.ts";
|
|
50
54
|
import { retireBranch } from "../git-retirement.ts";
|
|
51
55
|
import { formatLandBranch, landBranch } from "../git-landing.ts";
|
|
@@ -77,6 +81,28 @@ import type {
|
|
|
77
81
|
|
|
78
82
|
const EmptyParametersSchema = Type.Object({}, { additionalProperties: false });
|
|
79
83
|
|
|
84
|
+
const ListBranchesParametersSchema = Type.Object(
|
|
85
|
+
{
|
|
86
|
+
kind: Type.Optional(StringEnum(["local", "remote-tracking"] as const, {
|
|
87
|
+
description: "Limit discovery to local or remote-tracking branches; omit for both.",
|
|
88
|
+
})),
|
|
89
|
+
patterns: Type.Optional(Type.Array(Type.String({
|
|
90
|
+
minLength: 1,
|
|
91
|
+
maxLength: GIT_CONTEXT_VALUE_LIMIT_CHARS,
|
|
92
|
+
description: "Git branch-list glob on branch names, including remote prefix (for example origin/feat/23-*).",
|
|
93
|
+
}), { minItems: 1, maxItems: GIT_BRANCH_PATTERN_LIMIT, description: "Match any of these patterns before output limits; omit to list all selected branches." })),
|
|
94
|
+
},
|
|
95
|
+
{ additionalProperties: false },
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const FetchRemoteParametersSchema = Type.Object(
|
|
99
|
+
{
|
|
100
|
+
remote: Type.Optional(Type.String({ minLength: 1, description: "Configured remote name; defaults to origin." })),
|
|
101
|
+
prune: Type.Optional(Type.Boolean({ description: "Delete stale cached branch refs only for this remote; defaults to false. Never deletes local branches or tags." })),
|
|
102
|
+
},
|
|
103
|
+
{ additionalProperties: false },
|
|
104
|
+
);
|
|
105
|
+
|
|
80
106
|
const InitRepositoryParametersSchema = Type.Object(
|
|
81
107
|
{
|
|
82
108
|
initialBranch: Type.Optional(Type.String({
|
|
@@ -183,6 +209,9 @@ const RemoveWorktreeParametersSchema = Type.Object(
|
|
|
183
209
|
deleteIgnored: Type.Optional(Type.Boolean({
|
|
184
210
|
description: "Explicit authorization to delete ignored files and directories with the worktree; defaults to false.",
|
|
185
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
|
+
})),
|
|
186
215
|
},
|
|
187
216
|
{ additionalProperties: false },
|
|
188
217
|
);
|
|
@@ -522,15 +551,16 @@ export function registerBranchMeTools(pi: Pick<ExtensionAPI, "registerTool" | "e
|
|
|
522
551
|
pi.registerTool({
|
|
523
552
|
name: LIST_BRANCHES_TOOL_NAME,
|
|
524
553
|
label: "List Branches",
|
|
525
|
-
description: "list_branches reads up to 200 local and remote-tracking branches, including commits, upstream counts, symbolic refs, and worktree occupancy. Read-only; cached remote refs are not fetched. Text is bounded to 4000 characters.",
|
|
554
|
+
description: "list_branches reads up to 200 local and remote-tracking branches, including commits, upstream counts, symbolic refs, and worktree occupancy. Optional kind and Git branch-list patterns filter before limits. Read-only; cached remote refs are not fetched. Text is bounded to 4000 characters.",
|
|
526
555
|
promptSnippet: "list_branches: discover local and cached remote-tracking branches without mutation",
|
|
527
556
|
promptGuidelines: [
|
|
528
557
|
"Use list_branches to discover names and worktree occupancy before branch operations; it never fetches or mutates Git state.",
|
|
529
558
|
"Treat list_branches names and paths as display metadata; redacted or truncated values are not executable identities.",
|
|
559
|
+
"Use list_branches with kind: 'remote-tracking' and patterns such as ['origin/feat/23', 'origin/feat/23-*'] for issue branch discovery; wait for fetch_remote to complete first when fresh remote refs are needed.",
|
|
530
560
|
],
|
|
531
|
-
parameters:
|
|
532
|
-
async execute(_toolCallId,
|
|
533
|
-
const details = await listBranches(pi, ctx, signal);
|
|
561
|
+
parameters: ListBranchesParametersSchema,
|
|
562
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
563
|
+
const details = await listBranches(pi, ctx, signal, params);
|
|
534
564
|
const lines = details.branches.map((branch) =>
|
|
535
565
|
`${branch.current ? "*" : "-"} ${JSON.stringify(branch.name)} (${branch.kind}) ${shortCommit(branch.head)}; upstream ${JSON.stringify(branch.upstream)}; ahead ${branch.ahead ?? "?"}, behind ${branch.behind ?? "?"}; worktrees ${JSON.stringify(branch.worktreePaths)}`);
|
|
536
566
|
const text = [`Branches: ${details.branches.length}; omitted: ${details.omitted}.`, ...lines].join("\n");
|
|
@@ -670,6 +700,27 @@ export function registerBranchMeTools(pi: Pick<ExtensionAPI, "registerTool" | "e
|
|
|
670
700
|
},
|
|
671
701
|
});
|
|
672
702
|
|
|
703
|
+
pi.registerTool({
|
|
704
|
+
name: FETCH_REMOTE_TOOL_NAME,
|
|
705
|
+
label: "Fetch Remote",
|
|
706
|
+
description: "fetch_remote refreshes all branch refs for one configured remote (default origin). Optional prune deletes only that remote's stale cached branch refs; default false. Uses an internal atomic heads-to-remote-tracking refspec, ignores configured refmaps, and disables tags, tag pruning, and submodules. Requires the files ref backend and non-overlapping remote namespaces; refuses unsafe symbolic destinations. Preserves safe remote HEAD aliases by excluding the remote branch named HEAD. Never changes local branches, checkout, upstream configuration, or working-tree files.",
|
|
707
|
+
promptSnippet: "fetch_remote: refresh a configured remote's branch cache with optional remote-tracking-only pruning",
|
|
708
|
+
promptGuidelines: [
|
|
709
|
+
"Use fetch_remote when fresh discovery of unknown remote branch names is needed; fetch_branch remains the narrow tool for a known branch.",
|
|
710
|
+
"Use fetch_remote with prune: true only when stale cached branch deletion is intended; it never deletes local branches, tags, or branches on the server.",
|
|
711
|
+
"Call fetch_remote by itself and wait for it to complete before list_branches, track_branch, or other dependent Git operations; do not batch Git mutations.",
|
|
712
|
+
"fetch_remote accepts only a configured remote name and optional prune boolean; never pass URLs, refspecs, force, tags, or checkout controls.",
|
|
713
|
+
],
|
|
714
|
+
parameters: FetchRemoteParametersSchema,
|
|
715
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
716
|
+
const details = await fetchRemote(pi, ctx, params.remote ?? "origin", params.prune ?? false, signal);
|
|
717
|
+
return {
|
|
718
|
+
content: [{ type: "text", text: `Fetched branch cache for remote ${details.remote}${details.prune ? " and pruned its stale remote-tracking branch refs" : " without pruning"}. No local branches or tags changed.` }],
|
|
719
|
+
details,
|
|
720
|
+
};
|
|
721
|
+
},
|
|
722
|
+
});
|
|
723
|
+
|
|
673
724
|
pi.registerTool({
|
|
674
725
|
name: PULL_BRANCH_TOOL_NAME,
|
|
675
726
|
label: "Pull Branch",
|
|
@@ -784,7 +835,7 @@ export function registerBranchMeTools(pi: Pick<ExtensionAPI, "registerTool" | "e
|
|
|
784
835
|
pi.registerTool({
|
|
785
836
|
name: PUSH_BRANCH_TOOL_NAME,
|
|
786
837
|
label: "Push Branch",
|
|
787
|
-
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.",
|
|
788
839
|
promptSnippet: "push_branch: push or publish the current branch only with an explicit target, without committing or staging",
|
|
789
840
|
promptGuidelines: [
|
|
790
841
|
"Use push_branch only after commits already exist; push_branch never commits, stages, or edits files.",
|
|
@@ -909,17 +960,25 @@ export function registerBranchMeTools(pi: Pick<ExtensionAPI, "registerTool" | "e
|
|
|
909
960
|
pi.registerTool({
|
|
910
961
|
name: REMOVE_WORKTREE_TOOL_NAME,
|
|
911
962
|
label: "Remove Worktree",
|
|
912
|
-
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,
|
|
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.",
|
|
913
964
|
promptSnippet: "remove_worktree: force-free removal of an explicitly selected clean linked worktree, with optional explicit ignored-residue deletion, while retaining its branch",
|
|
914
965
|
promptGuidelines: [
|
|
915
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.",
|
|
916
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.",
|
|
917
969
|
"remove_worktree never accepts force, move, prune, repair, lock, unlock, branch deletion, remote, or refspec parameters.",
|
|
918
970
|
"Do not batch remove_worktree with dependent worktree mutations; wait for remove_worktree to complete and verify its non-ready handoff before continuing.",
|
|
919
971
|
],
|
|
920
972
|
parameters: RemoveWorktreeParametersSchema,
|
|
921
973
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
922
|
-
const details = await removeWorktree(
|
|
974
|
+
const details = await removeWorktree(
|
|
975
|
+
pi,
|
|
976
|
+
ctx,
|
|
977
|
+
params.worktreePath,
|
|
978
|
+
signal,
|
|
979
|
+
params.deleteIgnored === true,
|
|
980
|
+
params.discardChanges === true,
|
|
981
|
+
);
|
|
923
982
|
return {
|
|
924
983
|
content: [{ type: "text", text: formatRemoveWorktree(details) }],
|
|
925
984
|
details,
|
package/src/types.ts
CHANGED
|
@@ -29,7 +29,25 @@ export interface BranchStatusToolInput {
|
|
|
29
29
|
ancestry?: BranchStatusAncestryQuery;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
export
|
|
32
|
+
export interface ListBranchesToolInput {
|
|
33
|
+
kind?: "local" | "remote-tracking";
|
|
34
|
+
/** Git branch-list glob patterns on names, including the remote prefix for remote-tracking refs. */
|
|
35
|
+
patterns?: string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface FetchRemoteToolInput {
|
|
39
|
+
remote?: string;
|
|
40
|
+
prune?: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface FetchRemoteDetails {
|
|
44
|
+
action: "fetch_remote";
|
|
45
|
+
repoRoot: string;
|
|
46
|
+
remote: string;
|
|
47
|
+
prune: boolean;
|
|
48
|
+
refspec: string;
|
|
49
|
+
output: string;
|
|
50
|
+
}
|
|
33
51
|
|
|
34
52
|
export interface BranchEntry {
|
|
35
53
|
name: string;
|
|
@@ -93,6 +111,8 @@ export interface RemoveWorktreeToolInput {
|
|
|
93
111
|
worktreePath: string;
|
|
94
112
|
/** Explicitly authorizes deleting ignored files and directories with the worktree. */
|
|
95
113
|
deleteIgnored?: boolean;
|
|
114
|
+
/** Explicitly authorizes discarding staged, unstaged, untracked and unmerged changes with the worktree. */
|
|
115
|
+
discardChanges?: boolean;
|
|
96
116
|
}
|
|
97
117
|
|
|
98
118
|
export interface WorktreeEntry {
|
|
@@ -176,6 +196,8 @@ export interface CreateWorktreeDetails {
|
|
|
176
196
|
export interface RemoveWorktreeDetails {
|
|
177
197
|
action: "remove_worktree";
|
|
178
198
|
deletedIgnoredPaths: string[];
|
|
199
|
+
/** Changed paths discarded under discardChanges; empty for a clean worktree. */
|
|
200
|
+
discardedPaths: string[];
|
|
179
201
|
repoRoot: string;
|
|
180
202
|
request: RemoveWorktreeToolInput;
|
|
181
203
|
verified: {
|