@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 CHANGED
@@ -1,12 +1,16 @@
1
1
  # Changelog
2
2
 
3
- ## 0.2.2 - Unreleased
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 thirteen strict BranchMe tools: `branch_status`, `change_branch`, `fetch_branch`, `pull_branch`, `rebase_branch`, `integrate_branch`, `retire_branch`, `create_branch`, `push_branch`, `pull_request`, `list_worktrees`, `create_worktree`, and `remove_worktree`; merge-continuation tools are intentionally absent.
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 thirteen 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.
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
- BranchMe does not change the active Pi process's cwd, start Pi or other processes, create sessions, copy `.env` or other ignored/untracked files, or automatically retire a branch when removing its worktree. For credentials needed by agents in linked worktrees, prefer process-level environment variables rather than copying repository-root secrets.
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 worktree removal. 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` under the leased boundary above.
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 thirteen 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 registration and schema but never executes branch retirement. 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).
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 removal. 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
+ 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` is the only local branch-deletion surface; it has no bulk, pattern, inferred-target, remote-delete, remote-tracking-delete, automatic worktree-removal, or rollback-ref control.
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
- - Branch and PR tools never accept filesystem paths, `owner`, `repo`, or owner-prefixed `owner:branch` PR refs. Worktree mutations accept only an explicitly approved absolute `worktreePath`, with `branchName` and `branchMode` additionally required for creation.
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 removal until they are removed or preserved outside the checkout. Git documents support for multiple worktrees of a superproject containing submodules as incomplete; BranchMe does not add force-based submodule cleanup.
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.2.0` package.
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: thirteen strict agent-callable tools.
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, thirteen tools, and automatic context hook.
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.
@@ -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 thirteen tools—`branch_status`, `change_branch`, `create_branch`, `create_worktree`, `fetch_branch`, `integrate_branch`, `list_worktrees`, `pull_branch`, `pull_request`, `push_branch`, `rebase_branch`, `remove_worktree`, and `retire_branch`—are each registered exactly once and active with strict schemas, named prompt guidelines, descriptions, and extension source metadata. It also proves `continue_merge` and `abort_merge` are absent.
26
+ - The temporary command verifier calls `pi.getAllTools()` after BranchMe loads and confirms exactly 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 thirteen tools but forbids `retire_branch`, `integrate_branch`, and every remote or worktree mutation tool from executing.
28
+ - A second temporary verifier registers a deterministic local smoke model, blocks `fetch`, and runs normal prompts through real Pi lifecycle handling. It verifies automatic no-tool Git context in a temporary repository, one real `branch_status` tool refresh after a verifier-created local change, safe credential-free related-PR status with no request, and non-Git startup fallback. It expects all 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 thirteen BranchMe tools (including strict `integrate_branch`, `retire_branch`, and nested `branch_status.ancestry` schemas), proved merge-continuation tools absent, and confirmed non-mutating BranchMe command output.
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 thirteen branch/integration/retirement/worktree/GitHub tools
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, thirteen tools, and one `before_agent_start` context hook.
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 linked worktrees that contain ignored entries, delete retained worktree branches during 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` 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.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@senad-d/branchme",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "Pi extension for verified current-repository Git branch, worktree, integration, retirement, push, and pull request workflows.",
6
6
  "license": "MIT",
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
+ }
@@ -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 inspectDirectLocalBranchRef(pi, rootCtx, request.targetBranch, signal),
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 getLocalBranchCommit(pi, rootCtx, request.targetBranch, signal);
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 inspectDirectLocalBranchRef(pi, rootCtx, prepared.request.targetBranch, signal),
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 getLocalBranchCommit(
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 inspectDirectLocalBranchRef(
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 getLocalBranchCommit(pi, rootCtx, prepared.request.targetBranch);
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
- async function retireBranchWithinQueue(
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
- hasIgnoredEntries: boolean;
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
- let hasIgnoredEntries = false;
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 === "!!") hasIgnoredEntries = true;
1426
+ if (parsed.status === "!!") ignoredPaths.add(parsed.path.split("/")[0]);
1423
1427
  }
1424
1428
 
1425
1429
  return {
1426
1430
  workingTree: parseWorkingTreeStatus(output).workingTree,
1427
- hasIgnoredEntries,
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
- ): Promise<WorkingTreeDetails> {
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.hasIgnoredEntries) {
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 workingTree;
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(repoRoot, async () => {
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", "HEAD...@{u}"], {
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 = `refs/heads/${branchName}`;
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(repoRoot, async () => {
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",