@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.
- package/CHANGELOG.md +9 -2
- package/README.md +47 -13
- package/SECURITY.md +14 -5
- package/docs/PROJECT_DEFINITION_BRIEF.md +8 -3
- package/docs/SMOKE_TEST.md +3 -3
- package/docs/STRUCTURE.md +10 -6
- package/docs/TUI_CAPTURE.md +2 -2
- package/package.json +1 -1
- package/src/commands/branchme-command.ts +2 -2
- package/src/constants.ts +2 -0
- package/src/git-landing.ts +449 -0
- package/src/git-retirement.ts +34 -11
- package/src/git.ts +250 -28
- package/src/tools/branchme-tools.ts +88 -18
- package/src/types.ts +12 -0
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
import { lstat, realpath } from "node:fs/promises";
|
|
2
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { GIT_FETCH_TIMEOUT_MS, GIT_PULL_TIMEOUT_MS, MAX_SUMMARY_OUTPUT_CHARS } from "./constants.ts";
|
|
4
|
+
import {
|
|
5
|
+
canonicalizePathAllowMissing,
|
|
6
|
+
collectWorktreeInventory,
|
|
7
|
+
fetchRemoteBranchWithinQueue,
|
|
8
|
+
formatGitFailure,
|
|
9
|
+
getAheadBehindCount,
|
|
10
|
+
getCanonicalCommonGitDirectory,
|
|
11
|
+
getCanonicalGitWorktreeRoot,
|
|
12
|
+
getCurrentBranch,
|
|
13
|
+
getGitOperationState,
|
|
14
|
+
getLocalBranchCommit,
|
|
15
|
+
getRemoteTrackingRefCommit,
|
|
16
|
+
getWorkingTreeStatus,
|
|
17
|
+
inspectDirectLocalBranchRef,
|
|
18
|
+
inspectDirectRemoteTrackingRef,
|
|
19
|
+
isCommitAncestor,
|
|
20
|
+
pathIsInsideOrEqual,
|
|
21
|
+
removeWorktreeWithinQueue,
|
|
22
|
+
requireLosslessWorktreeIdentity,
|
|
23
|
+
runGit,
|
|
24
|
+
validateBranchName,
|
|
25
|
+
validateBranchNameInput,
|
|
26
|
+
validateWorktreePathInput,
|
|
27
|
+
validateWorktreeRemovalPath,
|
|
28
|
+
withRepositoryMutationQueue,
|
|
29
|
+
type GitCommandContext,
|
|
30
|
+
} from "./git.ts";
|
|
31
|
+
import { retireBranchWithinQueue } from "./git-retirement.ts";
|
|
32
|
+
import { redactSecrets } from "./redaction.ts";
|
|
33
|
+
import type { AheadBehindCount } from "./types.ts";
|
|
34
|
+
|
|
35
|
+
type GitAPI = Pick<ExtensionAPI, "exec">;
|
|
36
|
+
type Inventory = Awaited<ReturnType<typeof collectWorktreeInventory>>;
|
|
37
|
+
|
|
38
|
+
export interface LandBranchInput {
|
|
39
|
+
sourceBranch: string;
|
|
40
|
+
targetBranch: string;
|
|
41
|
+
remote?: string;
|
|
42
|
+
worktreePath?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface LandBranchReceipt {
|
|
46
|
+
repositoryRoot: string;
|
|
47
|
+
remote: string;
|
|
48
|
+
targetBranch: string;
|
|
49
|
+
remoteTargetHead: string | null;
|
|
50
|
+
sourceBranch: string;
|
|
51
|
+
sourceHead: string | null;
|
|
52
|
+
ancestry: { isAncestor: boolean | null };
|
|
53
|
+
worktree: {
|
|
54
|
+
path: string | null;
|
|
55
|
+
outcome: "removed" | "absent" | "refused";
|
|
56
|
+
reason: string | null;
|
|
57
|
+
deletedIgnoredPaths: string[];
|
|
58
|
+
};
|
|
59
|
+
branch: {
|
|
60
|
+
outcome: "deleted" | "absent" | "refused";
|
|
61
|
+
reason: string | null;
|
|
62
|
+
expectedHead: string | null;
|
|
63
|
+
};
|
|
64
|
+
targetSync: {
|
|
65
|
+
mode: "fetch-refspec" | "pull-ff" | "skipped-dirty" | "noop" | "not-run" | "failed";
|
|
66
|
+
worktreePath: string | null;
|
|
67
|
+
before: string | null;
|
|
68
|
+
after: string | null;
|
|
69
|
+
aheadBehind: AheadBehindCount | null;
|
|
70
|
+
reason: string | null;
|
|
71
|
+
};
|
|
72
|
+
steps: {
|
|
73
|
+
step: "repository" | "cwd" | "fetch" | "ancestry" | "worktree" | "branch" | "targetSync";
|
|
74
|
+
outcome: string;
|
|
75
|
+
reason: string | null;
|
|
76
|
+
}[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function landingError(error: unknown): string {
|
|
80
|
+
const text = error instanceof Error ? error.message : String(error);
|
|
81
|
+
return redactSecrets(text).replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, " ").slice(0, MAX_SUMMARY_OUTPUT_CHARS);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function validateLandingInput(input: LandBranchInput): void {
|
|
85
|
+
for (const key of Object.keys(input)) {
|
|
86
|
+
if (!["sourceBranch", "targetBranch", "remote", "worktreePath"].includes(key)) {
|
|
87
|
+
throw new Error("land_branch accepts only sourceBranch, targetBranch, remote, and worktreePath.");
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
for (const branch of [input.sourceBranch, input.targetBranch]) {
|
|
91
|
+
validateBranchNameInput(branch);
|
|
92
|
+
requireLosslessWorktreeIdentity(branch, "branch");
|
|
93
|
+
if (branch.startsWith("refs/") || branch.includes("@{")) {
|
|
94
|
+
throw new Error("land_branch requires exact local branch names, not full refs or revision expressions.");
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (input.sourceBranch === input.targetBranch) throw new Error("Source and target branches must be distinct.");
|
|
98
|
+
if (input.remote !== undefined) {
|
|
99
|
+
validateBranchNameInput(input.remote, "Remote");
|
|
100
|
+
requireLosslessWorktreeIdentity(input.remote, "branch");
|
|
101
|
+
}
|
|
102
|
+
if (input.worktreePath !== undefined) validateWorktreePathInput(input.worktreePath);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Reused Git helpers all receive explicit -C routing. Disable config-driven pruning,
|
|
106
|
+
// extra fetch mappings and submodule recursion as well as command-line force options.
|
|
107
|
+
async function executeLandingGit(
|
|
108
|
+
pi: GitAPI,
|
|
109
|
+
remote: string,
|
|
110
|
+
command: string,
|
|
111
|
+
args: string[],
|
|
112
|
+
options: Parameters<GitAPI["exec"]>[2],
|
|
113
|
+
) {
|
|
114
|
+
if (!options?.cwd) throw new Error("Landing Git commands require an explicit working directory.");
|
|
115
|
+
let routedArgs = args;
|
|
116
|
+
if (args[0] === "fetch") {
|
|
117
|
+
routedArgs = ["fetch", "--no-prune", "--no-prune-tags", "--refmap=", ...args.slice(1)];
|
|
118
|
+
} else if (args[0] === "pull") {
|
|
119
|
+
routedArgs = [
|
|
120
|
+
"-c", "fetch.prune=false", "-c", "fetch.pruneTags=false",
|
|
121
|
+
"-c", `remote.${remote}.prune=false`, "-c", `remote.${remote}.pruneTags=false`,
|
|
122
|
+
"pull", "--no-tags", "--no-prune", "--refmap=", "--no-recurse-submodules", ...args.slice(1),
|
|
123
|
+
];
|
|
124
|
+
}
|
|
125
|
+
return pi.exec(command, ["-C", options.cwd, ...routedArgs], options);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function newLandingReceipt(root: string, input: LandBranchInput): LandBranchReceipt {
|
|
129
|
+
return {
|
|
130
|
+
repositoryRoot: root,
|
|
131
|
+
remote: input.remote ?? "origin",
|
|
132
|
+
targetBranch: input.targetBranch,
|
|
133
|
+
remoteTargetHead: null,
|
|
134
|
+
sourceBranch: input.sourceBranch,
|
|
135
|
+
sourceHead: null,
|
|
136
|
+
ancestry: { isAncestor: null },
|
|
137
|
+
worktree: { path: null, outcome: "refused", reason: "Not attempted.", deletedIgnoredPaths: [] },
|
|
138
|
+
branch: { outcome: "refused", reason: "Not attempted.", expectedHead: null },
|
|
139
|
+
targetSync: { mode: "not-run", worktreePath: null, before: null, after: null, aheadBehind: null, reason: "Not attempted." },
|
|
140
|
+
steps: [{ step: "repository", outcome: "resolved", reason: null }],
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function pathExists(path: string): Promise<boolean> {
|
|
145
|
+
try {
|
|
146
|
+
await lstat(path);
|
|
147
|
+
return true;
|
|
148
|
+
} catch (error) {
|
|
149
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function registeredBranchEntries(inventory: Inventory, branch: string) {
|
|
155
|
+
return inventory.entries.filter((entry) => entry.record.branch === branch);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function resolveLandingWorktree(
|
|
159
|
+
inventory: Inventory,
|
|
160
|
+
input: LandBranchInput,
|
|
161
|
+
): Promise<string | null> {
|
|
162
|
+
if (input.worktreePath !== undefined) {
|
|
163
|
+
const path = await canonicalizePathAllowMissing(validateWorktreePathInput(input.worktreePath));
|
|
164
|
+
if (!path) throw new Error("worktreePath could not be resolved.");
|
|
165
|
+
return requireLosslessWorktreeIdentity(path, "cwd");
|
|
166
|
+
}
|
|
167
|
+
const entries = registeredBranchEntries(inventory, input.sourceBranch).filter((entry) => entry.index !== 0);
|
|
168
|
+
if (entries.length > 1) throw new Error("Source branch occupies multiple worktrees; provide an exact worktreePath.");
|
|
169
|
+
if (entries.length === 0) return null;
|
|
170
|
+
const path = entries[0].canonicalPath;
|
|
171
|
+
if (!path) throw new Error("Source worktree path could not be resolved.");
|
|
172
|
+
return requireLosslessWorktreeIdentity(path, "cwd");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function requireSafeLandingCwd(cwd: string, worktreePath: string | null): Promise<void> {
|
|
176
|
+
if (worktreePath === null) return;
|
|
177
|
+
const callerCwd = await realpath(cwd);
|
|
178
|
+
const processCwd = await realpath(process.cwd());
|
|
179
|
+
if (pathIsInsideOrEqual(callerCwd, worktreePath) || pathIsInsideOrEqual(processCwd, worktreePath)) {
|
|
180
|
+
throw new Error("The running cwd is inside worktreePath; run from the repository root.");
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function captureLocalHead(pi: GitAPI, ctx: GitCommandContext, branch: string, signal?: AbortSignal) {
|
|
185
|
+
const ref = await inspectDirectLocalBranchRef(pi, ctx, branch, signal);
|
|
186
|
+
if (ref.status === "absent") return null;
|
|
187
|
+
const head = await getLocalBranchCommit(pi, ctx, branch, signal);
|
|
188
|
+
if (ref.objectId !== head) throw new Error("Local branch is not a direct ref to its captured commit.");
|
|
189
|
+
return head;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function requireIdleWorktree(pi: GitAPI, ctx: GitCommandContext, signal?: AbortSignal): Promise<void> {
|
|
193
|
+
const state = await getGitOperationState(pi, ctx, signal);
|
|
194
|
+
if (state.active.length > 0) throw new Error("Worktree has an in-progress Git operation; it will not be touched.");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function removeLandingWorktree(pi: GitAPI, receipt: LandBranchReceipt, signal?: AbortSignal): Promise<void> {
|
|
198
|
+
const worktree = receipt.worktree;
|
|
199
|
+
const ctx = { cwd: receipt.repositoryRoot };
|
|
200
|
+
const inventory = await collectWorktreeInventory(pi, ctx, signal);
|
|
201
|
+
if (worktree.path === null) {
|
|
202
|
+
if (registeredBranchEntries(inventory, receipt.sourceBranch).some((entry) => entry.index !== 0)) {
|
|
203
|
+
throw new Error("Source branch became occupied after preflight.");
|
|
204
|
+
}
|
|
205
|
+
worktree.outcome = "absent";
|
|
206
|
+
worktree.reason = null;
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const matches = inventory.entries.filter((entry) => entry.canonicalPath === worktree.path);
|
|
210
|
+
if (matches.some((entry) => entry.index === 0) || worktree.path === receipt.repositoryRoot) {
|
|
211
|
+
throw new Error("The primary checkout / repository root can never be removed.");
|
|
212
|
+
}
|
|
213
|
+
if (!(await pathExists(worktree.path))) {
|
|
214
|
+
worktree.outcome = "absent";
|
|
215
|
+
worktree.reason = matches.length > 0 ? "Directory absent; registration retained (no prune)." : null;
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
const validated = await validateWorktreeRemovalPath(pi, ctx, worktree.path, signal);
|
|
219
|
+
if (![receipt.sourceBranch, receipt.targetBranch].includes(validated.worktree.branch ?? "")) {
|
|
220
|
+
throw new Error("The selected worktree must hold the source or target branch, not an unrelated branch.");
|
|
221
|
+
}
|
|
222
|
+
if (receipt.sourceHead === null) throw new Error("Source branch is absent; cannot prove this existing worktree is merged.");
|
|
223
|
+
const sourceHead = await captureLocalHead(pi, ctx, receipt.sourceBranch, signal);
|
|
224
|
+
if (sourceHead !== receipt.sourceHead) throw new Error("Source branch moved after the ancestry proof; no worktree removed.");
|
|
225
|
+
const worktreeHead = validated.worktree.head;
|
|
226
|
+
if (!worktreeHead || !receipt.remoteTargetHead ||
|
|
227
|
+
!(await isCommitAncestor(pi, ctx, worktreeHead, receipt.remoteTargetHead, signal))) {
|
|
228
|
+
throw new Error("Selected worktree HEAD is not merged into the fetched target.");
|
|
229
|
+
}
|
|
230
|
+
await requireIdleWorktree(pi, { cwd: worktree.path }, signal);
|
|
231
|
+
const removed = await removeWorktreeWithinQueue(pi, ctx, worktree.path, signal, true, {
|
|
232
|
+
branch: validated.worktree.branch!, head: worktreeHead,
|
|
233
|
+
});
|
|
234
|
+
if (await pathExists(worktree.path)) throw new Error("Git unregistered the worktree but its directory is still present.");
|
|
235
|
+
worktree.outcome = "removed";
|
|
236
|
+
worktree.reason = null;
|
|
237
|
+
worktree.deletedIgnoredPaths = removed.deletedIgnoredPaths;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function retireLandingBranch(pi: GitAPI, receipt: LandBranchReceipt, signal?: AbortSignal): Promise<void> {
|
|
241
|
+
const ctx = { cwd: receipt.repositoryRoot };
|
|
242
|
+
const head = await captureLocalHead(pi, ctx, receipt.sourceBranch, signal);
|
|
243
|
+
if (head === null) {
|
|
244
|
+
receipt.branch.outcome = "absent";
|
|
245
|
+
receipt.branch.reason = null;
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (receipt.worktree.outcome === "refused") throw new Error("Worktree removal was refused; source branch retained.");
|
|
249
|
+
if (head !== receipt.sourceHead || receipt.remoteTargetHead === null) {
|
|
250
|
+
throw new Error("Source branch moved after the captured expected-HEAD lease; branch retained.");
|
|
251
|
+
}
|
|
252
|
+
await retireBranchWithinQueue(pi, ctx, {
|
|
253
|
+
branchName: receipt.sourceBranch,
|
|
254
|
+
expectedHead: head,
|
|
255
|
+
targetBranch: `${receipt.remote}/${receipt.targetBranch}`,
|
|
256
|
+
force: false,
|
|
257
|
+
}, ctx.cwd, signal, "remote-tracking", receipt.remoteTargetHead);
|
|
258
|
+
receipt.branch.outcome = "deleted";
|
|
259
|
+
receipt.branch.reason = null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function requireLandingPullPolicy(pi: GitAPI, ctx: GitCommandContext, branch: string, signal?: AbortSignal): Promise<void> {
|
|
263
|
+
const args = ["config", "--get-all", `branch.${branch}.mergeOptions`];
|
|
264
|
+
const result = await runGit(pi, ctx, args, { signal, allowFailure: true });
|
|
265
|
+
if (result.code === 1) return;
|
|
266
|
+
if (result.code !== 0) throw new Error(formatGitFailure(args, result));
|
|
267
|
+
if (result.stdout.trim()) {
|
|
268
|
+
throw new Error("Target has branch-specific mergeOptions; sync refused to preserve the fixed fast-forward policy.");
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function syncLandingTarget(pi: GitAPI, receipt: LandBranchReceipt, signal?: AbortSignal): Promise<void> {
|
|
273
|
+
const ctx = { cwd: receipt.repositoryRoot };
|
|
274
|
+
const sync = receipt.targetSync;
|
|
275
|
+
sync.before = await captureLocalHead(pi, ctx, receipt.targetBranch, signal);
|
|
276
|
+
if (sync.before === null) throw new Error("Local target branch is absent; landing will not create it.");
|
|
277
|
+
const inventory = await collectWorktreeInventory(pi, ctx, signal);
|
|
278
|
+
const entries = registeredBranchEntries(inventory, receipt.targetBranch);
|
|
279
|
+
if (entries.length > 1) throw new Error("Target branch is checked out in multiple worktrees; sync refused.");
|
|
280
|
+
if (entries.length === 1) {
|
|
281
|
+
sync.worktreePath = entries[0].canonicalPath;
|
|
282
|
+
if (!sync.worktreePath) throw new Error("Target worktree path could not be resolved.");
|
|
283
|
+
requireLosslessWorktreeIdentity(sync.worktreePath, "cwd");
|
|
284
|
+
}
|
|
285
|
+
if (sync.before === receipt.remoteTargetHead) {
|
|
286
|
+
sync.mode = "noop";
|
|
287
|
+
sync.reason = null;
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (sync.worktreePath !== null) {
|
|
291
|
+
const targetCtx = { cwd: sync.worktreePath };
|
|
292
|
+
const root = await getCanonicalGitWorktreeRoot(pi, targetCtx, signal);
|
|
293
|
+
if (root !== sync.worktreePath ||
|
|
294
|
+
await getCanonicalCommonGitDirectory(pi, targetCtx, signal) !== await getCanonicalCommonGitDirectory(pi, ctx, signal) ||
|
|
295
|
+
(await getCurrentBranch(pi, targetCtx, signal)).currentBranch !== receipt.targetBranch) {
|
|
296
|
+
throw new Error("Target checkout identity changed; sync refused.");
|
|
297
|
+
}
|
|
298
|
+
const status = await getWorkingTreeStatus(pi, targetCtx, signal);
|
|
299
|
+
if (status.workingTree.state !== "clean") {
|
|
300
|
+
sync.mode = "skipped-dirty";
|
|
301
|
+
sync.reason = "Target checkout is dirty; no pull attempted.";
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
await requireIdleWorktree(pi, targetCtx, signal);
|
|
305
|
+
await requireLandingPullPolicy(pi, targetCtx, receipt.targetBranch, signal);
|
|
306
|
+
await runGit(pi, targetCtx, ["pull", "--ff-only", "--no-rebase", "--no-autostash", receipt.remote, `refs/heads/${receipt.targetBranch}`], {
|
|
307
|
+
signal, timeout: GIT_PULL_TIMEOUT_MS,
|
|
308
|
+
});
|
|
309
|
+
sync.mode = "pull-ff";
|
|
310
|
+
} else {
|
|
311
|
+
await runGit(pi, ctx, ["fetch", "--no-tags", "--no-recurse-submodules", receipt.remote, `refs/heads/${receipt.targetBranch}:refs/heads/${receipt.targetBranch}`], {
|
|
312
|
+
signal, timeout: GIT_FETCH_TIMEOUT_MS,
|
|
313
|
+
});
|
|
314
|
+
sync.mode = "fetch-refspec";
|
|
315
|
+
}
|
|
316
|
+
sync.reason = null;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function recordTargetAfter(pi: GitAPI, receipt: LandBranchReceipt): Promise<void> {
|
|
320
|
+
const ctx = { cwd: receipt.repositoryRoot };
|
|
321
|
+
const sync = receipt.targetSync;
|
|
322
|
+
sync.after = await captureLocalHead(pi, ctx, receipt.targetBranch);
|
|
323
|
+
if (sync.after !== null && receipt.remoteTargetHead !== null) {
|
|
324
|
+
sync.aheadBehind = await getAheadBehindCount(pi, ctx, undefined, `${sync.after}...${receipt.remoteTargetHead}`);
|
|
325
|
+
}
|
|
326
|
+
if (["fetch-refspec", "pull-ff"].includes(sync.mode)) {
|
|
327
|
+
if (sync.before === null || sync.after === null ||
|
|
328
|
+
!(await isCommitAncestor(pi, ctx, sync.before, sync.after)) ||
|
|
329
|
+
receipt.remoteTargetHead === null || !(await isCommitAncestor(pi, ctx, receipt.remoteTargetHead, sync.after)) ||
|
|
330
|
+
(sync.after === sync.before && sync.before !== receipt.remoteTargetHead)) {
|
|
331
|
+
throw new Error("Git did not produce a verified target fast-forward; inspect the recorded before/after refs.");
|
|
332
|
+
}
|
|
333
|
+
} else if (["noop", "skipped-dirty"].includes(sync.mode) && sync.before !== sync.after) {
|
|
334
|
+
throw new Error("Target ref moved concurrently during a read-only sync decision.");
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function finishLandingSync(pi: GitAPI, receipt: LandBranchReceipt, signal?: AbortSignal): Promise<void> {
|
|
339
|
+
try {
|
|
340
|
+
await syncLandingTarget(pi, receipt, signal);
|
|
341
|
+
} catch (error) {
|
|
342
|
+
receipt.targetSync.mode = "failed";
|
|
343
|
+
receipt.targetSync.reason = landingError(error);
|
|
344
|
+
}
|
|
345
|
+
try {
|
|
346
|
+
// Verification is deliberately uncancelled after a possible mutation.
|
|
347
|
+
await recordTargetAfter(pi, receipt);
|
|
348
|
+
} catch (error) {
|
|
349
|
+
receipt.targetSync.mode = "failed";
|
|
350
|
+
receipt.targetSync.reason = landingError(error);
|
|
351
|
+
}
|
|
352
|
+
receipt.steps.push({ step: "targetSync", outcome: receipt.targetSync.mode, reason: receipt.targetSync.reason });
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function refuseLanding(pi: GitAPI, receipt: LandBranchReceipt, error: unknown): Promise<LandBranchReceipt> {
|
|
356
|
+
const reason = landingError(error);
|
|
357
|
+
receipt.worktree.reason = reason;
|
|
358
|
+
receipt.branch.reason = reason;
|
|
359
|
+
receipt.targetSync.reason = reason;
|
|
360
|
+
try {
|
|
361
|
+
receipt.targetSync.before ??= await captureLocalHead(pi, { cwd: receipt.repositoryRoot }, receipt.targetBranch);
|
|
362
|
+
await recordTargetAfter(pi, receipt);
|
|
363
|
+
} catch (verificationError) {
|
|
364
|
+
receipt.targetSync.reason = `${reason} Verification: ${landingError(verificationError)}`;
|
|
365
|
+
}
|
|
366
|
+
const recorded = new Set(receipt.steps.map((step) => step.step));
|
|
367
|
+
for (const step of ["cwd", "fetch", "ancestry", "worktree", "branch", "targetSync"] as const) {
|
|
368
|
+
if (!recorded.has(step)) receipt.steps.push({ step, outcome: "not-run", reason });
|
|
369
|
+
}
|
|
370
|
+
return receipt;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async function landBranchWithinQueue(
|
|
374
|
+
pi: GitAPI,
|
|
375
|
+
cwd: string,
|
|
376
|
+
root: string,
|
|
377
|
+
input: LandBranchInput,
|
|
378
|
+
signal?: AbortSignal,
|
|
379
|
+
): Promise<LandBranchReceipt> {
|
|
380
|
+
const ctx = { cwd: root };
|
|
381
|
+
const receipt = newLandingReceipt(root, input);
|
|
382
|
+
let step: "cwd" | "fetch" | "ancestry" = "cwd";
|
|
383
|
+
try {
|
|
384
|
+
const inventory = await collectWorktreeInventory(pi, ctx, signal);
|
|
385
|
+
receipt.worktree.path = await resolveLandingWorktree(inventory, input);
|
|
386
|
+
await requireSafeLandingCwd(cwd, receipt.worktree.path);
|
|
387
|
+
receipt.steps.push({ step: "cwd", outcome: "safe", reason: null });
|
|
388
|
+
step = "fetch";
|
|
389
|
+
await validateBranchName(pi, ctx, input.sourceBranch, signal);
|
|
390
|
+
await validateBranchName(pi, ctx, input.targetBranch, signal);
|
|
391
|
+
receipt.sourceHead = await captureLocalHead(pi, ctx, input.sourceBranch, signal);
|
|
392
|
+
receipt.branch.expectedHead = receipt.sourceHead;
|
|
393
|
+
receipt.targetSync.before = await captureLocalHead(pi, ctx, input.targetBranch, signal);
|
|
394
|
+
if (receipt.targetSync.before === null) throw new Error("Local target branch does not exist.");
|
|
395
|
+
await fetchRemoteBranchWithinQueue(pi, ctx, receipt.remote, receipt.targetBranch, signal);
|
|
396
|
+
const tracking = `${receipt.remote}/${receipt.targetBranch}`;
|
|
397
|
+
const ref = await inspectDirectRemoteTrackingRef(pi, ctx, tracking, signal);
|
|
398
|
+
receipt.remoteTargetHead = await getRemoteTrackingRefCommit(pi, ctx, tracking, signal);
|
|
399
|
+
if (ref.status !== "present" || ref.objectId !== receipt.remoteTargetHead) {
|
|
400
|
+
throw new Error("Fetched target is not a direct ref to its captured commit.");
|
|
401
|
+
}
|
|
402
|
+
receipt.steps.push({ step: "fetch", outcome: "fetched", reason: null });
|
|
403
|
+
step = "ancestry";
|
|
404
|
+
if (receipt.sourceHead !== null) {
|
|
405
|
+
receipt.ancestry.isAncestor = await isCommitAncestor(pi, ctx, receipt.sourceHead, receipt.remoteTargetHead, signal);
|
|
406
|
+
if (!receipt.ancestry.isAncestor) throw new Error("Source branch is not merged into the fetched remote target; landing refused.");
|
|
407
|
+
}
|
|
408
|
+
receipt.steps.push({ step: "ancestry", outcome: receipt.sourceHead === null ? "absent" : "verified", reason: null });
|
|
409
|
+
} catch (error) {
|
|
410
|
+
receipt.steps.push({ step, outcome: "refused", reason: landingError(error) });
|
|
411
|
+
return refuseLanding(pi, receipt, error);
|
|
412
|
+
}
|
|
413
|
+
try {
|
|
414
|
+
await removeLandingWorktree(pi, receipt, signal);
|
|
415
|
+
} catch (error) {
|
|
416
|
+
receipt.worktree.reason = landingError(error);
|
|
417
|
+
}
|
|
418
|
+
receipt.steps.push({ step: "worktree", outcome: receipt.worktree.outcome, reason: receipt.worktree.reason });
|
|
419
|
+
try {
|
|
420
|
+
await retireLandingBranch(pi, receipt, signal);
|
|
421
|
+
} catch (error) {
|
|
422
|
+
receipt.branch.reason = landingError(error);
|
|
423
|
+
}
|
|
424
|
+
receipt.steps.push({ step: "branch", outcome: receipt.branch.outcome, reason: receipt.branch.reason });
|
|
425
|
+
await finishLandingSync(pi, receipt, signal);
|
|
426
|
+
return receipt;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
export async function landBranch(pi: GitAPI, ctx: GitCommandContext, input: LandBranchInput, signal?: AbortSignal): Promise<LandBranchReceipt> {
|
|
430
|
+
validateLandingInput(input);
|
|
431
|
+
const routedPi: GitAPI = { exec: executeLandingGit.bind(undefined, pi, input.remote ?? "origin") };
|
|
432
|
+
const callerRoot = await getCanonicalGitWorktreeRoot(routedPi, ctx, signal);
|
|
433
|
+
const inventory = await collectWorktreeInventory(routedPi, { cwd: callerRoot }, signal);
|
|
434
|
+
const primary = inventory.entries[0];
|
|
435
|
+
if (!primary?.canonicalPath || primary.record.bare) throw new Error("Landing requires a primary non-bare checkout.");
|
|
436
|
+
const root = await getCanonicalGitWorktreeRoot(routedPi, { cwd: primary.canonicalPath }, signal);
|
|
437
|
+
requireLosslessWorktreeIdentity(root, "cwd");
|
|
438
|
+
if (root !== primary.canonicalPath ||
|
|
439
|
+
await getCanonicalCommonGitDirectory(routedPi, { cwd: root }, signal) !== await getCanonicalCommonGitDirectory(routedPi, { cwd: callerRoot }, signal)) {
|
|
440
|
+
throw new Error("Primary checkout repository identity could not be verified.");
|
|
441
|
+
}
|
|
442
|
+
return withRepositoryMutationQueue(root, landBranchWithinQueue.bind(undefined, routedPi, ctx.cwd, root, input, signal));
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
export function formatLandBranch(receipt: LandBranchReceipt): string {
|
|
446
|
+
const summary = `land_branch: worktree ${receipt.worktree.outcome}; branch ${receipt.branch.outcome}; target sync ${receipt.targetSync.mode}.`;
|
|
447
|
+
const reason = receipt.worktree.reason ?? receipt.branch.reason ?? receipt.targetSync.reason;
|
|
448
|
+
return reason ? `${summary} ${landingError(reason)}` : summary;
|
|
449
|
+
}
|
package/src/git-retirement.ts
CHANGED
|
@@ -10,7 +10,9 @@ import {
|
|
|
10
10
|
getCanonicalCommonGitDirectory,
|
|
11
11
|
getCanonicalGitWorktreeRoot,
|
|
12
12
|
getLocalBranchCommit,
|
|
13
|
+
getRemoteTrackingRefCommit,
|
|
13
14
|
inspectDirectLocalBranchRef,
|
|
15
|
+
inspectDirectRemoteTrackingRef,
|
|
14
16
|
inspectLocalBranchWorktreeOccupancy,
|
|
15
17
|
isCommitAncestor,
|
|
16
18
|
isLosslessGitMetadata,
|
|
@@ -36,6 +38,8 @@ import type {
|
|
|
36
38
|
const FULL_OBJECT_ID_PATTERN = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/iu;
|
|
37
39
|
const RETIREMENT_REQUEST_FIELDS = new Set(["branchName", "expectedHead", "targetBranch", "force"]);
|
|
38
40
|
|
|
41
|
+
type RetirementTargetScope = "local" | "remote-tracking";
|
|
42
|
+
|
|
39
43
|
export interface PreparedBranchRetirement {
|
|
40
44
|
worktreeRoot: string;
|
|
41
45
|
canonicalCommonGitDirectory: string;
|
|
@@ -62,7 +66,7 @@ function normalizeExpectedHead(value: unknown): string {
|
|
|
62
66
|
return value.toLowerCase();
|
|
63
67
|
}
|
|
64
68
|
|
|
65
|
-
function validateRetirementRequest(request: unknown): RetireBranchToolInput {
|
|
69
|
+
function validateRetirementRequest(request: unknown, targetScope: RetirementTargetScope = "local"): RetireBranchToolInput {
|
|
66
70
|
if (typeof request !== "object" || request === null || Array.isArray(request)) {
|
|
67
71
|
throw new TypeError("Branch retirement request must be an object.");
|
|
68
72
|
}
|
|
@@ -78,7 +82,7 @@ function validateRetirementRequest(request: unknown): RetireBranchToolInput {
|
|
|
78
82
|
|
|
79
83
|
validateRetirementBranchName(values.branchName, "Retiring branch");
|
|
80
84
|
validateRetirementBranchName(values.targetBranch, "Target branch");
|
|
81
|
-
if (values.branchName === values.targetBranch) {
|
|
85
|
+
if (targetScope === "local" && values.branchName === values.targetBranch) {
|
|
82
86
|
throw new Error("Retiring branch and target branch must be distinct local branches.");
|
|
83
87
|
}
|
|
84
88
|
const expectedHead = normalizeExpectedHead(values.expectedHead);
|
|
@@ -164,13 +168,26 @@ function retirementRefIdentity(
|
|
|
164
168
|
};
|
|
165
169
|
}
|
|
166
170
|
|
|
171
|
+
function retirementTargetScope(prepared: PreparedBranchRetirement): RetirementTargetScope {
|
|
172
|
+
return prepared.target.fullRef.startsWith("refs/remotes/") ? "remote-tracking" : "local";
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function targetRefInspector(scope: RetirementTargetScope) {
|
|
176
|
+
return scope === "local" ? inspectDirectLocalBranchRef : inspectDirectRemoteTrackingRef;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function targetCommitResolver(scope: RetirementTargetScope) {
|
|
180
|
+
return scope === "local" ? getLocalBranchCommit : getRemoteTrackingRefCommit;
|
|
181
|
+
}
|
|
182
|
+
|
|
167
183
|
export async function prepareBranchRetirement(
|
|
168
184
|
pi: Pick<ExtensionAPI, "exec">,
|
|
169
185
|
ctx: GitCommandContext,
|
|
170
186
|
requestValue: unknown,
|
|
171
187
|
signal?: AbortSignal,
|
|
188
|
+
targetScope: RetirementTargetScope = "local",
|
|
172
189
|
): Promise<PreparedBranchRetirement> {
|
|
173
|
-
const request = validateRetirementRequest(requestValue);
|
|
190
|
+
const request = validateRetirementRequest(requestValue, targetScope);
|
|
174
191
|
const worktreeRoot = await getCanonicalGitWorktreeRoot(pi, ctx, signal);
|
|
175
192
|
const rootCtx = { cwd: worktreeRoot };
|
|
176
193
|
const canonicalCommonGitDirectory = await getCanonicalCommonGitDirectory(pi, rootCtx, signal);
|
|
@@ -182,11 +199,11 @@ export async function prepareBranchRetirement(
|
|
|
182
199
|
"Retiring",
|
|
183
200
|
);
|
|
184
201
|
const targetRef = requirePresentDirectRef(
|
|
185
|
-
await
|
|
202
|
+
await targetRefInspector(targetScope)(pi, rootCtx, request.targetBranch, signal),
|
|
186
203
|
"Target",
|
|
187
204
|
);
|
|
188
205
|
const retiringHead = await getLocalBranchCommit(pi, rootCtx, request.branchName, signal);
|
|
189
|
-
const targetHead = await
|
|
206
|
+
const targetHead = await targetCommitResolver(targetScope)(pi, rootCtx, request.targetBranch, signal);
|
|
190
207
|
requireDirectRefCommitIdentity(retiringRef, retiringHead, "Retiring");
|
|
191
208
|
requireDirectRefCommitIdentity(targetRef, targetHead, "Target");
|
|
192
209
|
if (!sameObjectIdentity(retiringHead, request.expectedHead)) {
|
|
@@ -328,7 +345,7 @@ async function inspectImmediateRetirementState(
|
|
|
328
345
|
"Retiring",
|
|
329
346
|
);
|
|
330
347
|
const targetRef = requirePresentDirectRef(
|
|
331
|
-
await
|
|
348
|
+
await targetRefInspector(retirementTargetScope(prepared))(pi, rootCtx, prepared.request.targetBranch, signal),
|
|
332
349
|
"Target",
|
|
333
350
|
);
|
|
334
351
|
const retiringHead = await getLocalBranchCommit(
|
|
@@ -337,7 +354,7 @@ async function inspectImmediateRetirementState(
|
|
|
337
354
|
prepared.request.branchName,
|
|
338
355
|
signal,
|
|
339
356
|
);
|
|
340
|
-
const targetHead = await
|
|
357
|
+
const targetHead = await targetCommitResolver(retirementTargetScope(prepared))(
|
|
341
358
|
pi,
|
|
342
359
|
rootCtx,
|
|
343
360
|
prepared.request.targetBranch,
|
|
@@ -445,7 +462,7 @@ async function inspectPostRetirementState(
|
|
|
445
462
|
rootCtx,
|
|
446
463
|
prepared.request.branchName,
|
|
447
464
|
);
|
|
448
|
-
const targetRef = await
|
|
465
|
+
const targetRef = await targetRefInspector(retirementTargetScope(prepared))(
|
|
449
466
|
pi,
|
|
450
467
|
rootCtx,
|
|
451
468
|
prepared.request.targetBranch,
|
|
@@ -458,7 +475,7 @@ async function inspectPostRetirementState(
|
|
|
458
475
|
}
|
|
459
476
|
let targetHead: string | null = null;
|
|
460
477
|
if (targetRef.status === "present") {
|
|
461
|
-
targetHead = await
|
|
478
|
+
targetHead = await targetCommitResolver(retirementTargetScope(prepared))(pi, rootCtx, prepared.request.targetBranch);
|
|
462
479
|
requireDirectRefCommitIdentity(targetRef, targetHead, "Target");
|
|
463
480
|
targetHead = targetHead.toLowerCase();
|
|
464
481
|
}
|
|
@@ -610,14 +627,20 @@ function classifyRetirementOutcome(
|
|
|
610
627
|
);
|
|
611
628
|
}
|
|
612
629
|
|
|
613
|
-
|
|
630
|
+
// Caller must hold the repository mutation queue; remote targets are used only by land_branch.
|
|
631
|
+
export async function retireBranchWithinQueue(
|
|
614
632
|
pi: Pick<ExtensionAPI, "exec">,
|
|
615
633
|
ctx: GitCommandContext,
|
|
616
634
|
request: RetireBranchToolInput,
|
|
617
635
|
queuedWorktreeRoot: string,
|
|
618
636
|
signal?: AbortSignal,
|
|
637
|
+
targetScope: RetirementTargetScope = "local",
|
|
638
|
+
expectedTargetHead?: string,
|
|
619
639
|
): Promise<RetireBranchDetails> {
|
|
620
|
-
const prepared = await prepareBranchRetirement(pi, ctx, request, signal);
|
|
640
|
+
const prepared = await prepareBranchRetirement(pi, ctx, request, signal, targetScope);
|
|
641
|
+
if (expectedTargetHead !== undefined && prepared.target.head !== expectedTargetHead) {
|
|
642
|
+
throw new Error("The fetched retirement target moved after the landing ancestry proof.");
|
|
643
|
+
}
|
|
621
644
|
if (prepared.worktreeRoot !== queuedWorktreeRoot) {
|
|
622
645
|
throw new Error("The active worktree changed while preparing branch retirement.");
|
|
623
646
|
}
|