@senad-d/branchme 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -2
- package/README.md +38 -4
- package/SECURITY.md +14 -5
- package/docs/PROJECT_DEFINITION_BRIEF.md +8 -3
- package/docs/SMOKE_TEST.md +3 -3
- package/docs/STRUCTURE.md +7 -3
- package/package.json +1 -1
- package/src/constants.ts +2 -0
- package/src/git-landing.ts +449 -0
- package/src/git-retirement.ts +34 -11
- package/src/git.ts +76 -19
- package/src/tools/branchme-tools.ts +34 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.
|
|
3
|
+
## 0.3.0 - Unreleased
|
|
4
|
+
|
|
5
|
+
- Added `land_branch` for deterministic post-host-merge cleanup in one queued call: explicit cwd-independent Git routing from the primary repository root, targeted remote fetch and captured ancestry proof, force-free linked-worktree removal including ignored residue, expected-HEAD-leased local source deletion against the fetched remote target, and independent final target sync (`fetch-refspec`, `pull-ff`, `skipped-dirty`, or `noop`).
|
|
6
|
+
- Landing refuses an unmerged source or a cwd inside the removal target, never switches branches or prunes, preserves dirty checkouts and remote branches, and supports absent-branch/worktree retries. Structured per-step receipts report redacted diagnostics, deleted ignored top-level paths, actual before/after refs, and ahead/behind counts; failed or unattempted sync is never reported as a fast-forward.
|
|
7
|
+
- Added bare-origin real-Git landing regression tests for the parked-main/dirty-primary incident, clean and dirty target checkouts, unmerged and dirty-worktree refusals, cwd safety, idempotence, lease races, false-success command reporting, divergence, configuration-driven pruning, and redaction.
|
|
4
8
|
|
|
5
9
|
- Allowed `branch_status` ancestry endpoints to be remote-tracking refs such as `origin/main` in addition to exact local branches; local branches take precedence and remote-tracking refs stay read-only comparison targets.
|
|
6
10
|
- 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.
|
|
7
11
|
- 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.
|
|
8
12
|
- Implemented the `branchme` informational slash command with help aliases.
|
|
9
|
-
- Added
|
|
13
|
+
- Added fourteen strict BranchMe tools: `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`; merge-continuation tools are intentionally absent.
|
|
10
14
|
- 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.
|
|
11
15
|
- 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.
|
|
12
16
|
- 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 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
|
|
19
|
+
BranchMe is a Pi extension for safe 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 fourteen agent-callable tools that refresh state, manage, integrate, and retire local branches, inspect/create/remove linked worktrees, push the current branch, and create GitHub pull requests.
|
|
20
20
|
|
|
21
21
|
<table align="center">
|
|
22
22
|
<tr>
|
|
@@ -99,7 +99,9 @@ For isolated work, a specialized Git subagent can use the explicit worktree work
|
|
|
99
99
|
5. After that session finishes, remove or preserve any staged, unstaged, untracked, unmerged, or ignored local files, then explicitly call `remove_worktree` if removal was requested. Removal retains the local branch.
|
|
100
100
|
6. If the user separately asks to retire that retained branch, obtain its fresh exact `HEAD`, verify it against an exact local target, and call `retire_branch` only after worktree removal has completed.
|
|
101
101
|
|
|
102
|
-
|
|
102
|
+
After a pull request merges on the host, use [`land_branch`](#post-merge-cleanup-in-one-call) from the repository root instead of composing fetch/removal/retirement/sync calls. This explicitly authorized combined workflow deletes ignored worktree residue and retires the source branch; standalone `remove_worktree` continues to retain the branch and refuse ignored residue.
|
|
103
|
+
|
|
104
|
+
BranchMe does not change the active Pi process's cwd, start Pi or other processes, create sessions, or copy `.env` or other ignored/untracked files. For credentials needed by agents in linked worktrees, prefer process-level environment variables rather than copying repository-root secrets.
|
|
103
105
|
|
|
104
106
|
To refresh the current branch's configured remote-tracking ref without changing the local branch or working tree, use `fetch_branch` with no arguments. To refresh another remote-tracking ref — for example `origin/main` after a pull request merged on GitHub — call `fetch_branch` with `branch` (and optional `remote`, default `origin`); the targeted fetch never touches local branches, the working tree, or the current branch's upstream configuration. To reconcile the clean current branch by rewriting its local commits, run `fetch_branch`, wait for it to complete, and then run `rebase_branch`. The no-argument `fetch_branch` and `rebase_branch` require a configured upstream; `rebase_branch` automatically attempts `git rebase --abort` if rebasing fails.
|
|
105
107
|
|
|
@@ -229,6 +231,7 @@ Commands are informational only. BranchMe actions are performed by agent-callabl
|
|
|
229
231
|
| `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. |
|
|
230
232
|
| `remove_worktree` | `{ "worktreePath": string }` | Force-free removal of an explicitly selected, verified clean linked worktree. It rejects the main/current, detached, locked, prunable/missing, dirty, ignored-file-containing, or foreign worktree and verifies that the local branch remains at the same commit. |
|
|
231
233
|
| `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. |
|
|
234
|
+
| `land_branch` | `{ "sourceBranch": string, "targetBranch": string, "remote"?: string, "worktreePath"?: string }` | Post-merge fetch, ancestry 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. |
|
|
232
235
|
| `change_branch` | `{ "branchName": string }` | Validates `branchName`, requires `refs/heads/<branchName>` to exist locally, rejects dirty worktrees, and runs `git switch <branchName>`. |
|
|
233
236
|
| `fetch_branch` | `{ "remote"?: string, "branch"?: string }` | With no arguments it requires a current branch with a configured upstream and runs `git fetch --no-tags --no-recurse-submodules <upstream-remote> <upstream-branch-ref>:<remote-tracking-ref>`. With `branch` (and optional `remote`, default `origin`; `remote` requires `branch`) it fetches that exact remote branch into `refs/remotes/<remote>/<branch>` instead. Either way only that tracking ref is refreshed without changing local branches, working-tree files, or upstream configuration. |
|
|
234
237
|
| `pull_branch` | `{}` | Requires a clean current branch with a configured upstream and runs `git pull --ff-only --no-rebase --no-autostash <upstream-remote> <upstream-branch-ref>`; divergence fails without rebasing or creating a merge commit. |
|
|
@@ -344,6 +347,37 @@ The full details also distinguish requested input from verified before/after sta
|
|
|
344
347
|
|
|
345
348
|
Ignored and untracked files—including a repository-root `.env`—are not copied into a new linked worktree. Prefer credentials inherited through the new agent process environment. Git documents support for multiple worktrees of a superproject containing submodules as incomplete; BranchMe adds no force-based submodule cleanup. There is no force, move, prune, repair, lock, unlock, detached, orphan, or remote-inference worktree behavior.
|
|
346
349
|
|
|
350
|
+
### Post-merge cleanup in one call
|
|
351
|
+
|
|
352
|
+
After the pull request **merged on the host**, run from the repository root:
|
|
353
|
+
|
|
354
|
+
```json
|
|
355
|
+
{
|
|
356
|
+
"sourceBranch": "docs/74-add-canonical-glossary-context",
|
|
357
|
+
"targetBranch": "main",
|
|
358
|
+
"remote": "origin",
|
|
359
|
+
"worktreePath": "/absolute/path/to/issue-74-worktree"
|
|
360
|
+
}
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
`remote` defaults to `origin`. Omit `worktreePath` to find the linked worktree holding `sourceBranch`, if any. An explicit path can also select a worktree incorrectly parked on `targetBranch`; unrelated branches, primary/root checkouts, detached, locked, dirty, or foreign worktrees are not removed. Invocation from inside the removal directory (including a nested or symlinked cwd) is refused: **run from the repository root**. Other caller directories in the same repository are supported; every Git command has an explicit `-C` directory, and cleanup runs through the primary checkout without switching any branches.
|
|
364
|
+
|
|
365
|
+
The tool fetches the exact remote target and requires the captured source tip to be its ancestor before cleanup. Squash/rebase merges that do not preserve ancestry are refused; there is no force escape hatch. Tracked/staged changes and non-ignored untracked files block removal. **Ignored `.env`, `.pi/`, `node_modules/`, `dist/`, and other ignored residue are deleted with the worktree**; preserve anything needed first. Only their top-level path entries, never contents, appear in `deletedIgnoredPaths`. Source retirement uses the captured expected-HEAD lease and the **remote-tracking target**, not local `HEAD` or a stale local target.
|
|
366
|
+
|
|
367
|
+
Target sync is last, even when worktree removal or branch retirement is refused:
|
|
368
|
+
|
|
369
|
+
| Mode | Behavior |
|
|
370
|
+
| --- | --- |
|
|
371
|
+
| `fetch-refspec` | Target is not checked out anywhere: non-forced `fetch <remote> refs/heads/<target>:refs/heads/<target>`. Git enforces fast-forward-only updates. |
|
|
372
|
+
| `pull-ff` | Target is checked out clean: explicit `pull --ff-only --no-rebase --no-autostash <remote> refs/heads/<target>` in that worktree. |
|
|
373
|
+
| `skipped-dirty` | Target checkout is dirty: leave it untouched and report its path and ahead/behind counts against the captured remote target. |
|
|
374
|
+
| `noop` | Local target already equals the captured remote head; no sync mutation. |
|
|
375
|
+
| `not-run` / `failed` | Initial safety/ancestry refusal or sync failure; a successful fast-forward is never inferred from Git's prose. |
|
|
376
|
+
|
|
377
|
+
The structured result contains `repositoryRoot`, `remote`, `targetBranch`, `remoteTargetHead`, `sourceBranch`, `sourceHead`, `ancestry.isAncestor`, `worktree`, `branch`, `targetSync`, and ordered `steps`, plus a one-line summary. Worktree outcomes are `removed`/`absent`/`refused`; branch outcomes are `deleted`/`absent`/`refused`. Full target `before`/`after` IDs are read from the exact local ref in this call. Unknown/not-applicable identities and ancestry are `null`, not invented proofs. A second successful call reports absent cleanup and `noop` sync. An already-missing directory is reported absent without pruning a remaining registration; such occupancy can still block branch deletion.
|
|
378
|
+
|
|
379
|
+
`land_branch` never stashes, resets, checks out/switches branches, force-updates, pushes, prunes, or deletes remote/tracking refs. It holds one process-local primary-root mutation queue; it cannot lock other Pi/external Git processes. Inspect each receipt outcome before declaring landing complete. Standalone `remove_worktree` and `retire_branch` retain their original contracts.
|
|
380
|
+
|
|
347
381
|
### Verified local branch retirement
|
|
348
382
|
|
|
349
383
|
`retire_branch` is a separate, explicit lifecycle step after integration and any linked-worktree removal. It accepts exactly:
|
|
@@ -391,7 +425,7 @@ BranchMe operates only on the repository where pi is running:
|
|
|
391
425
|
- `pull_request` creates PRs only for the resolved current GitHub repository, requires resolved `headBranch` and `baseBranch` values to be distinct and exist locally, requires the GitHub `headBranch` commit to match the local branch, queues behind in-flight same-repository git mutation windows when possible, and rejects `owner:branch` head refs. Missing PR fields fail unless `BRANCHME_PR_AUTOFILL=true`.
|
|
392
426
|
- If local `origin` and `GITHUB_REPOSITORY` both resolve but disagree, `pull_request` fails closed.
|
|
393
427
|
|
|
394
|
-
BranchMe intentionally does **not** stage files, create user-authored commits, accept or generate commit messages, force checkout, stash changes, discard changes, force-push, reset, edit files directly, copy ignored/untracked files between worktrees, or delete branches during
|
|
428
|
+
BranchMe intentionally does **not** stage files, create user-authored commits, accept or generate commit messages, force checkout, stash changes, discard changes, force-push, reset, edit files directly, copy ignored/untracked files between worktrees, or delete branches during standalone `remove_worktree`. Rebase-driven rewriting occurs only through explicit `rebase_branch`; a Git-generated standard merge commit is possible only through explicit `integrate_branch` for divergent histories; one exact local ref can be deleted only through explicit `retire_branch` or merged-only `land_branch` under the leased boundaries above.
|
|
395
429
|
|
|
396
430
|
---
|
|
397
431
|
|
|
@@ -476,7 +510,7 @@ npm run check:pack
|
|
|
476
510
|
printf '/branchme help\n/quit\n' | pi --no-extensions -e .
|
|
477
511
|
```
|
|
478
512
|
|
|
479
|
-
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
|
|
513
|
+
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 fourteen BranchMe tools are visible through `pi.getAllTools()` with strict schemas and prompt metadata, including `integrate_branch`, `retire_branch`, and targeted `branch_status.ancestry`, with no merge-continuation 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).
|
|
480
514
|
|
|
481
515
|
Refresh TUI captures intentionally with:
|
|
482
516
|
|
package/SECURITY.md
CHANGED
|
@@ -24,19 +24,20 @@ Implemented git mutations are limited to:
|
|
|
24
24
|
- `push_branch`: `git push <upstreamRemote> HEAD:<upstreamBranchRef>` for the current branch when an upstream exists, or `git push --set-upstream origin <currentBranch>` when no upstream exists.
|
|
25
25
|
- `create_worktree`: `git worktree add -b <branchName> <canonicalPath> HEAD` for a new local branch, or `git worktree add <canonicalPath> <existingLocalBranch>` for an existing unoccupied local branch, after destination and repository-boundary validation.
|
|
26
26
|
- `remove_worktree`: `git worktree remove <verifiedCanonicalPath>` without force, only after fresh repository-membership, safety-state, path, tracked/untracked status, and ignored-entry checks. The local branch is retained and verified at the same commit.
|
|
27
|
+
- `land_branch`: explicit post-host-merge targeted fetch, linked-worktree removal including ignored residue, leased source-local-ref deletion against the fetched remote target, and independent final target sync. Every Git command uses an explicit `-C` directory; unoccupied targets use a non-forced fetch refspec, clean occupied targets use an explicit ff-only pull, and dirty targets are left untouched. No remote mutation, prune, stash, reset, or branch switching is attempted.
|
|
27
28
|
- `retire_branch`: `git update-ref --no-deref -d refs/heads/<branchName> <capturedRetiringHead>` only for one exact unoccupied direct local ref whose commit matches the required full `expectedHead`. The exact local target commit and ancestry are captured first; unmerged retirement requires explicit `force: true` authorization.
|
|
28
29
|
|
|
29
30
|
Before each agent run, BranchMe also runs bounded, read-only Git commands to collect branch/upstream/ahead-behind state, working-tree counts, up to 20 unstaged or untracked path entries, and up to 5 recent commits. The same collector runs when `branch_status` explicitly refreshes context. An explicit optional `branch_status.ancestry` query captures exact local source/target commit IDs and uses `git merge-base --is-ancestor` against those commits; automatic Git context never runs this query and remains unchanged. Collection does not run `fetch`, `switch`, `pull`, `rebase`, `merge`, `push`, `add`, `commit`, or any other mutation, and it never reads diffs or file contents.
|
|
30
31
|
|
|
31
32
|
Branch switching, worktree creation/removal, fast-forward pulls, successful rebases, and branch integration can update or remove filesystem content as normal Git behavior; fetch updates one validated remote-tracking ref without changing local branches or the working tree, and retirement deletes one verified local branch ref. Mutating operations for the same repository are serialized to avoid same-turn races. `integrate_branch` holds one queue window across preflight, merge, cleanup, and final verification; `retire_branch` holds one active-worktree-keyed window across preflight, immediate reinspection, leased deletion, and postcondition verification; `pull_request` uses the queue around PR preflight and creation. This in-memory queue is process-local: it coordinates BranchMe calls using the same active checkout but does not lock a different active worktree, another Pi process, or an external Git process. Integration refs are captured and re-verified. Retirement additionally uses an expected-old-value ref lease and rechecks its captured target and complete worktree occupancy. Unexpected or inconclusive movement produces a bounded uncertain error with manual inspection guidance and no reset-based rollback.
|
|
32
33
|
|
|
33
|
-
BranchMe rejects a dirty control worktree before `change_branch`, `pull_branch`, `rebase_branch`, and `integrate_branch`, and rejects any staged, unstaged, untracked, unmerged, or ignored entry in a linked worktree before
|
|
34
|
+
BranchMe rejects a dirty control worktree before `change_branch`, `pull_branch`, `rebase_branch`, and `integrate_branch`, and rejects any staged, unstaged, untracked, unmerged, or ignored entry in a linked worktree before standalone `remove_worktree`. `land_branch` allows ignored residue to be deleted, but still refuses tracked/staged or non-ignored untracked changes. Retirement does not require an unrelated active worktree to be clean, but every registered worktree record is inspected and any occupancy of the retiring branch blocks deletion. Integration also rejects an existing merge, rebase, cherry-pick, revert, or sequencer state and any non-empty target-branch `mergeOptions` setting that could alter the fixed command policy. After merge or retirement mutation begins, cleanup/postcondition inspection ignores caller cancellation and uses bounded timeouts. A `conflict` result is returned only after exact repository-relative conflict paths are captured, `git merge --abort` succeeds, source and target refs are restored, repository/control-worktree identity is preserved, operation state is cleared, and the control worktree is clean. Failed non-conflict merges remain errors; inconclusive cleanup or verification is never reported as success. After retirement is attempted, contradictory or inconclusive repository, target-ref, retiring-ref, or occupancy state is an uncertain error stating that retirement may have completed and requiring manual inspection before retry.
|
|
34
35
|
|
|
35
|
-
BranchMe does not force checkout/removal, stash, stage files, create user-authored commits, accept commit messages, reset, force-push, or edit files directly. Rebase-driven rewriting occurs only through explicit `rebase_branch`. Explicit `integrate_branch` may let Git create its standard merge commit for divergent histories, but BranchMe exposes no strategy, squash, unrelated-history, signing, force, commit, `continue_merge`, or `abort_merge` control. Explicit `retire_branch`
|
|
36
|
+
BranchMe does not force checkout/removal, stash, stage files, create user-authored commits, accept commit messages, reset, force-push, or edit files directly. Rebase-driven rewriting occurs only through explicit `rebase_branch`. Explicit `integrate_branch` may let Git create its standard merge commit for divergent histories, but BranchMe exposes no strategy, squash, unrelated-history, signing, force, commit, `continue_merge`, or `abort_merge` control. Explicit `retire_branch` and merged-only `land_branch` are the local branch-deletion surfaces. Standalone retirement has no bulk, pattern, inferred-target, remote-delete, remote-tracking-delete, automatic worktree-removal, or rollback-ref control.
|
|
36
37
|
|
|
37
38
|
## Network behavior
|
|
38
39
|
|
|
39
|
-
`fetch_branch`, `pull_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.
|
|
40
|
+
`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.
|
|
40
41
|
|
|
41
42
|
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.
|
|
42
43
|
|
|
@@ -58,7 +59,7 @@ The branch preflight requests have no body. BranchMe uses the resolved `headBran
|
|
|
58
59
|
BranchMe operates on the current repository only.
|
|
59
60
|
|
|
60
61
|
- The GitHub repository is inferred from local `origin` and/or `GITHUB_REPOSITORY`.
|
|
61
|
-
-
|
|
62
|
+
- Except for `land_branch`'s optional absolute cleanup path, branch and PR tools never accept filesystem paths. They reject `owner`, `repo`, and owner-prefixed `owner:branch` PR refs. Worktree mutations accept only an explicitly approved absolute `worktreePath`, with `branchName` and `branchMode` additionally required for creation.
|
|
62
63
|
- `list_worktrees` is read-only and returns a bounded inventory collected from `git worktree list --porcelain -z`; automatic Git context remains focused on the active worktree.
|
|
63
64
|
- `create_worktree` accepts exactly `worktreePath`, `branchName`, and `branchMode` (`new` or `existing`). New mode uses current `HEAD` only; existing mode requires an existing local branch not checked out elsewhere and does not infer remote branches.
|
|
64
65
|
- `remove_worktree` accepts exactly `worktreePath`, requires an exact canonical match in a fresh current-repository inventory, removes no branch, and does not accept force.
|
|
@@ -81,7 +82,15 @@ Linked worktree management expands the mutation boundary beyond the active check
|
|
|
81
82
|
|
|
82
83
|
Paths, branch names, lock/prune reasons, and Git output are untrusted metadata. Informational inventory, prose, summaries, and non-identity details are escaped, redacted, and bounded. Successful machine-readable `handoff.cwd` and handoff branch fields instead contain exact verified identities, so BranchMe rejects any identity that would require display transformation before mutation. This also applies to the retained branch returned after removal. Creation failures do not trigger automatic deletion of a possibly created directory or branch; the caller is told to inspect the repository and destination. No force, move, prune, repair, lock, unlock, detached, orphan, or remote-inference worktree operation is implemented.
|
|
83
84
|
|
|
84
|
-
BranchMe does not copy ignored or untracked files, including repository-root `.env` files, into linked worktrees. If a caller creates ignored local files in a linked checkout, those files block
|
|
85
|
+
BranchMe does not copy ignored or untracked files, including repository-root `.env` files, into linked worktrees. If a caller creates ignored local files in a linked checkout, those files block standalone `remove_worktree` until they are removed or preserved outside the checkout. In contrast, an explicit `land_branch` call authorizes deletion of ignored residue with that worktree. Git documents support for multiple worktrees of a superproject containing submodules as incomplete; BranchMe does not add force-based submodule cleanup.
|
|
86
|
+
|
|
87
|
+
## Post-merge landing boundary
|
|
88
|
+
|
|
89
|
+
`land_branch` is execution-only and accepts required exact local `sourceBranch`/`targetBranch`, optional configured `remote` (default `origin`), and optional absolute `worktreePath`. It resolves the primary checkout from the caller's repository and holds its process-local mutation queue for the entire operation. This is not an external-process or cross-worktree lock. Both the actual process cwd and tool-context cwd are checked against the canonical removal path; if either is inside it, landing refuses before fetching and says to run from the repository root.
|
|
90
|
+
|
|
91
|
+
The captured source tip must be an ancestor of the freshly fetched remote-tracking target before any deletion. The primary checkout is never removed. The selected linked checkout must be on the source or target branch, clean of tracked/staged/non-ignored untracked changes, unlocked, attached, and part of the same repository. Its HEAD must also be contained in the captured target. Ignored files and directories, including `.env` and `.pi/`, are intentionally deleted by force-free `git worktree remove`; preserve needed files first. The receipt lists redacted top-level ignored paths, never file contents. The source branch is then retired with the same atomic expected-old-value lease and full occupancy checks as standalone retirement, but ancestry is against the fetched remote-tracking ref. No remote or remote-tracking ref is deleted.
|
|
92
|
+
|
|
93
|
+
Target sync always follows attempted cleanup, including cleanup refusals, unless initial cwd/fetch/ancestry checks refuse the whole operation. No branch switching, stash, reset, force update, or prune occurs. Explicit fetch mappings and prune-disabling flags/config prevent Git's configured extra fetch mappings and pruning from expanding the landing scope. Actual local target refs are read before/after sync; errors, dirty skips, and unattempted steps are reported, not described as successful fast-forwards. After possible sync mutation, final ref verification ignores cancellation. Missing branches/worktrees are idempotent; a leftover missing-worktree registration is not pruned and can still block retirement. Repository hooks and external concurrent mutations remain within the existing trust/uncertainty boundary.
|
|
85
94
|
|
|
86
95
|
## Local branch-retirement boundary
|
|
87
96
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Project Definition Brief
|
|
2
2
|
|
|
3
|
-
Originally approved on 2026-06-30. Updated to describe the implemented `0.
|
|
3
|
+
Originally approved on 2026-06-30. Updated to describe the implemented `0.3.0` package.
|
|
4
4
|
|
|
5
5
|
## 1. Bootstrap history
|
|
6
6
|
|
|
@@ -15,7 +15,7 @@ Originally approved on 2026-06-30. Updated to describe the implemented `0.2.0` p
|
|
|
15
15
|
- Exported extension function: `branchMeExtension`
|
|
16
16
|
- Repository URL: `https://github.com/senad-d/branchme`
|
|
17
17
|
- One-sentence pitch: Verified current-repository Pi tools for branch, integration, retirement, linked-worktree, push, and GitHub pull request workflows.
|
|
18
|
-
- Tool count:
|
|
18
|
+
- Tool count: fourteen strict agent-callable tools.
|
|
19
19
|
|
|
20
20
|
## 3. Users and use cases
|
|
21
21
|
|
|
@@ -27,6 +27,7 @@ Originally approved on 2026-06-30. Updated to describe the implemented `0.2.0` p
|
|
|
27
27
|
- Create and verify a linked worktree for a new branch from current `HEAD` or an unoccupied existing local branch.
|
|
28
28
|
- Return an exact, absolute, machine-readable worktree handoff for a caller-managed separate Pi session or subagent.
|
|
29
29
|
- Remove an exact verified linked worktree only when it is clean and contains no ignored entries, while retaining its local branch.
|
|
30
|
+
- Land a host-merged feature in one cwd-independent call: fetch/prove remote ancestry, remove a clean linked checkout including ignored residue, lease-delete the source branch, and sync the target independently without touching a dirty checkout.
|
|
30
31
|
- Retire one exact unoccupied local branch ref only when it matches a full expected `HEAD` and its relationship to one exact local target has been verified; unmerged retirement requires explicit force authorization.
|
|
31
32
|
- Switch to an existing local branch after a clean-worktree preflight or create a new branch from current `HEAD`.
|
|
32
33
|
- Fetch a configured upstream tracking ref, fast-forward the current branch, or explicitly rebase it.
|
|
@@ -51,6 +52,7 @@ Originally approved on 2026-06-30. Updated to describe the implemented `0.2.0` p
|
|
|
51
52
|
| Tool | `create_worktree` | Create and verify a linked worktree | Exact absolute handoff cwd; new/existing local branch modes only |
|
|
52
53
|
| Tool | `remove_worktree` | Remove an exact verified clean linked worktree | Force-free; ignored entries block removal; branch retained |
|
|
53
54
|
| Tool | `retire_branch` | Delete one exact verified local branch ref | Expected-`HEAD` lease; exact target ancestry; explicit force for unmerged history; local-only |
|
|
55
|
+
| Tool | `land_branch` | Post-host-merge cleanup and final target sync in one call | Remote ancestry gate, ignored-residue deletion, leased local source deletion, per-step receipts; run from repository root |
|
|
54
56
|
| Tool | `change_branch` | Switch to an existing local branch | Rejects dirty worktrees |
|
|
55
57
|
| Tool | `fetch_branch` | Refresh the current branch's configured tracking ref | Explicit fetch refspec; no checkout change |
|
|
56
58
|
| Tool | `pull_branch` | Fast-forward the clean current branch | No rebase, merge commit, or autostash |
|
|
@@ -75,10 +77,11 @@ Originally approved on 2026-06-30. Updated to describe the implemented `0.2.0` p
|
|
|
75
77
|
- `src/git.ts`
|
|
76
78
|
- `src/git-integration.ts`
|
|
77
79
|
- `src/git-retirement.ts`
|
|
80
|
+
- `src/git-landing.ts`
|
|
78
81
|
- `src/github.ts`
|
|
79
82
|
- `src/ui/branchme-panel.ts`
|
|
80
83
|
- Module boundaries:
|
|
81
|
-
- The extension entry point registers the informational command,
|
|
84
|
+
- The extension entry point registers the informational command, fourteen tools, and automatic context hook.
|
|
82
85
|
- 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.
|
|
83
86
|
- The command and UI modules own mode-safe informational help/status behavior and never invoke mutations.
|
|
84
87
|
- The tools module owns strict TypeBox schemas, descriptions, prompt metadata, bounded display content, and serializable result details.
|
|
@@ -102,6 +105,8 @@ Originally approved on 2026-06-30. Updated to describe the implemented `0.2.0` p
|
|
|
102
105
|
- Linked-worktree mutations: `create_worktree` can create a checkout directory outside the active checkout after canonical destination and repository-boundary validation. `remove_worktree` can recursively remove only an exact verified linked-worktree directory after clean and ignored-entry preflights.
|
|
103
106
|
- BranchMe does not directly edit project files, stage content, create user-authored commits, accept commit messages, copy local-only files into new worktrees, delete the retained branch during removal, or mutate worktrees through slash commands. `integrate_branch` may cause Git to create a standard merge commit under the verified boundary below; `retire_branch` may delete one exact local branch ref only under the separate leased boundary below.
|
|
104
107
|
|
|
108
|
+
The standalone worktree-removal/retirement contracts below remain unchanged. `land_branch` is an explicit combined post-merge alternative: it authorizes deletion of ignored residue, uses the fetched remote-tracking target for leased retirement, reports each outcome, and syncs the local target last. See [README landing contract](../README.md#post-merge-cleanup-in-one-call) and [security boundary](../SECURITY.md#post-merge-landing-boundary).
|
|
109
|
+
|
|
105
110
|
## 7. Worktree handoff contract
|
|
106
111
|
|
|
107
112
|
- `list_worktrees` reads bounded NUL-delimited porcelain inventory and keeps worktree discovery out of automatic active-worktree context.
|
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
|
|
26
|
+
- The temporary command verifier calls `pi.getAllTools()` after BranchMe loads and confirms exactly fourteen tools—`branch_status`, `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
|
|
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 fourteen tools but forbids `land_branch`, `retire_branch`, `integrate_branch`, and every remote or worktree mutation tool 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.
|
|
@@ -45,7 +45,7 @@ pi --no-extensions -e .
|
|
|
45
45
|
|
|
46
46
|
- `npm run typecheck`, `npm run format:check`, `npm run test`, `npm run smoke:pi`, `npm run check:pack`, `npm run validate`, and `npm run smoke:pi:packed` passed.
|
|
47
47
|
- `npm run smoke:worktree-handoff` returned `ok: true`, an absolute ready cwd, the expected `feature/isolated-handoff-smoke` branch and full commit ID from a separate process, `branchRetained: true` after force-free removal, `retireBranchInvoked: false`, zero network requests, and an isolated empty credential source.
|
|
48
|
-
- `npm run smoke:pi` loaded BranchMe through Pi, verified exactly
|
|
48
|
+
- `npm run smoke:pi` loaded BranchMe through Pi, verified exactly fourteen BranchMe tools (including strict `land_branch`, `integrate_branch`, `retire_branch`, and nested `branch_status.ancestry` schemas), proved merge-continuation tools absent, and confirmed non-mutating BranchMe command output.
|
|
49
49
|
- The isolated Git-context prompt smoke observed the `before_agent_start` snapshot, answered branch and dirty-tree state without a tool call, refreshed a verifier-created local change through one real `branch_status` call, returned safe unavailable context without credentials or outside Git, and attempted no network request.
|
|
50
50
|
- `npm run smoke:pi:packed` packed BranchMe outside the repository, installed the artifact in a temporary production workspace, loaded the installed package through Pi, and confirmed non-mutating BranchMe command output.
|
|
51
51
|
- `npm run check:pack` confirmed the package contents are limited to public docs (including worktree, local integration, leased local branch-retirement, and security boundaries), images, source, license, package metadata, `.env.example`, and `tsconfig.json`; private planning specs and generated files remain excluded.
|
package/docs/STRUCTURE.md
CHANGED
|
@@ -13,10 +13,11 @@ src/
|
|
|
13
13
|
├── commands/
|
|
14
14
|
│ └── branchme-command.ts # /branchme status/help command; informational only
|
|
15
15
|
├── tools/
|
|
16
|
-
│ └── branchme-tools.ts # registration for
|
|
16
|
+
│ └── branchme-tools.ts # registration for fourteen branch/integration/retirement/landing/worktree/GitHub tools
|
|
17
17
|
├── git.ts # shared argv-style Git primitives and per-repo mutation queue
|
|
18
18
|
├── git-integration.ts # integration preflight, merge, cleanup, and verification state machine
|
|
19
19
|
├── git-retirement.ts # leased local-ref retirement and postcondition state machine
|
|
20
|
+
├── git-landing.ts # queued post-merge cleanup, explicit cwd routing, per-step receipts
|
|
20
21
|
├── github.ts # GitHub repo resolution, env/.env tokens, branch preflight, REST calls, redaction
|
|
21
22
|
└── ui/
|
|
22
23
|
└── branchme-panel.ts # compact /branchme status panel renderer
|
|
@@ -24,7 +25,7 @@ src/
|
|
|
24
25
|
|
|
25
26
|
## Module boundaries
|
|
26
27
|
|
|
27
|
-
1. `src/extension.ts` stays small and registers the command,
|
|
28
|
+
1. `src/extension.ts` stays small and registers the command, fourteen tools, and one `before_agent_start` context hook.
|
|
28
29
|
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`.
|
|
29
30
|
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.
|
|
30
31
|
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.
|
|
@@ -35,6 +36,7 @@ src/
|
|
|
35
36
|
9. `src/redaction.ts` owns shared credential redaction for Git, GitHub, and prompt-bound metadata.
|
|
36
37
|
10. `src/types.ts` keeps serializable details shared by helpers, context, and tools.
|
|
37
38
|
11. `src/ui/branchme-panel.ts` renders a compact status panel and clips lines to terminal width.
|
|
39
|
+
12. `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 gates cleanup; final target sync independently reports exact ref observations. No startup work or persistent landing state is added.
|
|
38
40
|
|
|
39
41
|
## Pi extension conventions
|
|
40
42
|
|
|
@@ -69,10 +71,11 @@ src/
|
|
|
69
71
|
- `list_worktrees` reads a bounded `git worktree list --porcelain -z` inventory and remains explicit rather than expanding automatic active-worktree context.
|
|
70
72
|
- `create_worktree` requires an explicitly approved absolute destination, canonicalizes its existing parent, rejects existing or nested/common-Git-directory destinations, and creates from current `HEAD`, from an explicit read-only `baseRef` (exact local branch, remote-tracking ref, or full commit resolved to a commit before mutation and never checked out or reset), or from an unoccupied existing local branch. It verifies canonical path, branch, `HEAD`, and cleanliness before returning a ready handoff.
|
|
71
73
|
- `remove_worktree` resolves an exact fresh current-repository inventory match, rejects main/current/dirty/ignored-entry-containing/detached/locked/prunable/missing/bare entries, runs a bounded removal-specific ignored-entry scan, performs force-free removal with the verified path, and confirms that the local branch remains at the same commit.
|
|
74
|
+
- `land_branch` fetches the exact remote target, refuses unmerged history or a cwd inside the removal target, removes only a safe linked source/target checkout (including ignored residue), and lease-deletes only the source local ref against the fetched remote-tracking target. Target sync is last and uses a non-forced fetch refspec, a clean-worktree ff-only pull, dirty-skip, or no-op. Failed/unattempted sync is explicit; receipts include actual before/after refs and top-level deleted ignored paths. Standalone removal/retirement defaults are unchanged.
|
|
72
75
|
- `push_branch` mutates remote refs only for the current branch and uses an explicit upstream remote/refspec instead of bare `git push` when an upstream exists.
|
|
73
76
|
- `pull_request` requires resolved `headBranch` and `baseBranch` values to be distinct and exist locally, requires `headBranch` to match the GitHub-visible branch commit, queues behind already-started same-repository git mutation windows, makes GitHub REST API calls for the resolved current repository only, and rejects owner-prefixed or unsafe branch refs before the request. Omitted fields require configured autofill.
|
|
74
77
|
- `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.
|
|
75
|
-
- 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`, remove
|
|
78
|
+
- 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`, remove ignored-entry-containing worktrees through standalone `remove_worktree`, delete retained worktree branches during standalone removal, change Pi's cwd, or start Pi sessions. It also does not stash, stage, create user-authored commits, accept commit messages, reset, force-push, directly edit files, read unsupported `.env` keys, follow unsafe `.env` file types, depend on GitHub CLI, or collect telemetry. Only explicit `integrate_branch` may let Git create a standard merge commit for divergent histories; 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.
|
|
76
79
|
|
|
77
80
|
## Documentation
|
|
78
81
|
|
|
@@ -91,6 +94,7 @@ test/
|
|
|
91
94
|
├── git-integration.test.mjs # isolated real-Git context, branch integration, and worktree lifecycle coverage
|
|
92
95
|
├── git-retirement.test.mjs # mocked retirement preflight, lease, postconditions, and failures
|
|
93
96
|
├── git-retirement-integration.test.mjs # isolated real-Git local-ref retirement lifecycle coverage
|
|
97
|
+
├── land-branch.test.mjs # bare-origin post-merge cleanup, cwd safety, idempotence, and receipt regressions
|
|
94
98
|
├── github.test.mjs # GitHub parsing, token resolution, related-PR lookup, redaction
|
|
95
99
|
├── preparation.test.mjs # package/docs/source metadata checks
|
|
96
100
|
├── schema-validation.test.mjs # strict TypeBox schema validation, including worktree and retirement fields
|
package/package.json
CHANGED
package/src/constants.ts
CHANGED
|
@@ -14,6 +14,7 @@ export const CREATE_WORKTREE_TOOL_NAME = "create_worktree";
|
|
|
14
14
|
export const REMOVE_WORKTREE_TOOL_NAME = "remove_worktree";
|
|
15
15
|
export const INTEGRATE_BRANCH_TOOL_NAME = "integrate_branch";
|
|
16
16
|
export const RETIRE_BRANCH_TOOL_NAME = "retire_branch";
|
|
17
|
+
export const LAND_BRANCH_TOOL_NAME = "land_branch";
|
|
17
18
|
export const PULL_REQUEST_AUTOFILL_ENV_NAME = "BRANCHME_PR_AUTOFILL";
|
|
18
19
|
|
|
19
20
|
export const BRANCHME_TOOL_NAMES = [
|
|
@@ -25,6 +26,7 @@ export const BRANCHME_TOOL_NAMES = [
|
|
|
25
26
|
REBASE_BRANCH_TOOL_NAME,
|
|
26
27
|
INTEGRATE_BRANCH_TOOL_NAME,
|
|
27
28
|
RETIRE_BRANCH_TOOL_NAME,
|
|
29
|
+
LAND_BRANCH_TOOL_NAME,
|
|
28
30
|
PUSH_BRANCH_TOOL_NAME,
|
|
29
31
|
PULL_REQUEST_TOOL_NAME,
|
|
30
32
|
LIST_WORKTREES_TOOL_NAME,
|
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
import { lstat, realpath } from "node:fs/promises";
|
|
2
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { GIT_FETCH_TIMEOUT_MS, GIT_PULL_TIMEOUT_MS, MAX_SUMMARY_OUTPUT_CHARS } from "./constants.ts";
|
|
4
|
+
import {
|
|
5
|
+
canonicalizePathAllowMissing,
|
|
6
|
+
collectWorktreeInventory,
|
|
7
|
+
fetchRemoteBranchWithinQueue,
|
|
8
|
+
formatGitFailure,
|
|
9
|
+
getAheadBehindCount,
|
|
10
|
+
getCanonicalCommonGitDirectory,
|
|
11
|
+
getCanonicalGitWorktreeRoot,
|
|
12
|
+
getCurrentBranch,
|
|
13
|
+
getGitOperationState,
|
|
14
|
+
getLocalBranchCommit,
|
|
15
|
+
getRemoteTrackingRefCommit,
|
|
16
|
+
getWorkingTreeStatus,
|
|
17
|
+
inspectDirectLocalBranchRef,
|
|
18
|
+
inspectDirectRemoteTrackingRef,
|
|
19
|
+
isCommitAncestor,
|
|
20
|
+
pathIsInsideOrEqual,
|
|
21
|
+
removeWorktreeWithinQueue,
|
|
22
|
+
requireLosslessWorktreeIdentity,
|
|
23
|
+
runGit,
|
|
24
|
+
validateBranchName,
|
|
25
|
+
validateBranchNameInput,
|
|
26
|
+
validateWorktreePathInput,
|
|
27
|
+
validateWorktreeRemovalPath,
|
|
28
|
+
withRepositoryMutationQueue,
|
|
29
|
+
type GitCommandContext,
|
|
30
|
+
} from "./git.ts";
|
|
31
|
+
import { retireBranchWithinQueue } from "./git-retirement.ts";
|
|
32
|
+
import { redactSecrets } from "./redaction.ts";
|
|
33
|
+
import type { AheadBehindCount } from "./types.ts";
|
|
34
|
+
|
|
35
|
+
type GitAPI = Pick<ExtensionAPI, "exec">;
|
|
36
|
+
type Inventory = Awaited<ReturnType<typeof collectWorktreeInventory>>;
|
|
37
|
+
|
|
38
|
+
export interface LandBranchInput {
|
|
39
|
+
sourceBranch: string;
|
|
40
|
+
targetBranch: string;
|
|
41
|
+
remote?: string;
|
|
42
|
+
worktreePath?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface LandBranchReceipt {
|
|
46
|
+
repositoryRoot: string;
|
|
47
|
+
remote: string;
|
|
48
|
+
targetBranch: string;
|
|
49
|
+
remoteTargetHead: string | null;
|
|
50
|
+
sourceBranch: string;
|
|
51
|
+
sourceHead: string | null;
|
|
52
|
+
ancestry: { isAncestor: boolean | null };
|
|
53
|
+
worktree: {
|
|
54
|
+
path: string | null;
|
|
55
|
+
outcome: "removed" | "absent" | "refused";
|
|
56
|
+
reason: string | null;
|
|
57
|
+
deletedIgnoredPaths: string[];
|
|
58
|
+
};
|
|
59
|
+
branch: {
|
|
60
|
+
outcome: "deleted" | "absent" | "refused";
|
|
61
|
+
reason: string | null;
|
|
62
|
+
expectedHead: string | null;
|
|
63
|
+
};
|
|
64
|
+
targetSync: {
|
|
65
|
+
mode: "fetch-refspec" | "pull-ff" | "skipped-dirty" | "noop" | "not-run" | "failed";
|
|
66
|
+
worktreePath: string | null;
|
|
67
|
+
before: string | null;
|
|
68
|
+
after: string | null;
|
|
69
|
+
aheadBehind: AheadBehindCount | null;
|
|
70
|
+
reason: string | null;
|
|
71
|
+
};
|
|
72
|
+
steps: {
|
|
73
|
+
step: "repository" | "cwd" | "fetch" | "ancestry" | "worktree" | "branch" | "targetSync";
|
|
74
|
+
outcome: string;
|
|
75
|
+
reason: string | null;
|
|
76
|
+
}[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function landingError(error: unknown): string {
|
|
80
|
+
const text = error instanceof Error ? error.message : String(error);
|
|
81
|
+
return redactSecrets(text).replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, " ").slice(0, MAX_SUMMARY_OUTPUT_CHARS);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function validateLandingInput(input: LandBranchInput): void {
|
|
85
|
+
for (const key of Object.keys(input)) {
|
|
86
|
+
if (!["sourceBranch", "targetBranch", "remote", "worktreePath"].includes(key)) {
|
|
87
|
+
throw new Error("land_branch accepts only sourceBranch, targetBranch, remote, and worktreePath.");
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
for (const branch of [input.sourceBranch, input.targetBranch]) {
|
|
91
|
+
validateBranchNameInput(branch);
|
|
92
|
+
requireLosslessWorktreeIdentity(branch, "branch");
|
|
93
|
+
if (branch.startsWith("refs/") || branch.includes("@{")) {
|
|
94
|
+
throw new Error("land_branch requires exact local branch names, not full refs or revision expressions.");
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (input.sourceBranch === input.targetBranch) throw new Error("Source and target branches must be distinct.");
|
|
98
|
+
if (input.remote !== undefined) {
|
|
99
|
+
validateBranchNameInput(input.remote, "Remote");
|
|
100
|
+
requireLosslessWorktreeIdentity(input.remote, "branch");
|
|
101
|
+
}
|
|
102
|
+
if (input.worktreePath !== undefined) validateWorktreePathInput(input.worktreePath);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Reused Git helpers all receive explicit -C routing. Disable config-driven pruning,
|
|
106
|
+
// extra fetch mappings and submodule recursion as well as command-line force options.
|
|
107
|
+
async function executeLandingGit(
|
|
108
|
+
pi: GitAPI,
|
|
109
|
+
remote: string,
|
|
110
|
+
command: string,
|
|
111
|
+
args: string[],
|
|
112
|
+
options: Parameters<GitAPI["exec"]>[2],
|
|
113
|
+
) {
|
|
114
|
+
if (!options?.cwd) throw new Error("Landing Git commands require an explicit working directory.");
|
|
115
|
+
let routedArgs = args;
|
|
116
|
+
if (args[0] === "fetch") {
|
|
117
|
+
routedArgs = ["fetch", "--no-prune", "--no-prune-tags", "--refmap=", ...args.slice(1)];
|
|
118
|
+
} else if (args[0] === "pull") {
|
|
119
|
+
routedArgs = [
|
|
120
|
+
"-c", "fetch.prune=false", "-c", "fetch.pruneTags=false",
|
|
121
|
+
"-c", `remote.${remote}.prune=false`, "-c", `remote.${remote}.pruneTags=false`,
|
|
122
|
+
"pull", "--no-tags", "--no-prune", "--refmap=", "--no-recurse-submodules", ...args.slice(1),
|
|
123
|
+
];
|
|
124
|
+
}
|
|
125
|
+
return pi.exec(command, ["-C", options.cwd, ...routedArgs], options);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function newLandingReceipt(root: string, input: LandBranchInput): LandBranchReceipt {
|
|
129
|
+
return {
|
|
130
|
+
repositoryRoot: root,
|
|
131
|
+
remote: input.remote ?? "origin",
|
|
132
|
+
targetBranch: input.targetBranch,
|
|
133
|
+
remoteTargetHead: null,
|
|
134
|
+
sourceBranch: input.sourceBranch,
|
|
135
|
+
sourceHead: null,
|
|
136
|
+
ancestry: { isAncestor: null },
|
|
137
|
+
worktree: { path: null, outcome: "refused", reason: "Not attempted.", deletedIgnoredPaths: [] },
|
|
138
|
+
branch: { outcome: "refused", reason: "Not attempted.", expectedHead: null },
|
|
139
|
+
targetSync: { mode: "not-run", worktreePath: null, before: null, after: null, aheadBehind: null, reason: "Not attempted." },
|
|
140
|
+
steps: [{ step: "repository", outcome: "resolved", reason: null }],
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function pathExists(path: string): Promise<boolean> {
|
|
145
|
+
try {
|
|
146
|
+
await lstat(path);
|
|
147
|
+
return true;
|
|
148
|
+
} catch (error) {
|
|
149
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function registeredBranchEntries(inventory: Inventory, branch: string) {
|
|
155
|
+
return inventory.entries.filter((entry) => entry.record.branch === branch);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function resolveLandingWorktree(
|
|
159
|
+
inventory: Inventory,
|
|
160
|
+
input: LandBranchInput,
|
|
161
|
+
): Promise<string | null> {
|
|
162
|
+
if (input.worktreePath !== undefined) {
|
|
163
|
+
const path = await canonicalizePathAllowMissing(validateWorktreePathInput(input.worktreePath));
|
|
164
|
+
if (!path) throw new Error("worktreePath could not be resolved.");
|
|
165
|
+
return requireLosslessWorktreeIdentity(path, "cwd");
|
|
166
|
+
}
|
|
167
|
+
const entries = registeredBranchEntries(inventory, input.sourceBranch).filter((entry) => entry.index !== 0);
|
|
168
|
+
if (entries.length > 1) throw new Error("Source branch occupies multiple worktrees; provide an exact worktreePath.");
|
|
169
|
+
if (entries.length === 0) return null;
|
|
170
|
+
const path = entries[0].canonicalPath;
|
|
171
|
+
if (!path) throw new Error("Source worktree path could not be resolved.");
|
|
172
|
+
return requireLosslessWorktreeIdentity(path, "cwd");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function requireSafeLandingCwd(cwd: string, worktreePath: string | null): Promise<void> {
|
|
176
|
+
if (worktreePath === null) return;
|
|
177
|
+
const callerCwd = await realpath(cwd);
|
|
178
|
+
const processCwd = await realpath(process.cwd());
|
|
179
|
+
if (pathIsInsideOrEqual(callerCwd, worktreePath) || pathIsInsideOrEqual(processCwd, worktreePath)) {
|
|
180
|
+
throw new Error("The running cwd is inside worktreePath; run from the repository root.");
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function captureLocalHead(pi: GitAPI, ctx: GitCommandContext, branch: string, signal?: AbortSignal) {
|
|
185
|
+
const ref = await inspectDirectLocalBranchRef(pi, ctx, branch, signal);
|
|
186
|
+
if (ref.status === "absent") return null;
|
|
187
|
+
const head = await getLocalBranchCommit(pi, ctx, branch, signal);
|
|
188
|
+
if (ref.objectId !== head) throw new Error("Local branch is not a direct ref to its captured commit.");
|
|
189
|
+
return head;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function requireIdleWorktree(pi: GitAPI, ctx: GitCommandContext, signal?: AbortSignal): Promise<void> {
|
|
193
|
+
const state = await getGitOperationState(pi, ctx, signal);
|
|
194
|
+
if (state.active.length > 0) throw new Error("Worktree has an in-progress Git operation; it will not be touched.");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function removeLandingWorktree(pi: GitAPI, receipt: LandBranchReceipt, signal?: AbortSignal): Promise<void> {
|
|
198
|
+
const worktree = receipt.worktree;
|
|
199
|
+
const ctx = { cwd: receipt.repositoryRoot };
|
|
200
|
+
const inventory = await collectWorktreeInventory(pi, ctx, signal);
|
|
201
|
+
if (worktree.path === null) {
|
|
202
|
+
if (registeredBranchEntries(inventory, receipt.sourceBranch).some((entry) => entry.index !== 0)) {
|
|
203
|
+
throw new Error("Source branch became occupied after preflight.");
|
|
204
|
+
}
|
|
205
|
+
worktree.outcome = "absent";
|
|
206
|
+
worktree.reason = null;
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const matches = inventory.entries.filter((entry) => entry.canonicalPath === worktree.path);
|
|
210
|
+
if (matches.some((entry) => entry.index === 0) || worktree.path === receipt.repositoryRoot) {
|
|
211
|
+
throw new Error("The primary checkout / repository root can never be removed.");
|
|
212
|
+
}
|
|
213
|
+
if (!(await pathExists(worktree.path))) {
|
|
214
|
+
worktree.outcome = "absent";
|
|
215
|
+
worktree.reason = matches.length > 0 ? "Directory absent; registration retained (no prune)." : null;
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
const validated = await validateWorktreeRemovalPath(pi, ctx, worktree.path, signal);
|
|
219
|
+
if (![receipt.sourceBranch, receipt.targetBranch].includes(validated.worktree.branch ?? "")) {
|
|
220
|
+
throw new Error("The selected worktree must hold the source or target branch, not an unrelated branch.");
|
|
221
|
+
}
|
|
222
|
+
if (receipt.sourceHead === null) throw new Error("Source branch is absent; cannot prove this existing worktree is merged.");
|
|
223
|
+
const sourceHead = await captureLocalHead(pi, ctx, receipt.sourceBranch, signal);
|
|
224
|
+
if (sourceHead !== receipt.sourceHead) throw new Error("Source branch moved after the ancestry proof; no worktree removed.");
|
|
225
|
+
const worktreeHead = validated.worktree.head;
|
|
226
|
+
if (!worktreeHead || !receipt.remoteTargetHead ||
|
|
227
|
+
!(await isCommitAncestor(pi, ctx, worktreeHead, receipt.remoteTargetHead, signal))) {
|
|
228
|
+
throw new Error("Selected worktree HEAD is not merged into the fetched target.");
|
|
229
|
+
}
|
|
230
|
+
await requireIdleWorktree(pi, { cwd: worktree.path }, signal);
|
|
231
|
+
const removed = await removeWorktreeWithinQueue(pi, ctx, worktree.path, signal, true, {
|
|
232
|
+
branch: validated.worktree.branch!, head: worktreeHead,
|
|
233
|
+
});
|
|
234
|
+
if (await pathExists(worktree.path)) throw new Error("Git unregistered the worktree but its directory is still present.");
|
|
235
|
+
worktree.outcome = "removed";
|
|
236
|
+
worktree.reason = null;
|
|
237
|
+
worktree.deletedIgnoredPaths = removed.deletedIgnoredPaths;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function retireLandingBranch(pi: GitAPI, receipt: LandBranchReceipt, signal?: AbortSignal): Promise<void> {
|
|
241
|
+
const ctx = { cwd: receipt.repositoryRoot };
|
|
242
|
+
const head = await captureLocalHead(pi, ctx, receipt.sourceBranch, signal);
|
|
243
|
+
if (head === null) {
|
|
244
|
+
receipt.branch.outcome = "absent";
|
|
245
|
+
receipt.branch.reason = null;
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (receipt.worktree.outcome === "refused") throw new Error("Worktree removal was refused; source branch retained.");
|
|
249
|
+
if (head !== receipt.sourceHead || receipt.remoteTargetHead === null) {
|
|
250
|
+
throw new Error("Source branch moved after the captured expected-HEAD lease; branch retained.");
|
|
251
|
+
}
|
|
252
|
+
await retireBranchWithinQueue(pi, ctx, {
|
|
253
|
+
branchName: receipt.sourceBranch,
|
|
254
|
+
expectedHead: head,
|
|
255
|
+
targetBranch: `${receipt.remote}/${receipt.targetBranch}`,
|
|
256
|
+
force: false,
|
|
257
|
+
}, ctx.cwd, signal, "remote-tracking", receipt.remoteTargetHead);
|
|
258
|
+
receipt.branch.outcome = "deleted";
|
|
259
|
+
receipt.branch.reason = null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function requireLandingPullPolicy(pi: GitAPI, ctx: GitCommandContext, branch: string, signal?: AbortSignal): Promise<void> {
|
|
263
|
+
const args = ["config", "--get-all", `branch.${branch}.mergeOptions`];
|
|
264
|
+
const result = await runGit(pi, ctx, args, { signal, allowFailure: true });
|
|
265
|
+
if (result.code === 1) return;
|
|
266
|
+
if (result.code !== 0) throw new Error(formatGitFailure(args, result));
|
|
267
|
+
if (result.stdout.trim()) {
|
|
268
|
+
throw new Error("Target has branch-specific mergeOptions; sync refused to preserve the fixed fast-forward policy.");
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function syncLandingTarget(pi: GitAPI, receipt: LandBranchReceipt, signal?: AbortSignal): Promise<void> {
|
|
273
|
+
const ctx = { cwd: receipt.repositoryRoot };
|
|
274
|
+
const sync = receipt.targetSync;
|
|
275
|
+
sync.before = await captureLocalHead(pi, ctx, receipt.targetBranch, signal);
|
|
276
|
+
if (sync.before === null) throw new Error("Local target branch is absent; landing will not create it.");
|
|
277
|
+
const inventory = await collectWorktreeInventory(pi, ctx, signal);
|
|
278
|
+
const entries = registeredBranchEntries(inventory, receipt.targetBranch);
|
|
279
|
+
if (entries.length > 1) throw new Error("Target branch is checked out in multiple worktrees; sync refused.");
|
|
280
|
+
if (entries.length === 1) {
|
|
281
|
+
sync.worktreePath = entries[0].canonicalPath;
|
|
282
|
+
if (!sync.worktreePath) throw new Error("Target worktree path could not be resolved.");
|
|
283
|
+
requireLosslessWorktreeIdentity(sync.worktreePath, "cwd");
|
|
284
|
+
}
|
|
285
|
+
if (sync.before === receipt.remoteTargetHead) {
|
|
286
|
+
sync.mode = "noop";
|
|
287
|
+
sync.reason = null;
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (sync.worktreePath !== null) {
|
|
291
|
+
const targetCtx = { cwd: sync.worktreePath };
|
|
292
|
+
const root = await getCanonicalGitWorktreeRoot(pi, targetCtx, signal);
|
|
293
|
+
if (root !== sync.worktreePath ||
|
|
294
|
+
await getCanonicalCommonGitDirectory(pi, targetCtx, signal) !== await getCanonicalCommonGitDirectory(pi, ctx, signal) ||
|
|
295
|
+
(await getCurrentBranch(pi, targetCtx, signal)).currentBranch !== receipt.targetBranch) {
|
|
296
|
+
throw new Error("Target checkout identity changed; sync refused.");
|
|
297
|
+
}
|
|
298
|
+
const status = await getWorkingTreeStatus(pi, targetCtx, signal);
|
|
299
|
+
if (status.workingTree.state !== "clean") {
|
|
300
|
+
sync.mode = "skipped-dirty";
|
|
301
|
+
sync.reason = "Target checkout is dirty; no pull attempted.";
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
await requireIdleWorktree(pi, targetCtx, signal);
|
|
305
|
+
await requireLandingPullPolicy(pi, targetCtx, receipt.targetBranch, signal);
|
|
306
|
+
await runGit(pi, targetCtx, ["pull", "--ff-only", "--no-rebase", "--no-autostash", receipt.remote, `refs/heads/${receipt.targetBranch}`], {
|
|
307
|
+
signal, timeout: GIT_PULL_TIMEOUT_MS,
|
|
308
|
+
});
|
|
309
|
+
sync.mode = "pull-ff";
|
|
310
|
+
} else {
|
|
311
|
+
await runGit(pi, ctx, ["fetch", "--no-tags", "--no-recurse-submodules", receipt.remote, `refs/heads/${receipt.targetBranch}:refs/heads/${receipt.targetBranch}`], {
|
|
312
|
+
signal, timeout: GIT_FETCH_TIMEOUT_MS,
|
|
313
|
+
});
|
|
314
|
+
sync.mode = "fetch-refspec";
|
|
315
|
+
}
|
|
316
|
+
sync.reason = null;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function recordTargetAfter(pi: GitAPI, receipt: LandBranchReceipt): Promise<void> {
|
|
320
|
+
const ctx = { cwd: receipt.repositoryRoot };
|
|
321
|
+
const sync = receipt.targetSync;
|
|
322
|
+
sync.after = await captureLocalHead(pi, ctx, receipt.targetBranch);
|
|
323
|
+
if (sync.after !== null && receipt.remoteTargetHead !== null) {
|
|
324
|
+
sync.aheadBehind = await getAheadBehindCount(pi, ctx, undefined, `${sync.after}...${receipt.remoteTargetHead}`);
|
|
325
|
+
}
|
|
326
|
+
if (["fetch-refspec", "pull-ff"].includes(sync.mode)) {
|
|
327
|
+
if (sync.before === null || sync.after === null ||
|
|
328
|
+
!(await isCommitAncestor(pi, ctx, sync.before, sync.after)) ||
|
|
329
|
+
receipt.remoteTargetHead === null || !(await isCommitAncestor(pi, ctx, receipt.remoteTargetHead, sync.after)) ||
|
|
330
|
+
(sync.after === sync.before && sync.before !== receipt.remoteTargetHead)) {
|
|
331
|
+
throw new Error("Git did not produce a verified target fast-forward; inspect the recorded before/after refs.");
|
|
332
|
+
}
|
|
333
|
+
} else if (["noop", "skipped-dirty"].includes(sync.mode) && sync.before !== sync.after) {
|
|
334
|
+
throw new Error("Target ref moved concurrently during a read-only sync decision.");
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function finishLandingSync(pi: GitAPI, receipt: LandBranchReceipt, signal?: AbortSignal): Promise<void> {
|
|
339
|
+
try {
|
|
340
|
+
await syncLandingTarget(pi, receipt, signal);
|
|
341
|
+
} catch (error) {
|
|
342
|
+
receipt.targetSync.mode = "failed";
|
|
343
|
+
receipt.targetSync.reason = landingError(error);
|
|
344
|
+
}
|
|
345
|
+
try {
|
|
346
|
+
// Verification is deliberately uncancelled after a possible mutation.
|
|
347
|
+
await recordTargetAfter(pi, receipt);
|
|
348
|
+
} catch (error) {
|
|
349
|
+
receipt.targetSync.mode = "failed";
|
|
350
|
+
receipt.targetSync.reason = landingError(error);
|
|
351
|
+
}
|
|
352
|
+
receipt.steps.push({ step: "targetSync", outcome: receipt.targetSync.mode, reason: receipt.targetSync.reason });
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function refuseLanding(pi: GitAPI, receipt: LandBranchReceipt, error: unknown): Promise<LandBranchReceipt> {
|
|
356
|
+
const reason = landingError(error);
|
|
357
|
+
receipt.worktree.reason = reason;
|
|
358
|
+
receipt.branch.reason = reason;
|
|
359
|
+
receipt.targetSync.reason = reason;
|
|
360
|
+
try {
|
|
361
|
+
receipt.targetSync.before ??= await captureLocalHead(pi, { cwd: receipt.repositoryRoot }, receipt.targetBranch);
|
|
362
|
+
await recordTargetAfter(pi, receipt);
|
|
363
|
+
} catch (verificationError) {
|
|
364
|
+
receipt.targetSync.reason = `${reason} Verification: ${landingError(verificationError)}`;
|
|
365
|
+
}
|
|
366
|
+
const recorded = new Set(receipt.steps.map((step) => step.step));
|
|
367
|
+
for (const step of ["cwd", "fetch", "ancestry", "worktree", "branch", "targetSync"] as const) {
|
|
368
|
+
if (!recorded.has(step)) receipt.steps.push({ step, outcome: "not-run", reason });
|
|
369
|
+
}
|
|
370
|
+
return receipt;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async function landBranchWithinQueue(
|
|
374
|
+
pi: GitAPI,
|
|
375
|
+
cwd: string,
|
|
376
|
+
root: string,
|
|
377
|
+
input: LandBranchInput,
|
|
378
|
+
signal?: AbortSignal,
|
|
379
|
+
): Promise<LandBranchReceipt> {
|
|
380
|
+
const ctx = { cwd: root };
|
|
381
|
+
const receipt = newLandingReceipt(root, input);
|
|
382
|
+
let step: "cwd" | "fetch" | "ancestry" = "cwd";
|
|
383
|
+
try {
|
|
384
|
+
const inventory = await collectWorktreeInventory(pi, ctx, signal);
|
|
385
|
+
receipt.worktree.path = await resolveLandingWorktree(inventory, input);
|
|
386
|
+
await requireSafeLandingCwd(cwd, receipt.worktree.path);
|
|
387
|
+
receipt.steps.push({ step: "cwd", outcome: "safe", reason: null });
|
|
388
|
+
step = "fetch";
|
|
389
|
+
await validateBranchName(pi, ctx, input.sourceBranch, signal);
|
|
390
|
+
await validateBranchName(pi, ctx, input.targetBranch, signal);
|
|
391
|
+
receipt.sourceHead = await captureLocalHead(pi, ctx, input.sourceBranch, signal);
|
|
392
|
+
receipt.branch.expectedHead = receipt.sourceHead;
|
|
393
|
+
receipt.targetSync.before = await captureLocalHead(pi, ctx, input.targetBranch, signal);
|
|
394
|
+
if (receipt.targetSync.before === null) throw new Error("Local target branch does not exist.");
|
|
395
|
+
await fetchRemoteBranchWithinQueue(pi, ctx, receipt.remote, receipt.targetBranch, signal);
|
|
396
|
+
const tracking = `${receipt.remote}/${receipt.targetBranch}`;
|
|
397
|
+
const ref = await inspectDirectRemoteTrackingRef(pi, ctx, tracking, signal);
|
|
398
|
+
receipt.remoteTargetHead = await getRemoteTrackingRefCommit(pi, ctx, tracking, signal);
|
|
399
|
+
if (ref.status !== "present" || ref.objectId !== receipt.remoteTargetHead) {
|
|
400
|
+
throw new Error("Fetched target is not a direct ref to its captured commit.");
|
|
401
|
+
}
|
|
402
|
+
receipt.steps.push({ step: "fetch", outcome: "fetched", reason: null });
|
|
403
|
+
step = "ancestry";
|
|
404
|
+
if (receipt.sourceHead !== null) {
|
|
405
|
+
receipt.ancestry.isAncestor = await isCommitAncestor(pi, ctx, receipt.sourceHead, receipt.remoteTargetHead, signal);
|
|
406
|
+
if (!receipt.ancestry.isAncestor) throw new Error("Source branch is not merged into the fetched remote target; landing refused.");
|
|
407
|
+
}
|
|
408
|
+
receipt.steps.push({ step: "ancestry", outcome: receipt.sourceHead === null ? "absent" : "verified", reason: null });
|
|
409
|
+
} catch (error) {
|
|
410
|
+
receipt.steps.push({ step, outcome: "refused", reason: landingError(error) });
|
|
411
|
+
return refuseLanding(pi, receipt, error);
|
|
412
|
+
}
|
|
413
|
+
try {
|
|
414
|
+
await removeLandingWorktree(pi, receipt, signal);
|
|
415
|
+
} catch (error) {
|
|
416
|
+
receipt.worktree.reason = landingError(error);
|
|
417
|
+
}
|
|
418
|
+
receipt.steps.push({ step: "worktree", outcome: receipt.worktree.outcome, reason: receipt.worktree.reason });
|
|
419
|
+
try {
|
|
420
|
+
await retireLandingBranch(pi, receipt, signal);
|
|
421
|
+
} catch (error) {
|
|
422
|
+
receipt.branch.reason = landingError(error);
|
|
423
|
+
}
|
|
424
|
+
receipt.steps.push({ step: "branch", outcome: receipt.branch.outcome, reason: receipt.branch.reason });
|
|
425
|
+
await finishLandingSync(pi, receipt, signal);
|
|
426
|
+
return receipt;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
export async function landBranch(pi: GitAPI, ctx: GitCommandContext, input: LandBranchInput, signal?: AbortSignal): Promise<LandBranchReceipt> {
|
|
430
|
+
validateLandingInput(input);
|
|
431
|
+
const routedPi: GitAPI = { exec: executeLandingGit.bind(undefined, pi, input.remote ?? "origin") };
|
|
432
|
+
const callerRoot = await getCanonicalGitWorktreeRoot(routedPi, ctx, signal);
|
|
433
|
+
const inventory = await collectWorktreeInventory(routedPi, { cwd: callerRoot }, signal);
|
|
434
|
+
const primary = inventory.entries[0];
|
|
435
|
+
if (!primary?.canonicalPath || primary.record.bare) throw new Error("Landing requires a primary non-bare checkout.");
|
|
436
|
+
const root = await getCanonicalGitWorktreeRoot(routedPi, { cwd: primary.canonicalPath }, signal);
|
|
437
|
+
requireLosslessWorktreeIdentity(root, "cwd");
|
|
438
|
+
if (root !== primary.canonicalPath ||
|
|
439
|
+
await getCanonicalCommonGitDirectory(routedPi, { cwd: root }, signal) !== await getCanonicalCommonGitDirectory(routedPi, { cwd: callerRoot }, signal)) {
|
|
440
|
+
throw new Error("Primary checkout repository identity could not be verified.");
|
|
441
|
+
}
|
|
442
|
+
return withRepositoryMutationQueue(root, landBranchWithinQueue.bind(undefined, routedPi, ctx.cwd, root, input, signal));
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
export function formatLandBranch(receipt: LandBranchReceipt): string {
|
|
446
|
+
const summary = `land_branch: worktree ${receipt.worktree.outcome}; branch ${receipt.branch.outcome}; target sync ${receipt.targetSync.mode}.`;
|
|
447
|
+
const reason = receipt.worktree.reason ?? receipt.branch.reason ?? receipt.targetSync.reason;
|
|
448
|
+
return reason ? `${summary} ${landingError(reason)}` : summary;
|
|
449
|
+
}
|
package/src/git-retirement.ts
CHANGED
|
@@ -10,7 +10,9 @@ import {
|
|
|
10
10
|
getCanonicalCommonGitDirectory,
|
|
11
11
|
getCanonicalGitWorktreeRoot,
|
|
12
12
|
getLocalBranchCommit,
|
|
13
|
+
getRemoteTrackingRefCommit,
|
|
13
14
|
inspectDirectLocalBranchRef,
|
|
15
|
+
inspectDirectRemoteTrackingRef,
|
|
14
16
|
inspectLocalBranchWorktreeOccupancy,
|
|
15
17
|
isCommitAncestor,
|
|
16
18
|
isLosslessGitMetadata,
|
|
@@ -36,6 +38,8 @@ import type {
|
|
|
36
38
|
const FULL_OBJECT_ID_PATTERN = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/iu;
|
|
37
39
|
const RETIREMENT_REQUEST_FIELDS = new Set(["branchName", "expectedHead", "targetBranch", "force"]);
|
|
38
40
|
|
|
41
|
+
type RetirementTargetScope = "local" | "remote-tracking";
|
|
42
|
+
|
|
39
43
|
export interface PreparedBranchRetirement {
|
|
40
44
|
worktreeRoot: string;
|
|
41
45
|
canonicalCommonGitDirectory: string;
|
|
@@ -62,7 +66,7 @@ function normalizeExpectedHead(value: unknown): string {
|
|
|
62
66
|
return value.toLowerCase();
|
|
63
67
|
}
|
|
64
68
|
|
|
65
|
-
function validateRetirementRequest(request: unknown): RetireBranchToolInput {
|
|
69
|
+
function validateRetirementRequest(request: unknown, targetScope: RetirementTargetScope = "local"): RetireBranchToolInput {
|
|
66
70
|
if (typeof request !== "object" || request === null || Array.isArray(request)) {
|
|
67
71
|
throw new TypeError("Branch retirement request must be an object.");
|
|
68
72
|
}
|
|
@@ -78,7 +82,7 @@ function validateRetirementRequest(request: unknown): RetireBranchToolInput {
|
|
|
78
82
|
|
|
79
83
|
validateRetirementBranchName(values.branchName, "Retiring branch");
|
|
80
84
|
validateRetirementBranchName(values.targetBranch, "Target branch");
|
|
81
|
-
if (values.branchName === values.targetBranch) {
|
|
85
|
+
if (targetScope === "local" && values.branchName === values.targetBranch) {
|
|
82
86
|
throw new Error("Retiring branch and target branch must be distinct local branches.");
|
|
83
87
|
}
|
|
84
88
|
const expectedHead = normalizeExpectedHead(values.expectedHead);
|
|
@@ -164,13 +168,26 @@ function retirementRefIdentity(
|
|
|
164
168
|
};
|
|
165
169
|
}
|
|
166
170
|
|
|
171
|
+
function retirementTargetScope(prepared: PreparedBranchRetirement): RetirementTargetScope {
|
|
172
|
+
return prepared.target.fullRef.startsWith("refs/remotes/") ? "remote-tracking" : "local";
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function targetRefInspector(scope: RetirementTargetScope) {
|
|
176
|
+
return scope === "local" ? inspectDirectLocalBranchRef : inspectDirectRemoteTrackingRef;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function targetCommitResolver(scope: RetirementTargetScope) {
|
|
180
|
+
return scope === "local" ? getLocalBranchCommit : getRemoteTrackingRefCommit;
|
|
181
|
+
}
|
|
182
|
+
|
|
167
183
|
export async function prepareBranchRetirement(
|
|
168
184
|
pi: Pick<ExtensionAPI, "exec">,
|
|
169
185
|
ctx: GitCommandContext,
|
|
170
186
|
requestValue: unknown,
|
|
171
187
|
signal?: AbortSignal,
|
|
188
|
+
targetScope: RetirementTargetScope = "local",
|
|
172
189
|
): Promise<PreparedBranchRetirement> {
|
|
173
|
-
const request = validateRetirementRequest(requestValue);
|
|
190
|
+
const request = validateRetirementRequest(requestValue, targetScope);
|
|
174
191
|
const worktreeRoot = await getCanonicalGitWorktreeRoot(pi, ctx, signal);
|
|
175
192
|
const rootCtx = { cwd: worktreeRoot };
|
|
176
193
|
const canonicalCommonGitDirectory = await getCanonicalCommonGitDirectory(pi, rootCtx, signal);
|
|
@@ -182,11 +199,11 @@ export async function prepareBranchRetirement(
|
|
|
182
199
|
"Retiring",
|
|
183
200
|
);
|
|
184
201
|
const targetRef = requirePresentDirectRef(
|
|
185
|
-
await
|
|
202
|
+
await targetRefInspector(targetScope)(pi, rootCtx, request.targetBranch, signal),
|
|
186
203
|
"Target",
|
|
187
204
|
);
|
|
188
205
|
const retiringHead = await getLocalBranchCommit(pi, rootCtx, request.branchName, signal);
|
|
189
|
-
const targetHead = await
|
|
206
|
+
const targetHead = await targetCommitResolver(targetScope)(pi, rootCtx, request.targetBranch, signal);
|
|
190
207
|
requireDirectRefCommitIdentity(retiringRef, retiringHead, "Retiring");
|
|
191
208
|
requireDirectRefCommitIdentity(targetRef, targetHead, "Target");
|
|
192
209
|
if (!sameObjectIdentity(retiringHead, request.expectedHead)) {
|
|
@@ -328,7 +345,7 @@ async function inspectImmediateRetirementState(
|
|
|
328
345
|
"Retiring",
|
|
329
346
|
);
|
|
330
347
|
const targetRef = requirePresentDirectRef(
|
|
331
|
-
await
|
|
348
|
+
await targetRefInspector(retirementTargetScope(prepared))(pi, rootCtx, prepared.request.targetBranch, signal),
|
|
332
349
|
"Target",
|
|
333
350
|
);
|
|
334
351
|
const retiringHead = await getLocalBranchCommit(
|
|
@@ -337,7 +354,7 @@ async function inspectImmediateRetirementState(
|
|
|
337
354
|
prepared.request.branchName,
|
|
338
355
|
signal,
|
|
339
356
|
);
|
|
340
|
-
const targetHead = await
|
|
357
|
+
const targetHead = await targetCommitResolver(retirementTargetScope(prepared))(
|
|
341
358
|
pi,
|
|
342
359
|
rootCtx,
|
|
343
360
|
prepared.request.targetBranch,
|
|
@@ -445,7 +462,7 @@ async function inspectPostRetirementState(
|
|
|
445
462
|
rootCtx,
|
|
446
463
|
prepared.request.branchName,
|
|
447
464
|
);
|
|
448
|
-
const targetRef = await
|
|
465
|
+
const targetRef = await targetRefInspector(retirementTargetScope(prepared))(
|
|
449
466
|
pi,
|
|
450
467
|
rootCtx,
|
|
451
468
|
prepared.request.targetBranch,
|
|
@@ -458,7 +475,7 @@ async function inspectPostRetirementState(
|
|
|
458
475
|
}
|
|
459
476
|
let targetHead: string | null = null;
|
|
460
477
|
if (targetRef.status === "present") {
|
|
461
|
-
targetHead = await
|
|
478
|
+
targetHead = await targetCommitResolver(retirementTargetScope(prepared))(pi, rootCtx, prepared.request.targetBranch);
|
|
462
479
|
requireDirectRefCommitIdentity(targetRef, targetHead, "Target");
|
|
463
480
|
targetHead = targetHead.toLowerCase();
|
|
464
481
|
}
|
|
@@ -610,14 +627,20 @@ function classifyRetirementOutcome(
|
|
|
610
627
|
);
|
|
611
628
|
}
|
|
612
629
|
|
|
613
|
-
|
|
630
|
+
// Caller must hold the repository mutation queue; remote targets are used only by land_branch.
|
|
631
|
+
export async function retireBranchWithinQueue(
|
|
614
632
|
pi: Pick<ExtensionAPI, "exec">,
|
|
615
633
|
ctx: GitCommandContext,
|
|
616
634
|
request: RetireBranchToolInput,
|
|
617
635
|
queuedWorktreeRoot: string,
|
|
618
636
|
signal?: AbortSignal,
|
|
637
|
+
targetScope: RetirementTargetScope = "local",
|
|
638
|
+
expectedTargetHead?: string,
|
|
619
639
|
): Promise<RetireBranchDetails> {
|
|
620
|
-
const prepared = await prepareBranchRetirement(pi, ctx, request, signal);
|
|
640
|
+
const prepared = await prepareBranchRetirement(pi, ctx, request, signal, targetScope);
|
|
641
|
+
if (expectedTargetHead !== undefined && prepared.target.head !== expectedTargetHead) {
|
|
642
|
+
throw new Error("The fetched retirement target moved after the landing ancestry proof.");
|
|
643
|
+
}
|
|
621
644
|
if (prepared.worktreeRoot !== queuedWorktreeRoot) {
|
|
622
645
|
throw new Error("The active worktree changed while preparing branch retirement.");
|
|
623
646
|
}
|
package/src/git.ts
CHANGED
|
@@ -741,7 +741,7 @@ export function validateWorktreePathInput(worktreePath: unknown): string {
|
|
|
741
741
|
return normalizedPath;
|
|
742
742
|
}
|
|
743
743
|
|
|
744
|
-
async function canonicalizePathAllowMissing(path: string): Promise<string | null> {
|
|
744
|
+
export async function canonicalizePathAllowMissing(path: string): Promise<string | null> {
|
|
745
745
|
let candidate = normalize(path);
|
|
746
746
|
const suffix: string[] = [];
|
|
747
747
|
|
|
@@ -759,7 +759,7 @@ async function canonicalizePathAllowMissing(path: string): Promise<string | null
|
|
|
759
759
|
}
|
|
760
760
|
}
|
|
761
761
|
|
|
762
|
-
async function collectWorktreeInventory(
|
|
762
|
+
export async function collectWorktreeInventory(
|
|
763
763
|
pi: Pick<ExtensionAPI, "exec">,
|
|
764
764
|
ctx: GitCommandContext,
|
|
765
765
|
signal?: AbortSignal,
|
|
@@ -783,7 +783,7 @@ async function collectWorktreeInventory(
|
|
|
783
783
|
return { repoRoot, canonicalCurrentPath, entries };
|
|
784
784
|
}
|
|
785
785
|
|
|
786
|
-
function pathIsInsideOrEqual(candidatePath: string, boundaryPath: string): boolean {
|
|
786
|
+
export function pathIsInsideOrEqual(candidatePath: string, boundaryPath: string): boolean {
|
|
787
787
|
const relation = relative(boundaryPath, candidatePath);
|
|
788
788
|
return relation === "" || (relation !== ".." && !relation.startsWith(`..${sep}`) && !isAbsolute(relation));
|
|
789
789
|
}
|
|
@@ -1399,7 +1399,11 @@ async function requirePresentWorktreeDirectory(canonicalPath: string): Promise<v
|
|
|
1399
1399
|
|
|
1400
1400
|
interface WorktreeRemovalStatus {
|
|
1401
1401
|
workingTree: WorkingTreeDetails;
|
|
1402
|
-
|
|
1402
|
+
ignoredPaths: string[];
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
function compareIgnoredPaths(left: string, right: string): number {
|
|
1406
|
+
return left.localeCompare(right);
|
|
1403
1407
|
}
|
|
1404
1408
|
|
|
1405
1409
|
function parseWorktreeRemovalStatus(output: string): WorktreeRemovalStatus {
|
|
@@ -1408,7 +1412,7 @@ function parseWorktreeRemovalStatus(output: string): WorktreeRemovalStatus {
|
|
|
1408
1412
|
}
|
|
1409
1413
|
|
|
1410
1414
|
const records = output.split(NUL_SEPARATOR);
|
|
1411
|
-
|
|
1415
|
+
const ignoredPaths = new Set<string>();
|
|
1412
1416
|
let skipNextRecord = false;
|
|
1413
1417
|
for (const [index, record] of records.entries()) {
|
|
1414
1418
|
if (skipNextRecord) {
|
|
@@ -1419,12 +1423,12 @@ function parseWorktreeRemovalStatus(output: string): WorktreeRemovalStatus {
|
|
|
1419
1423
|
|
|
1420
1424
|
const parsed = parsePorcelainChange(records, index);
|
|
1421
1425
|
skipNextRecord = parsed.nextIndex > index;
|
|
1422
|
-
if (parsed.status === "!!")
|
|
1426
|
+
if (parsed.status === "!!") ignoredPaths.add(parsed.path.split("/")[0]);
|
|
1423
1427
|
}
|
|
1424
1428
|
|
|
1425
1429
|
return {
|
|
1426
1430
|
workingTree: parseWorkingTreeStatus(output).workingTree,
|
|
1427
|
-
|
|
1431
|
+
ignoredPaths: [...ignoredPaths].sort(compareIgnoredPaths),
|
|
1428
1432
|
};
|
|
1429
1433
|
}
|
|
1430
1434
|
|
|
@@ -1432,7 +1436,8 @@ async function inspectRemovableWorktree(
|
|
|
1432
1436
|
pi: Pick<ExtensionAPI, "exec">,
|
|
1433
1437
|
canonicalPath: string,
|
|
1434
1438
|
signal?: AbortSignal,
|
|
1435
|
-
|
|
1439
|
+
allowIgnored = false,
|
|
1440
|
+
): Promise<WorktreeRemovalStatus> {
|
|
1436
1441
|
const workingTree = await inspectWorktreeState(pi, canonicalPath, signal);
|
|
1437
1442
|
if (workingTree.state !== "clean") {
|
|
1438
1443
|
throw new Error("The target worktree has staged, unstaged, untracked, or unmerged changes; clean it before removal.");
|
|
@@ -1446,12 +1451,12 @@ async function inspectRemovableWorktree(
|
|
|
1446
1451
|
if (removalStatus.workingTree.state !== "clean") {
|
|
1447
1452
|
throw new Error("The target worktree has staged, unstaged, untracked, or unmerged changes; clean it before removal.");
|
|
1448
1453
|
}
|
|
1449
|
-
if (removalStatus.
|
|
1454
|
+
if (!allowIgnored && removalStatus.ignoredPaths.length > 0) {
|
|
1450
1455
|
throw new Error(
|
|
1451
1456
|
"The target worktree contains ignored files or directories; remove or preserve them outside the checkout before removal.",
|
|
1452
1457
|
);
|
|
1453
1458
|
}
|
|
1454
|
-
return
|
|
1459
|
+
return removalStatus;
|
|
1455
1460
|
}
|
|
1456
1461
|
|
|
1457
1462
|
function assertWorktreeRemoved(
|
|
@@ -1487,7 +1492,22 @@ export async function removeWorktree(
|
|
|
1487
1492
|
const repoRoot = await getGitRoot(pi, ctx, signal);
|
|
1488
1493
|
const rootCtx = { cwd: repoRoot };
|
|
1489
1494
|
|
|
1490
|
-
return withRepositoryMutationQueue(
|
|
1495
|
+
return withRepositoryMutationQueue(
|
|
1496
|
+
repoRoot,
|
|
1497
|
+
removeWorktreeWithinQueue.bind(undefined, pi, rootCtx, requestedWorktreePath, signal),
|
|
1498
|
+
);
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
// Caller must hold the repository mutation queue. Standalone removal still refuses ignored residue.
|
|
1502
|
+
export async function removeWorktreeWithinQueue(
|
|
1503
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
1504
|
+
rootCtx: GitCommandContext,
|
|
1505
|
+
requestedWorktreePath: string,
|
|
1506
|
+
signal?: AbortSignal,
|
|
1507
|
+
allowIgnored = false,
|
|
1508
|
+
expectedIdentity?: { branch: string; head: string },
|
|
1509
|
+
): Promise<RemoveWorktreeDetails & { deletedIgnoredPaths: string[] }> {
|
|
1510
|
+
const repoRoot = rootCtx.cwd;
|
|
1491
1511
|
const resolved = await resolveWorktreeRemovalTarget(
|
|
1492
1512
|
pi,
|
|
1493
1513
|
rootCtx,
|
|
@@ -1499,6 +1519,9 @@ export async function removeWorktree(
|
|
|
1499
1519
|
}
|
|
1500
1520
|
|
|
1501
1521
|
const worktree = requireRemovableWorktreeEntry(resolved);
|
|
1522
|
+
if (expectedIdentity && (worktree.branch !== expectedIdentity.branch || worktree.head !== expectedIdentity.head)) {
|
|
1523
|
+
throw new Error("The selected worktree branch or HEAD moved after the landing ancestry proof.");
|
|
1524
|
+
}
|
|
1502
1525
|
const branchName = resolved.entry.record.branch;
|
|
1503
1526
|
const head = resolved.entry.record.head;
|
|
1504
1527
|
if (branchName === null || head === null) {
|
|
@@ -1507,7 +1530,7 @@ export async function removeWorktree(
|
|
|
1507
1530
|
const verifiedCanonicalPath = requireLosslessWorktreeIdentity(resolved.canonicalPath, "cwd");
|
|
1508
1531
|
const retainedBranch = requireLosslessWorktreeIdentity(branchName, "branch");
|
|
1509
1532
|
await requirePresentWorktreeDirectory(verifiedCanonicalPath);
|
|
1510
|
-
const workingTree = await inspectRemovableWorktree(pi, verifiedCanonicalPath, signal);
|
|
1533
|
+
const { workingTree, ignoredPaths } = await inspectRemovableWorktree(pi, verifiedCanonicalPath, signal, allowIgnored);
|
|
1511
1534
|
const branchHeadBefore = await getLocalBranchCommit(pi, rootCtx, retainedBranch, signal);
|
|
1512
1535
|
if (branchHeadBefore.toLowerCase() !== head.toLowerCase()) {
|
|
1513
1536
|
throw new Error("The target local branch did not match the worktree HEAD before removal.");
|
|
@@ -1540,6 +1563,7 @@ export async function removeWorktree(
|
|
|
1540
1563
|
);
|
|
1541
1564
|
return {
|
|
1542
1565
|
action: "remove_worktree",
|
|
1566
|
+
deletedIgnoredPaths: ignoredPaths.map((path) => redactSecrets(path)),
|
|
1543
1567
|
repoRoot: safeWorktreeValue(repoRoot, GIT_WORKTREE_PATH_LIMIT_CHARS),
|
|
1544
1568
|
request: {
|
|
1545
1569
|
worktreePath: safeWorktreeValue(requestedWorktreePath, GIT_WORKTREE_PATH_LIMIT_CHARS),
|
|
@@ -1567,7 +1591,6 @@ export async function removeWorktree(
|
|
|
1567
1591
|
} catch (error) {
|
|
1568
1592
|
throw worktreeRemovalFailure(error, resolved.canonicalPath, branchName);
|
|
1569
1593
|
}
|
|
1570
|
-
});
|
|
1571
1594
|
}
|
|
1572
1595
|
|
|
1573
1596
|
function isRenameOrCopyStatus(status: string): boolean {
|
|
@@ -1880,8 +1903,9 @@ export async function getAheadBehindCount(
|
|
|
1880
1903
|
pi: Pick<ExtensionAPI, "exec">,
|
|
1881
1904
|
ctx: GitCommandContext,
|
|
1882
1905
|
signal?: AbortSignal,
|
|
1906
|
+
comparison = "HEAD...@{u}",
|
|
1883
1907
|
): Promise<AheadBehindCount> {
|
|
1884
|
-
const result = await runGit(pi, ctx, ["rev-list", "--left-right", "--count",
|
|
1908
|
+
const result = await runGit(pi, ctx, ["rev-list", "--left-right", "--count", comparison], {
|
|
1885
1909
|
signal,
|
|
1886
1910
|
timeout: GIT_STATUS_TIMEOUT_MS,
|
|
1887
1911
|
});
|
|
@@ -1934,9 +1958,9 @@ export async function localBranchExists(
|
|
|
1934
1958
|
return result.code === 0;
|
|
1935
1959
|
}
|
|
1936
1960
|
|
|
1937
|
-
function requireLosslessLocalRef(branchName: string): string {
|
|
1961
|
+
function requireLosslessLocalRef(branchName: string, prefix = "refs/heads/"): string {
|
|
1938
1962
|
requireLosslessWorktreeIdentity(branchName, "branch");
|
|
1939
|
-
const fullRef =
|
|
1963
|
+
const fullRef = `${prefix}${branchName}`;
|
|
1940
1964
|
if (!isLosslessGitMetadata(fullRef, GIT_CONTEXT_VALUE_LIMIT_CHARS)) {
|
|
1941
1965
|
throw new Error(
|
|
1942
1966
|
"The full local branch ref cannot be returned safely and losslessly. " +
|
|
@@ -2014,9 +2038,28 @@ export async function inspectDirectLocalBranchRef(
|
|
|
2014
2038
|
ctx: GitCommandContext,
|
|
2015
2039
|
branchName: string,
|
|
2016
2040
|
signal?: AbortSignal,
|
|
2041
|
+
): Promise<DirectLocalBranchRefInspection> {
|
|
2042
|
+
return inspectDirectBranchRef(pi, ctx, branchName, "refs/heads/", signal);
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
export async function inspectDirectRemoteTrackingRef(
|
|
2046
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
2047
|
+
ctx: GitCommandContext,
|
|
2048
|
+
refName: string,
|
|
2049
|
+
signal?: AbortSignal,
|
|
2050
|
+
): Promise<DirectLocalBranchRefInspection> {
|
|
2051
|
+
return inspectDirectBranchRef(pi, ctx, refName, "refs/remotes/", signal);
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
async function inspectDirectBranchRef(
|
|
2055
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
2056
|
+
ctx: GitCommandContext,
|
|
2057
|
+
branchName: string,
|
|
2058
|
+
prefix: string,
|
|
2059
|
+
signal?: AbortSignal,
|
|
2017
2060
|
): Promise<DirectLocalBranchRefInspection> {
|
|
2018
2061
|
validateBranchNameInput(branchName);
|
|
2019
|
-
const fullRef = requireLosslessLocalRef(branchName);
|
|
2062
|
+
const fullRef = requireLosslessLocalRef(branchName, prefix);
|
|
2020
2063
|
const listArgs = [
|
|
2021
2064
|
"for-each-ref",
|
|
2022
2065
|
"--count=1",
|
|
@@ -2317,7 +2360,22 @@ export async function fetchRemoteBranch(
|
|
|
2317
2360
|
const repoRoot = await getGitRoot(pi, ctx, signal);
|
|
2318
2361
|
const rootCtx = { cwd: repoRoot };
|
|
2319
2362
|
|
|
2320
|
-
return withRepositoryMutationQueue(
|
|
2363
|
+
return withRepositoryMutationQueue(
|
|
2364
|
+
repoRoot,
|
|
2365
|
+
fetchRemoteBranchWithinQueue.bind(undefined, pi, rootCtx, remote, branch, signal),
|
|
2366
|
+
);
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
// Caller must hold the repository mutation queue.
|
|
2370
|
+
export async function fetchRemoteBranchWithinQueue(
|
|
2371
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
2372
|
+
rootCtx: GitCommandContext,
|
|
2373
|
+
remote: string,
|
|
2374
|
+
branch: string,
|
|
2375
|
+
signal?: AbortSignal,
|
|
2376
|
+
): Promise<FetchRemoteBranchDetails> {
|
|
2377
|
+
const repoRoot = rootCtx.cwd;
|
|
2378
|
+
validateRequestedRemoteName(remote);
|
|
2321
2379
|
await validateBranchName(pi, rootCtx, branch, signal);
|
|
2322
2380
|
await requireConfiguredRemote(pi, rootCtx, remote, signal);
|
|
2323
2381
|
|
|
@@ -2343,7 +2401,6 @@ export async function fetchRemoteBranch(
|
|
|
2343
2401
|
refspec: safeDetail(refspec),
|
|
2344
2402
|
output: safeOutput(result.stdout || result.stderr),
|
|
2345
2403
|
};
|
|
2346
|
-
});
|
|
2347
2404
|
}
|
|
2348
2405
|
|
|
2349
2406
|
export async function pullCurrentBranch(
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
GIT_RETIREMENT_SUMMARY_LIMIT_CHARS,
|
|
12
12
|
GIT_WORKTREE_SUMMARY_LIMIT_CHARS,
|
|
13
13
|
INTEGRATE_BRANCH_TOOL_NAME,
|
|
14
|
+
LAND_BRANCH_TOOL_NAME,
|
|
14
15
|
LIST_WORKTREES_TOOL_NAME,
|
|
15
16
|
PULL_BRANCH_TOOL_NAME,
|
|
16
17
|
PULL_REQUEST_TOOL_NAME,
|
|
@@ -42,6 +43,7 @@ import {
|
|
|
42
43
|
import { collectGitContext, formatGitContext } from "../git-context.ts";
|
|
43
44
|
import { integrateBranch } from "../git-integration.ts";
|
|
44
45
|
import { retireBranch } from "../git-retirement.ts";
|
|
46
|
+
import { formatLandBranch, landBranch } from "../git-landing.ts";
|
|
45
47
|
import {
|
|
46
48
|
createGitHubPullRequest,
|
|
47
49
|
ensureGitHubBranchExists,
|
|
@@ -106,6 +108,16 @@ const RetireBranchParametersSchema = Type.Object(
|
|
|
106
108
|
{ additionalProperties: false },
|
|
107
109
|
);
|
|
108
110
|
|
|
111
|
+
const LandBranchParametersSchema = Type.Object(
|
|
112
|
+
{
|
|
113
|
+
sourceBranch: Type.String({ minLength: 1, description: "Merged local feature branch to delete, or its name on an idempotent retry." }),
|
|
114
|
+
targetBranch: Type.String({ minLength: 1, description: "Local default branch to fast-forward after cleanup." }),
|
|
115
|
+
remote: Type.Optional(Type.String({ minLength: 1, description: "Configured remote name; defaults to origin." })),
|
|
116
|
+
worktreePath: Type.Optional(Type.String({ minLength: 1, description: "Absolute linked-worktree path to remove, including one parked on targetBranch. Omit to find the source branch's worktree." })),
|
|
117
|
+
},
|
|
118
|
+
{ additionalProperties: false },
|
|
119
|
+
);
|
|
120
|
+
|
|
109
121
|
const CreateBranchParametersSchema = Type.Object(
|
|
110
122
|
{
|
|
111
123
|
branchName: Type.String({ minLength: 1, description: "Name of the new branch to create from current HEAD." }),
|
|
@@ -703,6 +715,28 @@ export function registerBranchMeTools(pi: Pick<ExtensionAPI, "registerTool" | "e
|
|
|
703
715
|
},
|
|
704
716
|
});
|
|
705
717
|
|
|
718
|
+
pi.registerTool({
|
|
719
|
+
name: LAND_BRANCH_TOOL_NAME,
|
|
720
|
+
label: "Land Branch",
|
|
721
|
+
description: "land_branch performs deterministic post-merge cleanup: fetch the remote target, prove source ancestry, remove its clean linked worktree (including ignored residue), lease-delete the local source branch, then fast-forward the local target without switching branches. Returns a structured per-step receipt and one-line summary. No remote mutation, force, stash, reset, checkout, or prune; retries are idempotent. Diagnostics are redacted and bounded to 4000 characters each.",
|
|
722
|
+
promptSnippet: "land_branch: post-merge linked-worktree cleanup, leased source retirement, and independent fast-forward target sync with verified receipts",
|
|
723
|
+
promptGuidelines: [
|
|
724
|
+
"Use land_branch after the pull request merged on the host; the source tip must be an ancestor of the fetched remote target (squash/rebase merges may not satisfy this).",
|
|
725
|
+
"land_branch is cwd-independent; run from the repository root, never inside the worktree being removed.",
|
|
726
|
+
"land_branch deletes ignored files such as .env, .pi/, node_modules/, and dist/ with the worktree and lists their top-level entries in deletedIgnoredPaths; preserve anything needed beforehand.",
|
|
727
|
+
"land_branch target sync never touches a dirty checkout; skipped-dirty reports its path and ahead/behind counts, independently of cleanup outcomes.",
|
|
728
|
+
"Call land_branch by itself and inspect each receipt outcome; do not batch it with other Git mutations. It never switches branches or mutates remote branches.",
|
|
729
|
+
],
|
|
730
|
+
parameters: LandBranchParametersSchema,
|
|
731
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
732
|
+
const details = await landBranch(pi, ctx, params, signal);
|
|
733
|
+
return {
|
|
734
|
+
content: [{ type: "text", text: formatLandBranch(details) }],
|
|
735
|
+
details,
|
|
736
|
+
};
|
|
737
|
+
},
|
|
738
|
+
});
|
|
739
|
+
|
|
706
740
|
pi.registerTool({
|
|
707
741
|
name: PUSH_BRANCH_TOOL_NAME,
|
|
708
742
|
label: "Push Branch",
|