@senad-d/branchme 0.1.8 → 0.2.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 +8 -5
- package/README.md +87 -14
- package/SECURITY.md +38 -4
- package/docs/PROJECT_DEFINITION_BRIEF.md +51 -17
- package/docs/SMOKE_TEST.md +10 -7
- package/docs/STRUCTURE.md +30 -18
- package/docs/TUI_CAPTURE.md +91 -32
- package/package.json +2 -2
- package/src/commands/branchme-command.ts +16 -2
- package/src/constants.ts +11 -0
- package/src/git-context.ts +81 -16
- package/src/git-integration.ts +673 -0
- package/src/git-retirement.ts +681 -0
- package/src/git.ts +313 -7
- package/src/tools/branchme-tools.ts +159 -4
- package/src/types.ts +191 -0
- package/src/ui/branchme-panel.ts +19 -3
|
@@ -0,0 +1,681 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
GIT_CONTEXT_VALUE_LIMIT_CHARS,
|
|
4
|
+
GIT_RETIREMENT_MUTATION_TIMEOUT_MS,
|
|
5
|
+
GIT_RETIREMENT_SUMMARY_LIMIT_CHARS,
|
|
6
|
+
GIT_WORKTREE_PATH_LIMIT_CHARS,
|
|
7
|
+
} from "./constants.ts";
|
|
8
|
+
import {
|
|
9
|
+
formatGitFailure,
|
|
10
|
+
getCanonicalCommonGitDirectory,
|
|
11
|
+
getCanonicalGitWorktreeRoot,
|
|
12
|
+
getLocalBranchCommit,
|
|
13
|
+
inspectDirectLocalBranchRef,
|
|
14
|
+
inspectLocalBranchWorktreeOccupancy,
|
|
15
|
+
isCommitAncestor,
|
|
16
|
+
isLosslessGitMetadata,
|
|
17
|
+
requireLosslessWorktreeIdentity,
|
|
18
|
+
runGit,
|
|
19
|
+
validateBranchName,
|
|
20
|
+
validateBranchNameInput,
|
|
21
|
+
withRepositoryMutationQueue,
|
|
22
|
+
type DirectLocalBranchRefInspection,
|
|
23
|
+
type GitCommandContext,
|
|
24
|
+
type LocalBranchWorktreeOccupancy,
|
|
25
|
+
type PresentDirectLocalBranchRef,
|
|
26
|
+
} from "./git.ts";
|
|
27
|
+
import { redactSecrets } from "./redaction.ts";
|
|
28
|
+
import type {
|
|
29
|
+
RetireBranchDetails,
|
|
30
|
+
RetireBranchMode,
|
|
31
|
+
RetireBranchRefIdentity,
|
|
32
|
+
RetireBranchToolInput,
|
|
33
|
+
RetireBranchWorktreeOccupancyProof,
|
|
34
|
+
} from "./types.ts";
|
|
35
|
+
|
|
36
|
+
const FULL_OBJECT_ID_PATTERN = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/iu;
|
|
37
|
+
const RETIREMENT_REQUEST_FIELDS = new Set(["branchName", "expectedHead", "targetBranch", "force"]);
|
|
38
|
+
|
|
39
|
+
export interface PreparedBranchRetirement {
|
|
40
|
+
worktreeRoot: string;
|
|
41
|
+
canonicalCommonGitDirectory: string;
|
|
42
|
+
request: RetireBranchToolInput;
|
|
43
|
+
retiring: RetireBranchRefIdentity;
|
|
44
|
+
target: RetireBranchRefIdentity;
|
|
45
|
+
worktreeOccupancy: RetireBranchWorktreeOccupancyProof;
|
|
46
|
+
retiringIsAncestorOfTarget: boolean;
|
|
47
|
+
mode: RetireBranchMode;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function validateRetirementBranchName(value: unknown, label: string): asserts value is string {
|
|
51
|
+
validateBranchNameInput(value, label);
|
|
52
|
+
if (value.startsWith("refs/")) {
|
|
53
|
+
throw new Error(`${label} must be a local branch name, not a full ref name.`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function normalizeExpectedHead(value: unknown): string {
|
|
58
|
+
if (typeof value !== "string") throw new TypeError("Expected HEAD must be a string.");
|
|
59
|
+
if (!FULL_OBJECT_ID_PATTERN.test(value)) {
|
|
60
|
+
throw new Error("Expected HEAD must be exactly 40 or 64 hexadecimal characters.");
|
|
61
|
+
}
|
|
62
|
+
return value.toLowerCase();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function validateRetirementRequest(request: unknown): RetireBranchToolInput {
|
|
66
|
+
if (typeof request !== "object" || request === null || Array.isArray(request)) {
|
|
67
|
+
throw new TypeError("Branch retirement request must be an object.");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const values = request as Record<string, unknown>;
|
|
71
|
+
const fields = Object.keys(values);
|
|
72
|
+
if (
|
|
73
|
+
fields.length !== RETIREMENT_REQUEST_FIELDS.size ||
|
|
74
|
+
fields.some((field) => !RETIREMENT_REQUEST_FIELDS.has(field))
|
|
75
|
+
) {
|
|
76
|
+
throw new Error("Branch retirement request must contain exactly branchName, expectedHead, targetBranch, and force.");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
validateRetirementBranchName(values.branchName, "Retiring branch");
|
|
80
|
+
validateRetirementBranchName(values.targetBranch, "Target branch");
|
|
81
|
+
if (values.branchName === values.targetBranch) {
|
|
82
|
+
throw new Error("Retiring branch and target branch must be distinct local branches.");
|
|
83
|
+
}
|
|
84
|
+
const expectedHead = normalizeExpectedHead(values.expectedHead);
|
|
85
|
+
if (typeof values.force !== "boolean") throw new TypeError("force must be a boolean.");
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
branchName: values.branchName,
|
|
89
|
+
expectedHead,
|
|
90
|
+
targetBranch: values.targetBranch,
|
|
91
|
+
force: values.force,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function sameObjectIdentity(left: string, right: string): boolean {
|
|
96
|
+
return left.toLowerCase() === right.toLowerCase();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function requirePresentDirectRef(
|
|
100
|
+
inspection: DirectLocalBranchRefInspection,
|
|
101
|
+
label: "Retiring" | "Target",
|
|
102
|
+
): PresentDirectLocalBranchRef {
|
|
103
|
+
if (inspection.status === "present") return inspection;
|
|
104
|
+
throw new Error(`${label} local branch '${inspection.branchName}' does not exist.`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function requireDirectRefCommitIdentity(
|
|
108
|
+
directRef: PresentDirectLocalBranchRef,
|
|
109
|
+
commit: string,
|
|
110
|
+
label: "Retiring" | "Target",
|
|
111
|
+
): void {
|
|
112
|
+
if (sameObjectIdentity(directRef.objectId, commit)) return;
|
|
113
|
+
throw new Error(
|
|
114
|
+
`${label} local branch '${directRef.branchName}' is not a direct ref to its resolved commit identity.`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function requireUnoccupiedRetiringBranch(
|
|
119
|
+
occupancy: LocalBranchWorktreeOccupancy,
|
|
120
|
+
): RetireBranchWorktreeOccupancyProof {
|
|
121
|
+
if (occupancy.occupied || occupancy.matchingWorktreeCount !== 0) {
|
|
122
|
+
throw new Error(
|
|
123
|
+
`Retiring local branch '${occupancy.branchName}' is occupied by ` +
|
|
124
|
+
`${occupancy.matchingWorktreeCount} registered worktree record(s).`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
branchName: occupancy.branchName,
|
|
129
|
+
completeInventoryInspected: true,
|
|
130
|
+
occupied: false,
|
|
131
|
+
matchingWorktreeCount: 0,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function requireLosslessRetirementMetadata(
|
|
136
|
+
worktreeRoot: string,
|
|
137
|
+
canonicalCommonGitDirectory: string,
|
|
138
|
+
request: RetireBranchToolInput,
|
|
139
|
+
retiringRef: PresentDirectLocalBranchRef,
|
|
140
|
+
targetRef: PresentDirectLocalBranchRef,
|
|
141
|
+
): void {
|
|
142
|
+
requireLosslessWorktreeIdentity(worktreeRoot, "cwd");
|
|
143
|
+
requireLosslessWorktreeIdentity(request.branchName, "branch");
|
|
144
|
+
requireLosslessWorktreeIdentity(request.targetBranch, "branch");
|
|
145
|
+
if (
|
|
146
|
+
!isLosslessGitMetadata(canonicalCommonGitDirectory, GIT_WORKTREE_PATH_LIMIT_CHARS) ||
|
|
147
|
+
!isLosslessGitMetadata(retiringRef.fullRef, GIT_CONTEXT_VALUE_LIMIT_CHARS) ||
|
|
148
|
+
!isLosslessGitMetadata(targetRef.fullRef, GIT_CONTEXT_VALUE_LIMIT_CHARS)
|
|
149
|
+
) {
|
|
150
|
+
throw new Error(
|
|
151
|
+
"Branch retirement identities cannot be returned safely and losslessly within bounded details.",
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function retirementRefIdentity(
|
|
157
|
+
directRef: PresentDirectLocalBranchRef,
|
|
158
|
+
commit: string,
|
|
159
|
+
): RetireBranchRefIdentity {
|
|
160
|
+
return {
|
|
161
|
+
branchName: directRef.branchName,
|
|
162
|
+
fullRef: directRef.fullRef,
|
|
163
|
+
head: commit.toLowerCase(),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function prepareBranchRetirement(
|
|
168
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
169
|
+
ctx: GitCommandContext,
|
|
170
|
+
requestValue: unknown,
|
|
171
|
+
signal?: AbortSignal,
|
|
172
|
+
): Promise<PreparedBranchRetirement> {
|
|
173
|
+
const request = validateRetirementRequest(requestValue);
|
|
174
|
+
const worktreeRoot = await getCanonicalGitWorktreeRoot(pi, ctx, signal);
|
|
175
|
+
const rootCtx = { cwd: worktreeRoot };
|
|
176
|
+
const canonicalCommonGitDirectory = await getCanonicalCommonGitDirectory(pi, rootCtx, signal);
|
|
177
|
+
|
|
178
|
+
await validateBranchName(pi, rootCtx, request.branchName, signal);
|
|
179
|
+
await validateBranchName(pi, rootCtx, request.targetBranch, signal);
|
|
180
|
+
const retiringRef = requirePresentDirectRef(
|
|
181
|
+
await inspectDirectLocalBranchRef(pi, rootCtx, request.branchName, signal),
|
|
182
|
+
"Retiring",
|
|
183
|
+
);
|
|
184
|
+
const targetRef = requirePresentDirectRef(
|
|
185
|
+
await inspectDirectLocalBranchRef(pi, rootCtx, request.targetBranch, signal),
|
|
186
|
+
"Target",
|
|
187
|
+
);
|
|
188
|
+
const retiringHead = await getLocalBranchCommit(pi, rootCtx, request.branchName, signal);
|
|
189
|
+
const targetHead = await getLocalBranchCommit(pi, rootCtx, request.targetBranch, signal);
|
|
190
|
+
requireDirectRefCommitIdentity(retiringRef, retiringHead, "Retiring");
|
|
191
|
+
requireDirectRefCommitIdentity(targetRef, targetHead, "Target");
|
|
192
|
+
if (!sameObjectIdentity(retiringHead, request.expectedHead)) {
|
|
193
|
+
throw new Error(
|
|
194
|
+
`Retiring local branch '${request.branchName}' does not match the required expected HEAD.`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const worktreeOccupancy = requireUnoccupiedRetiringBranch(
|
|
199
|
+
await inspectLocalBranchWorktreeOccupancy(pi, rootCtx, request.branchName, signal),
|
|
200
|
+
);
|
|
201
|
+
const retiringIsAncestorOfTarget = await isCommitAncestor(
|
|
202
|
+
pi,
|
|
203
|
+
rootCtx,
|
|
204
|
+
retiringHead,
|
|
205
|
+
targetHead,
|
|
206
|
+
signal,
|
|
207
|
+
);
|
|
208
|
+
if (!retiringIsAncestorOfTarget && request.force !== true) {
|
|
209
|
+
throw new Error(
|
|
210
|
+
"The retiring branch is not an ancestor of the captured target branch; force must be true to authorize unmerged retirement.",
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
requireLosslessRetirementMetadata(
|
|
215
|
+
worktreeRoot,
|
|
216
|
+
canonicalCommonGitDirectory,
|
|
217
|
+
request,
|
|
218
|
+
retiringRef,
|
|
219
|
+
targetRef,
|
|
220
|
+
);
|
|
221
|
+
return {
|
|
222
|
+
worktreeRoot,
|
|
223
|
+
canonicalCommonGitDirectory,
|
|
224
|
+
request,
|
|
225
|
+
retiring: retirementRefIdentity(retiringRef, retiringHead),
|
|
226
|
+
target: retirementRefIdentity(targetRef, targetHead),
|
|
227
|
+
worktreeOccupancy,
|
|
228
|
+
retiringIsAncestorOfTarget,
|
|
229
|
+
mode: retiringIsAncestorOfTarget ? "merged" : "forced_unmerged",
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
interface ImmediateRetirementState {
|
|
234
|
+
worktreeRoot: string;
|
|
235
|
+
canonicalCommonGitDirectory: string;
|
|
236
|
+
retiringHead: string;
|
|
237
|
+
targetHead: string;
|
|
238
|
+
worktreeOccupancy: RetireBranchWorktreeOccupancyProof;
|
|
239
|
+
refreshedAncestry: boolean | null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
interface PostRetirementState {
|
|
243
|
+
worktreeRoot: string;
|
|
244
|
+
canonicalCommonGitDirectory: string;
|
|
245
|
+
retiringRef: DirectLocalBranchRefInspection;
|
|
246
|
+
retiringHead: string | null;
|
|
247
|
+
targetRef: DirectLocalBranchRefInspection;
|
|
248
|
+
targetHead: string | null;
|
|
249
|
+
worktreeOccupancy: LocalBranchWorktreeOccupancy;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function retirementTokens(prepared: PreparedBranchRetirement): string[] {
|
|
253
|
+
return [
|
|
254
|
+
prepared.worktreeRoot,
|
|
255
|
+
prepared.canonicalCommonGitDirectory,
|
|
256
|
+
prepared.request.branchName,
|
|
257
|
+
prepared.request.targetBranch,
|
|
258
|
+
prepared.retiring.fullRef,
|
|
259
|
+
prepared.target.fullRef,
|
|
260
|
+
];
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function boundedRetirementText(
|
|
264
|
+
value: string,
|
|
265
|
+
prepared: PreparedBranchRetirement,
|
|
266
|
+
): string {
|
|
267
|
+
const redacted = redactSecrets(value, retirementTokens(prepared)).replace(
|
|
268
|
+
/[\p{Cc}\p{Cf}\u2028\u2029]/gu,
|
|
269
|
+
(character) => {
|
|
270
|
+
const codePoint = character.codePointAt(0);
|
|
271
|
+
return codePoint === undefined
|
|
272
|
+
? ""
|
|
273
|
+
: String.raw`\u${codePoint.toString(16).padStart(4, "0")}`;
|
|
274
|
+
},
|
|
275
|
+
);
|
|
276
|
+
if (redacted.length <= GIT_RETIREMENT_SUMMARY_LIMIT_CHARS) return redacted;
|
|
277
|
+
return `${redacted.slice(0, GIT_RETIREMENT_SUMMARY_LIMIT_CHARS - 14)}… [truncated]`;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function boundedRetirementError(
|
|
281
|
+
prepared: PreparedBranchRetirement,
|
|
282
|
+
error: unknown,
|
|
283
|
+
fallback: string,
|
|
284
|
+
): Error {
|
|
285
|
+
const rawMessage = error instanceof Error ? error.message : String(error);
|
|
286
|
+
return new Error(boundedRetirementText(rawMessage || fallback, prepared));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function uncertainRetirementError(
|
|
290
|
+
prepared: PreparedBranchRetirement,
|
|
291
|
+
reason: unknown,
|
|
292
|
+
): Error {
|
|
293
|
+
const rawReason = reason instanceof Error ? reason.message : String(reason);
|
|
294
|
+
const reasonText = boundedRetirementText(
|
|
295
|
+
rawReason || "post-mutation verification was inconclusive",
|
|
296
|
+
prepared,
|
|
297
|
+
);
|
|
298
|
+
return new Error(
|
|
299
|
+
boundedRetirementText(
|
|
300
|
+
"Branch retirement postconditions are uncertain. Retirement may have completed; " +
|
|
301
|
+
"manually inspect the repository, target ref, local retiring ref, and worktree inventory before retrying. " +
|
|
302
|
+
"Do not recreate, reset, or otherwise modify either branch as an automatic rollback. " +
|
|
303
|
+
`Reason: ${reasonText}`,
|
|
304
|
+
prepared,
|
|
305
|
+
),
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function sameRepositoryIdentity(
|
|
310
|
+
prepared: PreparedBranchRetirement,
|
|
311
|
+
worktreeRoot: string,
|
|
312
|
+
canonicalCommonGitDirectory: string,
|
|
313
|
+
): boolean {
|
|
314
|
+
return worktreeRoot === prepared.worktreeRoot &&
|
|
315
|
+
canonicalCommonGitDirectory === prepared.canonicalCommonGitDirectory;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function inspectImmediateRetirementState(
|
|
319
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
320
|
+
prepared: PreparedBranchRetirement,
|
|
321
|
+
signal?: AbortSignal,
|
|
322
|
+
): Promise<ImmediateRetirementState> {
|
|
323
|
+
const rootCtx = { cwd: prepared.worktreeRoot };
|
|
324
|
+
const worktreeRoot = await getCanonicalGitWorktreeRoot(pi, rootCtx, signal);
|
|
325
|
+
const canonicalCommonGitDirectory = await getCanonicalCommonGitDirectory(pi, rootCtx, signal);
|
|
326
|
+
const retiringRef = requirePresentDirectRef(
|
|
327
|
+
await inspectDirectLocalBranchRef(pi, rootCtx, prepared.request.branchName, signal),
|
|
328
|
+
"Retiring",
|
|
329
|
+
);
|
|
330
|
+
const targetRef = requirePresentDirectRef(
|
|
331
|
+
await inspectDirectLocalBranchRef(pi, rootCtx, prepared.request.targetBranch, signal),
|
|
332
|
+
"Target",
|
|
333
|
+
);
|
|
334
|
+
const retiringHead = await getLocalBranchCommit(
|
|
335
|
+
pi,
|
|
336
|
+
rootCtx,
|
|
337
|
+
prepared.request.branchName,
|
|
338
|
+
signal,
|
|
339
|
+
);
|
|
340
|
+
const targetHead = await getLocalBranchCommit(
|
|
341
|
+
pi,
|
|
342
|
+
rootCtx,
|
|
343
|
+
prepared.request.targetBranch,
|
|
344
|
+
signal,
|
|
345
|
+
);
|
|
346
|
+
requireDirectRefCommitIdentity(retiringRef, retiringHead, "Retiring");
|
|
347
|
+
requireDirectRefCommitIdentity(targetRef, targetHead, "Target");
|
|
348
|
+
const worktreeOccupancy = requireUnoccupiedRetiringBranch(
|
|
349
|
+
await inspectLocalBranchWorktreeOccupancy(
|
|
350
|
+
pi,
|
|
351
|
+
rootCtx,
|
|
352
|
+
prepared.request.branchName,
|
|
353
|
+
signal,
|
|
354
|
+
),
|
|
355
|
+
);
|
|
356
|
+
const refsChanged = !sameObjectIdentity(retiringHead, prepared.retiring.head) ||
|
|
357
|
+
!sameObjectIdentity(targetHead, prepared.target.head);
|
|
358
|
+
const refreshedAncestry = refsChanged
|
|
359
|
+
? await isCommitAncestor(pi, rootCtx, retiringHead, targetHead, signal)
|
|
360
|
+
: null;
|
|
361
|
+
|
|
362
|
+
return {
|
|
363
|
+
worktreeRoot,
|
|
364
|
+
canonicalCommonGitDirectory,
|
|
365
|
+
retiringHead: retiringHead.toLowerCase(),
|
|
366
|
+
targetHead: targetHead.toLowerCase(),
|
|
367
|
+
worktreeOccupancy,
|
|
368
|
+
refreshedAncestry,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function immediateRetirementProblems(
|
|
373
|
+
prepared: PreparedBranchRetirement,
|
|
374
|
+
immediate: ImmediateRetirementState,
|
|
375
|
+
): string[] {
|
|
376
|
+
const problems: string[] = [];
|
|
377
|
+
if (!sameRepositoryIdentity(
|
|
378
|
+
prepared,
|
|
379
|
+
immediate.worktreeRoot,
|
|
380
|
+
immediate.canonicalCommonGitDirectory,
|
|
381
|
+
)) {
|
|
382
|
+
problems.push("the canonical repository identity changed");
|
|
383
|
+
}
|
|
384
|
+
if (!sameObjectIdentity(immediate.retiringHead, prepared.retiring.head)) {
|
|
385
|
+
problems.push("the retiring local ref moved after preflight");
|
|
386
|
+
}
|
|
387
|
+
if (!sameObjectIdentity(immediate.targetHead, prepared.target.head)) {
|
|
388
|
+
problems.push("the target local ref moved after preflight");
|
|
389
|
+
}
|
|
390
|
+
if (immediate.worktreeOccupancy.occupied) {
|
|
391
|
+
problems.push("the retiring branch became occupied by a registered worktree");
|
|
392
|
+
}
|
|
393
|
+
if (immediate.refreshedAncestry !== null) {
|
|
394
|
+
problems.push(
|
|
395
|
+
`ancestry for the changed refs was rechecked as ${immediate.refreshedAncestry}`,
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
return problems;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async function attemptRetirementMutation(
|
|
402
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
403
|
+
prepared: PreparedBranchRetirement,
|
|
404
|
+
signal?: AbortSignal,
|
|
405
|
+
): Promise<Error | null> {
|
|
406
|
+
const args = [
|
|
407
|
+
"update-ref",
|
|
408
|
+
"--no-deref",
|
|
409
|
+
"-d",
|
|
410
|
+
prepared.retiring.fullRef,
|
|
411
|
+
prepared.retiring.head,
|
|
412
|
+
];
|
|
413
|
+
try {
|
|
414
|
+
const result = await runGit(pi, { cwd: prepared.worktreeRoot }, args, {
|
|
415
|
+
signal,
|
|
416
|
+
timeout: GIT_RETIREMENT_MUTATION_TIMEOUT_MS,
|
|
417
|
+
allowFailure: true,
|
|
418
|
+
tokens: retirementTokens(prepared),
|
|
419
|
+
});
|
|
420
|
+
if (result.code === 0) return null;
|
|
421
|
+
return new Error(
|
|
422
|
+
boundedRetirementText(
|
|
423
|
+
formatGitFailure(args, result, retirementTokens(prepared)),
|
|
424
|
+
prepared,
|
|
425
|
+
),
|
|
426
|
+
);
|
|
427
|
+
} catch (error) {
|
|
428
|
+
return boundedRetirementError(
|
|
429
|
+
prepared,
|
|
430
|
+
error,
|
|
431
|
+
"The leased local ref deletion did not report completion.",
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
async function inspectPostRetirementState(
|
|
437
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
438
|
+
prepared: PreparedBranchRetirement,
|
|
439
|
+
): Promise<PostRetirementState> {
|
|
440
|
+
const rootCtx = { cwd: prepared.worktreeRoot };
|
|
441
|
+
const worktreeRoot = await getCanonicalGitWorktreeRoot(pi, rootCtx);
|
|
442
|
+
const canonicalCommonGitDirectory = await getCanonicalCommonGitDirectory(pi, rootCtx);
|
|
443
|
+
const retiringRef = await inspectDirectLocalBranchRef(
|
|
444
|
+
pi,
|
|
445
|
+
rootCtx,
|
|
446
|
+
prepared.request.branchName,
|
|
447
|
+
);
|
|
448
|
+
const targetRef = await inspectDirectLocalBranchRef(
|
|
449
|
+
pi,
|
|
450
|
+
rootCtx,
|
|
451
|
+
prepared.request.targetBranch,
|
|
452
|
+
);
|
|
453
|
+
let retiringHead: string | null = null;
|
|
454
|
+
if (retiringRef.status === "present") {
|
|
455
|
+
retiringHead = await getLocalBranchCommit(pi, rootCtx, prepared.request.branchName);
|
|
456
|
+
requireDirectRefCommitIdentity(retiringRef, retiringHead, "Retiring");
|
|
457
|
+
retiringHead = retiringHead.toLowerCase();
|
|
458
|
+
}
|
|
459
|
+
let targetHead: string | null = null;
|
|
460
|
+
if (targetRef.status === "present") {
|
|
461
|
+
targetHead = await getLocalBranchCommit(pi, rootCtx, prepared.request.targetBranch);
|
|
462
|
+
requireDirectRefCommitIdentity(targetRef, targetHead, "Target");
|
|
463
|
+
targetHead = targetHead.toLowerCase();
|
|
464
|
+
}
|
|
465
|
+
const worktreeOccupancy = await inspectLocalBranchWorktreeOccupancy(
|
|
466
|
+
pi,
|
|
467
|
+
rootCtx,
|
|
468
|
+
prepared.request.branchName,
|
|
469
|
+
);
|
|
470
|
+
return {
|
|
471
|
+
worktreeRoot,
|
|
472
|
+
canonicalCommonGitDirectory,
|
|
473
|
+
retiringRef,
|
|
474
|
+
retiringHead,
|
|
475
|
+
targetRef,
|
|
476
|
+
targetHead,
|
|
477
|
+
worktreeOccupancy,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function postRetirementProblems(
|
|
482
|
+
prepared: PreparedBranchRetirement,
|
|
483
|
+
post: PostRetirementState,
|
|
484
|
+
): string[] {
|
|
485
|
+
const problems: string[] = [];
|
|
486
|
+
if (!sameRepositoryIdentity(prepared, post.worktreeRoot, post.canonicalCommonGitDirectory)) {
|
|
487
|
+
problems.push("the canonical repository identity changed");
|
|
488
|
+
}
|
|
489
|
+
if (post.targetRef.status === "absent") {
|
|
490
|
+
problems.push("the captured target local ref is missing");
|
|
491
|
+
} else if (post.targetHead === null || !sameObjectIdentity(post.targetHead, prepared.target.head)) {
|
|
492
|
+
problems.push("the captured target local ref moved");
|
|
493
|
+
}
|
|
494
|
+
if (
|
|
495
|
+
post.retiringRef.status === "absent" &&
|
|
496
|
+
(post.worktreeOccupancy.occupied || post.worktreeOccupancy.matchingWorktreeCount !== 0)
|
|
497
|
+
) {
|
|
498
|
+
problems.push("a registered worktree names the missing retiring branch");
|
|
499
|
+
}
|
|
500
|
+
return problems;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function successfulRetirementDetails(
|
|
504
|
+
prepared: PreparedBranchRetirement,
|
|
505
|
+
post: PostRetirementState,
|
|
506
|
+
): RetireBranchDetails {
|
|
507
|
+
if (post.retiringRef.status !== "absent" || post.targetRef.status !== "present" ||
|
|
508
|
+
post.targetHead === null || post.worktreeOccupancy.occupied ||
|
|
509
|
+
post.worktreeOccupancy.matchingWorktreeCount !== 0) {
|
|
510
|
+
throw uncertainRetirementError(prepared, "verified retirement details were contradictory");
|
|
511
|
+
}
|
|
512
|
+
const target = retirementRefIdentity(post.targetRef, post.targetHead);
|
|
513
|
+
return {
|
|
514
|
+
action: "retire_branch",
|
|
515
|
+
status: "retired",
|
|
516
|
+
mode: prepared.mode,
|
|
517
|
+
request: prepared.request,
|
|
518
|
+
verified: {
|
|
519
|
+
repository: {
|
|
520
|
+
before: {
|
|
521
|
+
worktreeRoot: prepared.worktreeRoot,
|
|
522
|
+
canonicalCommonGitDirectory: prepared.canonicalCommonGitDirectory,
|
|
523
|
+
},
|
|
524
|
+
after: {
|
|
525
|
+
worktreeRoot: post.worktreeRoot,
|
|
526
|
+
canonicalCommonGitDirectory: post.canonicalCommonGitDirectory,
|
|
527
|
+
},
|
|
528
|
+
identityPreserved: true,
|
|
529
|
+
},
|
|
530
|
+
refs: {
|
|
531
|
+
before: {
|
|
532
|
+
retiring: prepared.retiring,
|
|
533
|
+
target: prepared.target,
|
|
534
|
+
expectedHead: prepared.request.expectedHead,
|
|
535
|
+
expectedHeadMatches: true,
|
|
536
|
+
},
|
|
537
|
+
after: {
|
|
538
|
+
retiring: {
|
|
539
|
+
branchName: post.retiringRef.branchName,
|
|
540
|
+
fullRef: post.retiringRef.fullRef,
|
|
541
|
+
absent: true,
|
|
542
|
+
},
|
|
543
|
+
target,
|
|
544
|
+
targetHeadPreserved: true,
|
|
545
|
+
},
|
|
546
|
+
},
|
|
547
|
+
ancestry: {
|
|
548
|
+
retiringHead: prepared.retiring.head,
|
|
549
|
+
targetHead: prepared.target.head,
|
|
550
|
+
retiringIsAncestorOfTarget: prepared.retiringIsAncestorOfTarget,
|
|
551
|
+
},
|
|
552
|
+
worktreeOccupancy: {
|
|
553
|
+
before: prepared.worktreeOccupancy,
|
|
554
|
+
after: {
|
|
555
|
+
branchName: post.worktreeOccupancy.branchName,
|
|
556
|
+
completeInventoryInspected: true,
|
|
557
|
+
occupied: false,
|
|
558
|
+
matchingWorktreeCount: 0,
|
|
559
|
+
},
|
|
560
|
+
},
|
|
561
|
+
mutation: {
|
|
562
|
+
exactLocalRefDeletionAttempted: true,
|
|
563
|
+
localBranchAbsentAfterDeletion: true,
|
|
564
|
+
directRemoteDeletionAttempted: false,
|
|
565
|
+
remoteTrackingRefDeletionAttempted: false,
|
|
566
|
+
},
|
|
567
|
+
},
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function classifyRetirementOutcome(
|
|
572
|
+
prepared: PreparedBranchRetirement,
|
|
573
|
+
post: PostRetirementState,
|
|
574
|
+
mutationFailure: Error | null,
|
|
575
|
+
): RetireBranchDetails {
|
|
576
|
+
const problems = postRetirementProblems(prepared, post);
|
|
577
|
+
if (problems.length > 0) {
|
|
578
|
+
throw uncertainRetirementError(prepared, problems.join("; "));
|
|
579
|
+
}
|
|
580
|
+
if (post.retiringRef.status === "absent") {
|
|
581
|
+
return successfulRetirementDetails(prepared, post);
|
|
582
|
+
}
|
|
583
|
+
if (post.retiringHead === null) {
|
|
584
|
+
throw uncertainRetirementError(
|
|
585
|
+
prepared,
|
|
586
|
+
"the retiring local ref was present but its commit could not be verified",
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
if (mutationFailure === null) {
|
|
590
|
+
throw uncertainRetirementError(
|
|
591
|
+
prepared,
|
|
592
|
+
"Git reported successful deletion but the retiring local ref is still present",
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
if (sameObjectIdentity(post.retiringHead, prepared.retiring.head)) {
|
|
596
|
+
throw new Error(
|
|
597
|
+
boundedRetirementText(
|
|
598
|
+
`Branch retirement failed safely. The retiring local ref remains at captured HEAD ${prepared.retiring.head}. ` +
|
|
599
|
+
`Diagnostic: ${mutationFailure.message}`,
|
|
600
|
+
prepared,
|
|
601
|
+
),
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
throw new Error(
|
|
605
|
+
boundedRetirementText(
|
|
606
|
+
`Branch retirement was not performed because the retiring local ref moved to ${post.retiringHead}. ` +
|
|
607
|
+
`The expected-old-value lease protected the moved ref. ${mutationFailure.message}`,
|
|
608
|
+
prepared,
|
|
609
|
+
),
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
async function retireBranchWithinQueue(
|
|
614
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
615
|
+
ctx: GitCommandContext,
|
|
616
|
+
request: RetireBranchToolInput,
|
|
617
|
+
queuedWorktreeRoot: string,
|
|
618
|
+
signal?: AbortSignal,
|
|
619
|
+
): Promise<RetireBranchDetails> {
|
|
620
|
+
const prepared = await prepareBranchRetirement(pi, ctx, request, signal);
|
|
621
|
+
if (prepared.worktreeRoot !== queuedWorktreeRoot) {
|
|
622
|
+
throw new Error("The active worktree changed while preparing branch retirement.");
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
let immediate: ImmediateRetirementState;
|
|
626
|
+
try {
|
|
627
|
+
immediate = await inspectImmediateRetirementState(pi, prepared, signal);
|
|
628
|
+
} catch (error) {
|
|
629
|
+
throw boundedRetirementError(
|
|
630
|
+
prepared,
|
|
631
|
+
error,
|
|
632
|
+
"Branch retirement preconditions could not be reverified immediately before deletion.",
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
const immediateProblems = immediateRetirementProblems(prepared, immediate);
|
|
636
|
+
if (immediateProblems.length > 0) {
|
|
637
|
+
throw new Error(
|
|
638
|
+
boundedRetirementText(
|
|
639
|
+
`Branch retirement preconditions changed: ${immediateProblems.join("; ")}. No local ref was deleted.`,
|
|
640
|
+
prepared,
|
|
641
|
+
),
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
const mutationFailure = await attemptRetirementMutation(pi, prepared, signal);
|
|
646
|
+
let post: PostRetirementState;
|
|
647
|
+
try {
|
|
648
|
+
post = await inspectPostRetirementState(pi, prepared);
|
|
649
|
+
} catch (error) {
|
|
650
|
+
throw uncertainRetirementError(prepared, error);
|
|
651
|
+
}
|
|
652
|
+
return classifyRetirementOutcome(prepared, post, mutationFailure);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
export async function resolveRetirementWorktreeRoot(
|
|
656
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
657
|
+
ctx: GitCommandContext,
|
|
658
|
+
signal?: AbortSignal,
|
|
659
|
+
): Promise<string> {
|
|
660
|
+
return getCanonicalGitWorktreeRoot(pi, ctx, signal);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
export async function retireBranch(
|
|
664
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
665
|
+
ctx: GitCommandContext,
|
|
666
|
+
request: RetireBranchToolInput,
|
|
667
|
+
signal?: AbortSignal,
|
|
668
|
+
): Promise<RetireBranchDetails> {
|
|
669
|
+
const worktreeRoot = await resolveRetirementWorktreeRoot(pi, ctx, signal);
|
|
670
|
+
return withRepositoryMutationQueue(
|
|
671
|
+
worktreeRoot,
|
|
672
|
+
retireBranchWithinQueue.bind(
|
|
673
|
+
undefined,
|
|
674
|
+
pi,
|
|
675
|
+
ctx,
|
|
676
|
+
request,
|
|
677
|
+
worktreeRoot,
|
|
678
|
+
signal,
|
|
679
|
+
),
|
|
680
|
+
);
|
|
681
|
+
}
|