@senad-d/branchme 0.2.1 → 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.
@@ -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
  }