@git.zone/cli 4.0.0 → 5.0.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/.smartconfig.json +0 -1
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/helpers.workflow.d.ts +0 -2
- package/dist_ts/helpers.workflow.js +23 -12
- package/dist_ts/mod_commit/mod.helpers.d.ts +3 -3
- package/dist_ts/mod_commit/mod.helpers.js +10 -10
- package/dist_ts/mod_config/index.js +22 -23
- package/dist_ts/mod_release/helpers.releasebranch.d.ts +46 -0
- package/dist_ts/mod_release/helpers.releasebranch.js +579 -0
- package/dist_ts/mod_release/index.d.ts +10 -0
- package/dist_ts/mod_release/index.js +300 -92
- package/package.json +1 -1
- package/readme.md +21 -13
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/helpers.workflow.ts +41 -12
- package/ts/mod_commit/mod.helpers.ts +12 -8
- package/ts/mod_config/index.ts +21 -22
- package/ts/mod_release/helpers.releasebranch.ts +1282 -0
- package/ts/mod_release/index.ts +608 -129
|
@@ -0,0 +1,1282 @@
|
|
|
1
|
+
import * as plugins from "./mod.plugins.js";
|
|
2
|
+
|
|
3
|
+
const mainBranch = "main";
|
|
4
|
+
const mainBranchRef = `refs/heads/${mainBranch}`;
|
|
5
|
+
|
|
6
|
+
interface IGitWorktree {
|
|
7
|
+
path: string;
|
|
8
|
+
head?: string;
|
|
9
|
+
branch?: string;
|
|
10
|
+
prunable: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface IReleaseBranchContext {
|
|
14
|
+
sourceCwd: string;
|
|
15
|
+
sourceBranch: string;
|
|
16
|
+
sourceRef: string;
|
|
17
|
+
sourceOid: string;
|
|
18
|
+
gitRemote: string;
|
|
19
|
+
localMainOid?: string;
|
|
20
|
+
remoteMainOid?: string;
|
|
21
|
+
mainWorktreePath?: string;
|
|
22
|
+
mainWorktreeGitDir?: string;
|
|
23
|
+
pushUrl?: string;
|
|
24
|
+
mergeRequested: boolean;
|
|
25
|
+
planAncestryVerified: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface IIntegratedReleaseBranch {
|
|
29
|
+
releaseCwd: string;
|
|
30
|
+
pushUrl: string;
|
|
31
|
+
expectedRemoteMainOid: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface IInspectReleaseBranchOptions {
|
|
35
|
+
smartshell: TSmartshell;
|
|
36
|
+
cwd: string;
|
|
37
|
+
gitRemote: string;
|
|
38
|
+
gitTargetActive: boolean;
|
|
39
|
+
pushBranch: boolean;
|
|
40
|
+
pushTags?: boolean;
|
|
41
|
+
mergeRequested: boolean;
|
|
42
|
+
planMode: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type TSmartshell = plugins.smartshell.Smartshell;
|
|
46
|
+
|
|
47
|
+
export const releaseGitEnv: NodeJS.ProcessEnv = {
|
|
48
|
+
...process.env,
|
|
49
|
+
GIT_NO_REPLACE_OBJECTS: "1",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const readOnlyGitEnv: NodeJS.ProcessEnv = {
|
|
53
|
+
...releaseGitEnv,
|
|
54
|
+
GIT_NO_LAZY_FETCH: "1",
|
|
55
|
+
GIT_OPTIONAL_LOCKS: "0",
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export function resolveReleaseMergeFlag(
|
|
59
|
+
argvArg: Record<string, unknown>,
|
|
60
|
+
): boolean {
|
|
61
|
+
if (argvArg.merge === undefined || argvArg.merge === false) {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
if (argvArg.merge !== true) {
|
|
65
|
+
throw new Error("`--merge` is a boolean flag and does not accept a value.");
|
|
66
|
+
}
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function parseGitWorktreePorcelain(outputArg: string): IGitWorktree[] {
|
|
71
|
+
const worktrees: IGitWorktree[] = [];
|
|
72
|
+
let current: Partial<IGitWorktree> = {};
|
|
73
|
+
|
|
74
|
+
const flush = () => {
|
|
75
|
+
if (current.path) {
|
|
76
|
+
worktrees.push({
|
|
77
|
+
path: current.path,
|
|
78
|
+
head: current.head,
|
|
79
|
+
branch: current.branch,
|
|
80
|
+
prunable: current.prunable ?? false,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
current = {};
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
for (const field of outputArg.split("\0")) {
|
|
87
|
+
if (!field) {
|
|
88
|
+
flush();
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const separatorIndex = field.indexOf(" ");
|
|
92
|
+
const key = separatorIndex === -1 ? field : field.slice(0, separatorIndex);
|
|
93
|
+
const value = separatorIndex === -1 ? "" : field.slice(separatorIndex + 1);
|
|
94
|
+
if (key === "worktree") {
|
|
95
|
+
flush();
|
|
96
|
+
current.path = value;
|
|
97
|
+
} else if (key === "HEAD") {
|
|
98
|
+
current.head = value;
|
|
99
|
+
} else if (key === "branch") {
|
|
100
|
+
current.branch = value;
|
|
101
|
+
} else if (key === "prunable") {
|
|
102
|
+
current.prunable = true;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
flush();
|
|
106
|
+
return worktrees;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const runGit = async (
|
|
110
|
+
smartshellArg: TSmartshell,
|
|
111
|
+
cwdArg: string,
|
|
112
|
+
argsArg: string[],
|
|
113
|
+
readOnlyArg = false,
|
|
114
|
+
) =>
|
|
115
|
+
smartshellArg.execSpawn("git", argsArg, {
|
|
116
|
+
cwd: cwdArg,
|
|
117
|
+
env: readOnlyArg ? readOnlyGitEnv : releaseGitEnv,
|
|
118
|
+
silent: true,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const requireGitSuccess = (
|
|
122
|
+
resultArg: plugins.smartshell.IExecResult,
|
|
123
|
+
messageArg: string,
|
|
124
|
+
): plugins.smartshell.IExecResult => {
|
|
125
|
+
if (resultArg.exitCode !== 0) {
|
|
126
|
+
throw new Error(messageArg);
|
|
127
|
+
}
|
|
128
|
+
return resultArg;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const resolveSymbolicBranch = async (
|
|
132
|
+
smartshellArg: TSmartshell,
|
|
133
|
+
cwdArg: string,
|
|
134
|
+
readOnlyArg = false,
|
|
135
|
+
): Promise<{ branch: string; ref: string }> => {
|
|
136
|
+
const result = await runGit(
|
|
137
|
+
smartshellArg,
|
|
138
|
+
cwdArg,
|
|
139
|
+
["symbolic-ref", "--quiet", "HEAD"],
|
|
140
|
+
readOnlyArg,
|
|
141
|
+
);
|
|
142
|
+
const ref = result.stdout.trim();
|
|
143
|
+
if (result.exitCode !== 0 || !ref.startsWith("refs/heads/")) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
"A release requires a checked-out local branch; detached HEAD is not supported.",
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
branch: ref.slice("refs/heads/".length),
|
|
150
|
+
ref,
|
|
151
|
+
};
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const resolveCommit = async (
|
|
155
|
+
smartshellArg: TSmartshell,
|
|
156
|
+
cwdArg: string,
|
|
157
|
+
revisionArg: string,
|
|
158
|
+
messageArg: string,
|
|
159
|
+
readOnlyArg = false,
|
|
160
|
+
): Promise<string> => {
|
|
161
|
+
const result = requireGitSuccess(
|
|
162
|
+
await runGit(
|
|
163
|
+
smartshellArg,
|
|
164
|
+
cwdArg,
|
|
165
|
+
["rev-parse", "--verify", `${revisionArg}^{commit}`],
|
|
166
|
+
readOnlyArg,
|
|
167
|
+
),
|
|
168
|
+
messageArg,
|
|
169
|
+
);
|
|
170
|
+
return result.stdout.trim();
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const verifyCleanTree = async (
|
|
174
|
+
smartshellArg: TSmartshell,
|
|
175
|
+
cwdArg: string,
|
|
176
|
+
labelArg: string,
|
|
177
|
+
readOnlyArg = false,
|
|
178
|
+
): Promise<void> => {
|
|
179
|
+
const result = requireGitSuccess(
|
|
180
|
+
await runGit(
|
|
181
|
+
smartshellArg,
|
|
182
|
+
cwdArg,
|
|
183
|
+
[
|
|
184
|
+
"status",
|
|
185
|
+
"--porcelain=v1",
|
|
186
|
+
"--untracked-files=all",
|
|
187
|
+
"--ignore-submodules=none",
|
|
188
|
+
],
|
|
189
|
+
readOnlyArg,
|
|
190
|
+
),
|
|
191
|
+
`Unable to inspect the ${labelArg} worktree.`,
|
|
192
|
+
);
|
|
193
|
+
if (result.stdout.trim()) {
|
|
194
|
+
throw new Error(
|
|
195
|
+
`The ${labelArg} worktree is not clean. Commit or stash changes before releasing.`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const pathExists = async (pathArg: string): Promise<boolean> => {
|
|
201
|
+
try {
|
|
202
|
+
await plugins.fs.stat(pathArg);
|
|
203
|
+
return true;
|
|
204
|
+
} catch (error) {
|
|
205
|
+
if (
|
|
206
|
+
typeof error === "object" &&
|
|
207
|
+
error !== null &&
|
|
208
|
+
"code" in error &&
|
|
209
|
+
error.code === "ENOENT"
|
|
210
|
+
) {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
throw new Error(`Unable to inspect Git operation marker ${pathArg}.`, {
|
|
214
|
+
cause: error,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const verifyNoGitOperation = async (
|
|
220
|
+
smartshellArg: TSmartshell,
|
|
221
|
+
cwdArg: string,
|
|
222
|
+
labelArg: string,
|
|
223
|
+
readOnlyArg = false,
|
|
224
|
+
): Promise<void> => {
|
|
225
|
+
const markers = [
|
|
226
|
+
"MERGE_HEAD",
|
|
227
|
+
"CHERRY_PICK_HEAD",
|
|
228
|
+
"REVERT_HEAD",
|
|
229
|
+
"rebase-apply",
|
|
230
|
+
"rebase-merge",
|
|
231
|
+
"sequencer",
|
|
232
|
+
"BISECT_START",
|
|
233
|
+
"BISECT_LOG",
|
|
234
|
+
];
|
|
235
|
+
for (const marker of markers) {
|
|
236
|
+
const pathResult = requireGitSuccess(
|
|
237
|
+
await runGit(
|
|
238
|
+
smartshellArg,
|
|
239
|
+
cwdArg,
|
|
240
|
+
["rev-parse", "--git-path", marker],
|
|
241
|
+
readOnlyArg,
|
|
242
|
+
),
|
|
243
|
+
`Unable to inspect Git operation state in the ${labelArg} worktree.`,
|
|
244
|
+
);
|
|
245
|
+
const markerPath = plugins.path.resolve(cwdArg, pathResult.stdout.trim());
|
|
246
|
+
if (await pathExists(markerPath)) {
|
|
247
|
+
throw new Error(
|
|
248
|
+
`The ${labelArg} worktree has an in-progress Git operation. Complete or abort it before releasing.`,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const verifyNoHistoryOverrides = async (
|
|
255
|
+
smartshellArg: TSmartshell,
|
|
256
|
+
cwdArg: string,
|
|
257
|
+
readOnlyArg = false,
|
|
258
|
+
): Promise<void> => {
|
|
259
|
+
const replaceRefs = requireGitSuccess(
|
|
260
|
+
await runGit(
|
|
261
|
+
smartshellArg,
|
|
262
|
+
cwdArg,
|
|
263
|
+
["for-each-ref", "--format=%(refname)", "refs/replace/"],
|
|
264
|
+
readOnlyArg,
|
|
265
|
+
),
|
|
266
|
+
"Unable to inspect local Git replacement refs.",
|
|
267
|
+
);
|
|
268
|
+
if (replaceRefs.stdout.trim()) {
|
|
269
|
+
throw new Error(
|
|
270
|
+
"Release ancestry cannot be verified while local Git replacement refs exist. Remove them explicitly and retry.",
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const graftsPathResult = requireGitSuccess(
|
|
275
|
+
await runGit(
|
|
276
|
+
smartshellArg,
|
|
277
|
+
cwdArg,
|
|
278
|
+
["rev-parse", "--git-path", "info/grafts"],
|
|
279
|
+
readOnlyArg,
|
|
280
|
+
),
|
|
281
|
+
"Unable to locate the local Git grafts file.",
|
|
282
|
+
);
|
|
283
|
+
const graftsPath = plugins.path.resolve(
|
|
284
|
+
cwdArg,
|
|
285
|
+
graftsPathResult.stdout.trim(),
|
|
286
|
+
);
|
|
287
|
+
try {
|
|
288
|
+
const grafts = await plugins.fs.readFile(graftsPath, "utf8");
|
|
289
|
+
if (grafts.trim()) {
|
|
290
|
+
throw new Error(
|
|
291
|
+
"Release ancestry cannot be verified while a local Git grafts file is active. Remove it explicitly and retry.",
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
} catch (error) {
|
|
295
|
+
if (
|
|
296
|
+
error instanceof Error &&
|
|
297
|
+
"code" in error &&
|
|
298
|
+
(error.code === "ENOENT" || error.code === "ENOTDIR")
|
|
299
|
+
) {
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
throw error;
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const listWorktrees = async (
|
|
307
|
+
smartshellArg: TSmartshell,
|
|
308
|
+
cwdArg: string,
|
|
309
|
+
readOnlyArg = false,
|
|
310
|
+
): Promise<IGitWorktree[]> => {
|
|
311
|
+
const result = requireGitSuccess(
|
|
312
|
+
await runGit(
|
|
313
|
+
smartshellArg,
|
|
314
|
+
cwdArg,
|
|
315
|
+
["worktree", "list", "--porcelain", "-z"],
|
|
316
|
+
readOnlyArg,
|
|
317
|
+
),
|
|
318
|
+
"Unable to inspect Git worktrees.",
|
|
319
|
+
);
|
|
320
|
+
return parseGitWorktreePorcelain(result.stdout);
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
const resolveAbsoluteGitDir = async (
|
|
324
|
+
smartshellArg: TSmartshell,
|
|
325
|
+
cwdArg: string,
|
|
326
|
+
readOnlyArg = false,
|
|
327
|
+
): Promise<string> => {
|
|
328
|
+
const result = requireGitSuccess(
|
|
329
|
+
await runGit(
|
|
330
|
+
smartshellArg,
|
|
331
|
+
cwdArg,
|
|
332
|
+
["rev-parse", "--absolute-git-dir"],
|
|
333
|
+
readOnlyArg,
|
|
334
|
+
),
|
|
335
|
+
"Unable to identify the destination Git worktree.",
|
|
336
|
+
);
|
|
337
|
+
return result.stdout.trim();
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
const resolvePushUrl = async (
|
|
341
|
+
smartshellArg: TSmartshell,
|
|
342
|
+
cwdArg: string,
|
|
343
|
+
remoteArg: string,
|
|
344
|
+
readOnlyArg = false,
|
|
345
|
+
): Promise<string> => {
|
|
346
|
+
const result = requireGitSuccess(
|
|
347
|
+
await runGit(
|
|
348
|
+
smartshellArg,
|
|
349
|
+
cwdArg,
|
|
350
|
+
["remote", "get-url", "--push", "--all", remoteArg],
|
|
351
|
+
readOnlyArg,
|
|
352
|
+
),
|
|
353
|
+
`Unable to resolve the push URL for Git remote ${remoteArg}.`,
|
|
354
|
+
);
|
|
355
|
+
const urls = result.stdout
|
|
356
|
+
.split("\n")
|
|
357
|
+
.map((url) => url.trim())
|
|
358
|
+
.filter(Boolean);
|
|
359
|
+
if (urls.length !== 1) {
|
|
360
|
+
throw new Error(
|
|
361
|
+
`Release merge mode requires exactly one push URL for Git remote ${remoteArg}.`,
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
const pushUrl = urls[0];
|
|
365
|
+
const hasUrlScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(pushUrl);
|
|
366
|
+
const isScpStyle = /^(?:[^@/\\:]+@)?[^/\\:]+:.+/.test(pushUrl);
|
|
367
|
+
const isWindowsAbsolutePath = /^[a-z]:[\\/]/i.test(pushUrl);
|
|
368
|
+
if (
|
|
369
|
+
!hasUrlScheme &&
|
|
370
|
+
!isScpStyle &&
|
|
371
|
+
!plugins.path.isAbsolute(pushUrl) &&
|
|
372
|
+
!isWindowsAbsolutePath
|
|
373
|
+
) {
|
|
374
|
+
return plugins.path.resolve(cwdArg, pushUrl);
|
|
375
|
+
}
|
|
376
|
+
return pushUrl;
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
const resolveRemoteMainOid = async (
|
|
380
|
+
smartshellArg: TSmartshell,
|
|
381
|
+
cwdArg: string,
|
|
382
|
+
pushUrlArg: string,
|
|
383
|
+
readOnlyArg = false,
|
|
384
|
+
): Promise<string> => {
|
|
385
|
+
const result = requireGitSuccess(
|
|
386
|
+
await runGit(
|
|
387
|
+
smartshellArg,
|
|
388
|
+
cwdArg,
|
|
389
|
+
["ls-remote", "--exit-code", pushUrlArg, mainBranchRef],
|
|
390
|
+
readOnlyArg,
|
|
391
|
+
),
|
|
392
|
+
"The configured Git push destination does not expose refs/heads/main.",
|
|
393
|
+
);
|
|
394
|
+
const oid = result.stdout.trim().split(/\s+/)[0];
|
|
395
|
+
if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(oid)) {
|
|
396
|
+
throw new Error(
|
|
397
|
+
"The configured Git push destination returned an invalid main commit ID.",
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
return oid;
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
const hasCommitObject = async (
|
|
404
|
+
smartshellArg: TSmartshell,
|
|
405
|
+
cwdArg: string,
|
|
406
|
+
oidArg: string,
|
|
407
|
+
readOnlyArg = false,
|
|
408
|
+
): Promise<boolean> => {
|
|
409
|
+
const result = await runGit(
|
|
410
|
+
smartshellArg,
|
|
411
|
+
cwdArg,
|
|
412
|
+
["cat-file", "-e", `${oidArg}^{commit}`],
|
|
413
|
+
readOnlyArg,
|
|
414
|
+
);
|
|
415
|
+
return result.exitCode === 0;
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
const isShallowRepository = async (
|
|
419
|
+
smartshellArg: TSmartshell,
|
|
420
|
+
cwdArg: string,
|
|
421
|
+
readOnlyArg = false,
|
|
422
|
+
): Promise<boolean> => {
|
|
423
|
+
const result = requireGitSuccess(
|
|
424
|
+
await runGit(
|
|
425
|
+
smartshellArg,
|
|
426
|
+
cwdArg,
|
|
427
|
+
["rev-parse", "--is-shallow-repository"],
|
|
428
|
+
readOnlyArg,
|
|
429
|
+
),
|
|
430
|
+
"Unable to determine whether the repository is shallow.",
|
|
431
|
+
);
|
|
432
|
+
return result.stdout.trim() === "true";
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
const verifyLinearAncestry = async (
|
|
436
|
+
smartshellArg: TSmartshell,
|
|
437
|
+
cwdArg: string,
|
|
438
|
+
baseOidArg: string,
|
|
439
|
+
sourceOidArg: string,
|
|
440
|
+
readOnlyArg = false,
|
|
441
|
+
): Promise<void> => {
|
|
442
|
+
const ancestryResult = await runGit(
|
|
443
|
+
smartshellArg,
|
|
444
|
+
cwdArg,
|
|
445
|
+
["merge-base", "--is-ancestor", baseOidArg, sourceOidArg],
|
|
446
|
+
readOnlyArg,
|
|
447
|
+
);
|
|
448
|
+
if (ancestryResult.exitCode !== 0) {
|
|
449
|
+
if (await isShallowRepository(smartshellArg, cwdArg, readOnlyArg)) {
|
|
450
|
+
throw new Error(
|
|
451
|
+
"Release merge ancestry cannot be verified in this shallow clone. Fetch complete main and feature history, then retry.",
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
throw new Error(
|
|
455
|
+
"The release branch is not cleanly rebased onto current main. Rebase it onto main, resolve conflicts, and retry.",
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const mergeResult = requireGitSuccess(
|
|
460
|
+
await runGit(
|
|
461
|
+
smartshellArg,
|
|
462
|
+
cwdArg,
|
|
463
|
+
["rev-list", "--merges", `${baseOidArg}..${sourceOidArg}`],
|
|
464
|
+
readOnlyArg,
|
|
465
|
+
),
|
|
466
|
+
"Unable to verify linear release-branch history.",
|
|
467
|
+
);
|
|
468
|
+
if (mergeResult.stdout.trim()) {
|
|
469
|
+
throw new Error(
|
|
470
|
+
"The release branch contains merge commits after main. Rebase it cleanly onto main, then retry.",
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
const verifyMainWorktree = async (
|
|
476
|
+
smartshellArg: TSmartshell,
|
|
477
|
+
pathArg: string,
|
|
478
|
+
expectedOidArg: string,
|
|
479
|
+
expectedGitDirArg?: string,
|
|
480
|
+
readOnlyArg = false,
|
|
481
|
+
): Promise<string> => {
|
|
482
|
+
const symbolic = await resolveSymbolicBranch(
|
|
483
|
+
smartshellArg,
|
|
484
|
+
pathArg,
|
|
485
|
+
readOnlyArg,
|
|
486
|
+
);
|
|
487
|
+
if (symbolic.ref !== mainBranchRef) {
|
|
488
|
+
throw new Error(
|
|
489
|
+
"The destination worktree is no longer checked out on main. Retry after restoring a clean main worktree.",
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
const head = await resolveCommit(
|
|
493
|
+
smartshellArg,
|
|
494
|
+
pathArg,
|
|
495
|
+
"HEAD",
|
|
496
|
+
"Unable to resolve the destination main commit.",
|
|
497
|
+
readOnlyArg,
|
|
498
|
+
);
|
|
499
|
+
if (head !== expectedOidArg) {
|
|
500
|
+
throw new Error(
|
|
501
|
+
"Local main changed during release preparation. Rebase the feature branch onto current main and retry.",
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
const gitDir = await resolveAbsoluteGitDir(
|
|
505
|
+
smartshellArg,
|
|
506
|
+
pathArg,
|
|
507
|
+
readOnlyArg,
|
|
508
|
+
);
|
|
509
|
+
if (expectedGitDirArg && gitDir !== expectedGitDirArg) {
|
|
510
|
+
throw new Error(
|
|
511
|
+
"The destination main worktree identity changed during release preparation. Retry from a stable worktree.",
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
await verifyCleanTree(smartshellArg, pathArg, "main", readOnlyArg);
|
|
515
|
+
await verifyNoGitOperation(smartshellArg, pathArg, "main", readOnlyArg);
|
|
516
|
+
return gitDir;
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
const resolveMainWorktree = async (
|
|
520
|
+
smartshellArg: TSmartshell,
|
|
521
|
+
cwdArg: string,
|
|
522
|
+
localMainOidArg: string,
|
|
523
|
+
readOnlyArg = false,
|
|
524
|
+
): Promise<{ path: string; gitDir: string }> => {
|
|
525
|
+
const records = await listWorktrees(smartshellArg, cwdArg, readOnlyArg);
|
|
526
|
+
const mainRecords = records.filter(
|
|
527
|
+
(record) => record.branch === mainBranchRef,
|
|
528
|
+
);
|
|
529
|
+
if (mainRecords.length > 1) {
|
|
530
|
+
throw new Error(
|
|
531
|
+
"Multiple worktrees claim main. Repair the Git worktree registry before releasing.",
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
const mainRecord = mainRecords[0];
|
|
535
|
+
if (!mainRecord) {
|
|
536
|
+
throw new Error(
|
|
537
|
+
"Release merge mode requires exactly one existing worktree checked out on main.",
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
if (mainRecord.prunable) {
|
|
541
|
+
throw new Error(
|
|
542
|
+
"The registered main worktree is stale. Repair it explicitly before releasing.",
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
const gitDir = await verifyMainWorktree(
|
|
546
|
+
smartshellArg,
|
|
547
|
+
mainRecord.path,
|
|
548
|
+
localMainOidArg,
|
|
549
|
+
undefined,
|
|
550
|
+
readOnlyArg,
|
|
551
|
+
);
|
|
552
|
+
return {
|
|
553
|
+
path: mainRecord.path,
|
|
554
|
+
gitDir,
|
|
555
|
+
};
|
|
556
|
+
};
|
|
557
|
+
|
|
558
|
+
const verifySourceState = async (
|
|
559
|
+
smartshellArg: TSmartshell,
|
|
560
|
+
contextArg: IReleaseBranchContext,
|
|
561
|
+
readOnlyArg = false,
|
|
562
|
+
): Promise<void> => {
|
|
563
|
+
await verifyNoHistoryOverrides(
|
|
564
|
+
smartshellArg,
|
|
565
|
+
contextArg.sourceCwd,
|
|
566
|
+
readOnlyArg,
|
|
567
|
+
);
|
|
568
|
+
const symbolic = await resolveSymbolicBranch(
|
|
569
|
+
smartshellArg,
|
|
570
|
+
contextArg.sourceCwd,
|
|
571
|
+
readOnlyArg,
|
|
572
|
+
);
|
|
573
|
+
if (symbolic.ref !== contextArg.sourceRef) {
|
|
574
|
+
throw new Error(
|
|
575
|
+
"The release source worktree changed branches during preparation. Retry from the intended feature branch.",
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
const sourceHead = await resolveCommit(
|
|
579
|
+
smartshellArg,
|
|
580
|
+
contextArg.sourceCwd,
|
|
581
|
+
"HEAD",
|
|
582
|
+
"Unable to resolve the release source commit.",
|
|
583
|
+
readOnlyArg,
|
|
584
|
+
);
|
|
585
|
+
const sourceRefOid = await resolveCommit(
|
|
586
|
+
smartshellArg,
|
|
587
|
+
contextArg.sourceCwd,
|
|
588
|
+
contextArg.sourceRef,
|
|
589
|
+
"Unable to resolve the release source branch.",
|
|
590
|
+
readOnlyArg,
|
|
591
|
+
);
|
|
592
|
+
if (
|
|
593
|
+
sourceHead !== contextArg.sourceOid ||
|
|
594
|
+
sourceRefOid !== contextArg.sourceOid
|
|
595
|
+
) {
|
|
596
|
+
throw new Error(
|
|
597
|
+
"The release source branch changed during preparation. Restart the release from its new tip.",
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
await verifyCleanTree(
|
|
601
|
+
smartshellArg,
|
|
602
|
+
contextArg.sourceCwd,
|
|
603
|
+
"release source",
|
|
604
|
+
readOnlyArg,
|
|
605
|
+
);
|
|
606
|
+
await verifyNoGitOperation(
|
|
607
|
+
smartshellArg,
|
|
608
|
+
contextArg.sourceCwd,
|
|
609
|
+
"release source",
|
|
610
|
+
readOnlyArg,
|
|
611
|
+
);
|
|
612
|
+
};
|
|
613
|
+
|
|
614
|
+
export async function inspectReleaseBranch(
|
|
615
|
+
optionsArg: IInspectReleaseBranchOptions,
|
|
616
|
+
): Promise<IReleaseBranchContext> {
|
|
617
|
+
const symbolic = await resolveSymbolicBranch(
|
|
618
|
+
optionsArg.smartshell,
|
|
619
|
+
optionsArg.cwd,
|
|
620
|
+
optionsArg.planMode,
|
|
621
|
+
);
|
|
622
|
+
await verifyNoHistoryOverrides(
|
|
623
|
+
optionsArg.smartshell,
|
|
624
|
+
optionsArg.cwd,
|
|
625
|
+
optionsArg.planMode,
|
|
626
|
+
);
|
|
627
|
+
const sourceOid = await resolveCommit(
|
|
628
|
+
optionsArg.smartshell,
|
|
629
|
+
optionsArg.cwd,
|
|
630
|
+
"HEAD",
|
|
631
|
+
"Unable to resolve the release source commit.",
|
|
632
|
+
optionsArg.planMode,
|
|
633
|
+
);
|
|
634
|
+
const context: IReleaseBranchContext = {
|
|
635
|
+
sourceCwd: optionsArg.cwd,
|
|
636
|
+
sourceBranch: symbolic.branch,
|
|
637
|
+
sourceRef: symbolic.ref,
|
|
638
|
+
sourceOid,
|
|
639
|
+
gitRemote: optionsArg.gitRemote,
|
|
640
|
+
mergeRequested: optionsArg.mergeRequested,
|
|
641
|
+
planAncestryVerified: false,
|
|
642
|
+
};
|
|
643
|
+
|
|
644
|
+
await verifyNoGitOperation(
|
|
645
|
+
optionsArg.smartshell,
|
|
646
|
+
optionsArg.cwd,
|
|
647
|
+
"release source",
|
|
648
|
+
optionsArg.planMode,
|
|
649
|
+
);
|
|
650
|
+
|
|
651
|
+
if (symbolic.branch === mainBranch && optionsArg.mergeRequested) {
|
|
652
|
+
throw new Error(
|
|
653
|
+
"`gitzone release --merge` is only valid when releasing from a non-main branch.",
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
if (symbolic.branch !== mainBranch && !optionsArg.mergeRequested) {
|
|
657
|
+
throw new Error(
|
|
658
|
+
`Releases must run from main. Current branch: ${symbolic.branch}. Re-run with \`gitzone release --merge\` after rebasing cleanly onto main.`,
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
if (symbolic.branch !== mainBranch && !optionsArg.gitTargetActive) {
|
|
662
|
+
throw new Error(
|
|
663
|
+
"`gitzone release --merge` requires the active Git target.",
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
if (
|
|
667
|
+
optionsArg.gitTargetActive &&
|
|
668
|
+
(!optionsArg.pushBranch || optionsArg.pushTags === false)
|
|
669
|
+
) {
|
|
670
|
+
throw new Error(
|
|
671
|
+
"The Git release target requires both branch and tag pushing so release history can publish atomically.",
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
await verifyCleanTree(
|
|
676
|
+
optionsArg.smartshell,
|
|
677
|
+
optionsArg.cwd,
|
|
678
|
+
"release source",
|
|
679
|
+
optionsArg.planMode,
|
|
680
|
+
);
|
|
681
|
+
context.localMainOid = await resolveCommit(
|
|
682
|
+
optionsArg.smartshell,
|
|
683
|
+
optionsArg.cwd,
|
|
684
|
+
mainBranchRef,
|
|
685
|
+
"A local main branch is required for release merge mode.",
|
|
686
|
+
optionsArg.planMode,
|
|
687
|
+
);
|
|
688
|
+
if (symbolic.branch !== mainBranch) {
|
|
689
|
+
await verifyLinearAncestry(
|
|
690
|
+
optionsArg.smartshell,
|
|
691
|
+
optionsArg.cwd,
|
|
692
|
+
context.localMainOid,
|
|
693
|
+
context.sourceOid,
|
|
694
|
+
optionsArg.planMode,
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const mainWorktree = await resolveMainWorktree(
|
|
699
|
+
optionsArg.smartshell,
|
|
700
|
+
optionsArg.cwd,
|
|
701
|
+
context.localMainOid,
|
|
702
|
+
optionsArg.planMode,
|
|
703
|
+
);
|
|
704
|
+
context.mainWorktreePath = mainWorktree.path;
|
|
705
|
+
context.mainWorktreeGitDir = mainWorktree.gitDir;
|
|
706
|
+
if (
|
|
707
|
+
symbolic.branch === mainBranch &&
|
|
708
|
+
plugins.path.resolve(mainWorktree.path) !==
|
|
709
|
+
plugins.path.resolve(optionsArg.cwd)
|
|
710
|
+
) {
|
|
711
|
+
throw new Error(
|
|
712
|
+
"The current main worktree does not match Git worktree ownership.",
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
if (optionsArg.gitTargetActive) {
|
|
717
|
+
context.pushUrl = await resolvePushUrl(
|
|
718
|
+
optionsArg.smartshell,
|
|
719
|
+
optionsArg.cwd,
|
|
720
|
+
optionsArg.gitRemote,
|
|
721
|
+
optionsArg.planMode,
|
|
722
|
+
);
|
|
723
|
+
context.remoteMainOid = await resolveRemoteMainOid(
|
|
724
|
+
optionsArg.smartshell,
|
|
725
|
+
optionsArg.cwd,
|
|
726
|
+
context.pushUrl,
|
|
727
|
+
true,
|
|
728
|
+
);
|
|
729
|
+
if (
|
|
730
|
+
await hasCommitObject(
|
|
731
|
+
optionsArg.smartshell,
|
|
732
|
+
optionsArg.cwd,
|
|
733
|
+
context.remoteMainOid,
|
|
734
|
+
optionsArg.planMode,
|
|
735
|
+
)
|
|
736
|
+
) {
|
|
737
|
+
await verifyLinearAncestry(
|
|
738
|
+
optionsArg.smartshell,
|
|
739
|
+
optionsArg.cwd,
|
|
740
|
+
context.remoteMainOid,
|
|
741
|
+
context.sourceOid,
|
|
742
|
+
optionsArg.planMode,
|
|
743
|
+
);
|
|
744
|
+
context.planAncestryVerified = true;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
return context;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
export async function prepareReleaseIntegration(
|
|
752
|
+
smartshellArg: TSmartshell,
|
|
753
|
+
contextArg: IReleaseBranchContext,
|
|
754
|
+
): Promise<IReleaseBranchContext> {
|
|
755
|
+
if (!contextArg.localMainOid || !contextArg.mainWorktreePath) {
|
|
756
|
+
throw new Error(
|
|
757
|
+
"Release branch context is incomplete. Restart the release.",
|
|
758
|
+
);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
await verifySourceState(smartshellArg, contextArg);
|
|
762
|
+
await verifyMainWorktree(
|
|
763
|
+
smartshellArg,
|
|
764
|
+
contextArg.mainWorktreePath,
|
|
765
|
+
contextArg.localMainOid,
|
|
766
|
+
contextArg.mainWorktreeGitDir,
|
|
767
|
+
);
|
|
768
|
+
if (!contextArg.pushUrl) {
|
|
769
|
+
return contextArg;
|
|
770
|
+
}
|
|
771
|
+
const currentPushUrl = await resolvePushUrl(
|
|
772
|
+
smartshellArg,
|
|
773
|
+
contextArg.sourceCwd,
|
|
774
|
+
contextArg.gitRemote,
|
|
775
|
+
);
|
|
776
|
+
if (currentPushUrl !== contextArg.pushUrl) {
|
|
777
|
+
throw new Error(
|
|
778
|
+
"The Git push destination changed during release preparation. Restart the release after reviewing the remote configuration.",
|
|
779
|
+
);
|
|
780
|
+
}
|
|
781
|
+
const remoteMainOid = await resolveRemoteMainOid(
|
|
782
|
+
smartshellArg,
|
|
783
|
+
contextArg.sourceCwd,
|
|
784
|
+
contextArg.pushUrl,
|
|
785
|
+
);
|
|
786
|
+
if (contextArg.remoteMainOid && remoteMainOid !== contextArg.remoteMainOid) {
|
|
787
|
+
throw new Error(
|
|
788
|
+
"Remote main changed during release preparation. Fetch, rebase cleanly, and retry.",
|
|
789
|
+
);
|
|
790
|
+
}
|
|
791
|
+
requireGitSuccess(
|
|
792
|
+
await runGit(smartshellArg, contextArg.sourceCwd, [
|
|
793
|
+
"fetch",
|
|
794
|
+
"--no-tags",
|
|
795
|
+
"--no-recurse-submodules",
|
|
796
|
+
"--no-write-fetch-head",
|
|
797
|
+
contextArg.pushUrl,
|
|
798
|
+
mainBranchRef,
|
|
799
|
+
]),
|
|
800
|
+
"Unable to fetch current main from the configured Git push destination.",
|
|
801
|
+
);
|
|
802
|
+
const verifiedRemoteMainOid = await resolveRemoteMainOid(
|
|
803
|
+
smartshellArg,
|
|
804
|
+
contextArg.sourceCwd,
|
|
805
|
+
contextArg.pushUrl,
|
|
806
|
+
);
|
|
807
|
+
if (verifiedRemoteMainOid !== remoteMainOid) {
|
|
808
|
+
throw new Error(
|
|
809
|
+
"Remote main changed during release preparation. Fetch, rebase the feature branch cleanly, and retry.",
|
|
810
|
+
);
|
|
811
|
+
}
|
|
812
|
+
if (
|
|
813
|
+
!(await hasCommitObject(smartshellArg, contextArg.sourceCwd, remoteMainOid))
|
|
814
|
+
) {
|
|
815
|
+
throw new Error(
|
|
816
|
+
"Current remote main could not be loaded locally. Fetch complete history and retry.",
|
|
817
|
+
);
|
|
818
|
+
}
|
|
819
|
+
await verifyLinearAncestry(
|
|
820
|
+
smartshellArg,
|
|
821
|
+
contextArg.sourceCwd,
|
|
822
|
+
remoteMainOid,
|
|
823
|
+
contextArg.sourceOid,
|
|
824
|
+
);
|
|
825
|
+
await verifySourceState(smartshellArg, contextArg);
|
|
826
|
+
await verifyMainWorktree(
|
|
827
|
+
smartshellArg,
|
|
828
|
+
contextArg.mainWorktreePath,
|
|
829
|
+
contextArg.localMainOid,
|
|
830
|
+
contextArg.mainWorktreeGitDir,
|
|
831
|
+
);
|
|
832
|
+
return {
|
|
833
|
+
...contextArg,
|
|
834
|
+
remoteMainOid,
|
|
835
|
+
planAncestryVerified: true,
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
export async function revalidatePreparedReleaseBranch(
|
|
840
|
+
smartshellArg: TSmartshell,
|
|
841
|
+
contextArg: IReleaseBranchContext,
|
|
842
|
+
): Promise<void> {
|
|
843
|
+
if (
|
|
844
|
+
!contextArg.localMainOid ||
|
|
845
|
+
!contextArg.mainWorktreePath ||
|
|
846
|
+
!contextArg.mainWorktreeGitDir
|
|
847
|
+
) {
|
|
848
|
+
throw new Error(
|
|
849
|
+
"Release branch context is incomplete. Restart the release.",
|
|
850
|
+
);
|
|
851
|
+
}
|
|
852
|
+
await verifySourceState(smartshellArg, contextArg);
|
|
853
|
+
await verifyMainWorktree(
|
|
854
|
+
smartshellArg,
|
|
855
|
+
contextArg.mainWorktreePath,
|
|
856
|
+
contextArg.localMainOid,
|
|
857
|
+
contextArg.mainWorktreeGitDir,
|
|
858
|
+
);
|
|
859
|
+
if (contextArg.pushUrl) {
|
|
860
|
+
const currentPushUrl = await resolvePushUrl(
|
|
861
|
+
smartshellArg,
|
|
862
|
+
contextArg.sourceCwd,
|
|
863
|
+
contextArg.gitRemote,
|
|
864
|
+
);
|
|
865
|
+
if (currentPushUrl !== contextArg.pushUrl) {
|
|
866
|
+
throw new Error(
|
|
867
|
+
"The Git push destination changed after release preflight. Restart after reviewing the remote configuration.",
|
|
868
|
+
);
|
|
869
|
+
}
|
|
870
|
+
const currentRemoteMainOid = await resolveRemoteMainOid(
|
|
871
|
+
smartshellArg,
|
|
872
|
+
contextArg.sourceCwd,
|
|
873
|
+
contextArg.pushUrl,
|
|
874
|
+
);
|
|
875
|
+
if (currentRemoteMainOid !== contextArg.remoteMainOid) {
|
|
876
|
+
throw new Error(
|
|
877
|
+
"Remote main changed after release preflight. Fetch, rebase cleanly, and retry.",
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
export async function revalidateReleasePublicationState(
|
|
884
|
+
smartshellArg: TSmartshell,
|
|
885
|
+
contextArg: IReleaseBranchContext,
|
|
886
|
+
expectedMainOidArg: string,
|
|
887
|
+
expectedRemoteMainOidArg?: string,
|
|
888
|
+
): Promise<void> {
|
|
889
|
+
if (!contextArg.mainWorktreePath || !contextArg.mainWorktreeGitDir) {
|
|
890
|
+
throw new Error(
|
|
891
|
+
"Release branch context is incomplete. Restart the release.",
|
|
892
|
+
);
|
|
893
|
+
}
|
|
894
|
+
await verifyNoHistoryOverrides(smartshellArg, contextArg.sourceCwd);
|
|
895
|
+
const currentMainWorktree = await resolveMainWorktree(
|
|
896
|
+
smartshellArg,
|
|
897
|
+
contextArg.sourceCwd,
|
|
898
|
+
expectedMainOidArg,
|
|
899
|
+
);
|
|
900
|
+
if (
|
|
901
|
+
currentMainWorktree.path !== contextArg.mainWorktreePath ||
|
|
902
|
+
currentMainWorktree.gitDir !== contextArg.mainWorktreeGitDir
|
|
903
|
+
) {
|
|
904
|
+
throw new Error(
|
|
905
|
+
"Main worktree ownership changed before release publication. Inspect the worktree layout before retrying.",
|
|
906
|
+
);
|
|
907
|
+
}
|
|
908
|
+
if (contextArg.sourceBranch !== mainBranch) {
|
|
909
|
+
await verifySourceState(smartshellArg, contextArg);
|
|
910
|
+
}
|
|
911
|
+
if (contextArg.pushUrl) {
|
|
912
|
+
if (!expectedRemoteMainOidArg) {
|
|
913
|
+
throw new Error(
|
|
914
|
+
"Expected remote main state is unavailable. Restart the release.",
|
|
915
|
+
);
|
|
916
|
+
}
|
|
917
|
+
const currentPushUrl = await resolvePushUrl(
|
|
918
|
+
smartshellArg,
|
|
919
|
+
contextArg.sourceCwd,
|
|
920
|
+
contextArg.gitRemote,
|
|
921
|
+
);
|
|
922
|
+
if (currentPushUrl !== contextArg.pushUrl) {
|
|
923
|
+
throw new Error(
|
|
924
|
+
"The Git push destination changed before release publication. Restart after reviewing the remote configuration.",
|
|
925
|
+
);
|
|
926
|
+
}
|
|
927
|
+
const currentRemoteMainOid = await resolveRemoteMainOid(
|
|
928
|
+
smartshellArg,
|
|
929
|
+
contextArg.sourceCwd,
|
|
930
|
+
contextArg.pushUrl,
|
|
931
|
+
);
|
|
932
|
+
if (currentRemoteMainOid !== expectedRemoteMainOidArg) {
|
|
933
|
+
throw new Error(
|
|
934
|
+
"Remote main changed before release publication. Inspect the remote state before retrying.",
|
|
935
|
+
);
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
const describeLocalMainState = async (
|
|
941
|
+
smartshellArg: TSmartshell,
|
|
942
|
+
contextArg: IReleaseBranchContext,
|
|
943
|
+
releaseCwdArg: string,
|
|
944
|
+
destinationGitDirArg: string,
|
|
945
|
+
): Promise<string> => {
|
|
946
|
+
let mainOid: string;
|
|
947
|
+
try {
|
|
948
|
+
mainOid = await resolveCommit(
|
|
949
|
+
smartshellArg,
|
|
950
|
+
releaseCwdArg,
|
|
951
|
+
mainBranchRef,
|
|
952
|
+
"Local main no longer resolves after rollback failure.",
|
|
953
|
+
);
|
|
954
|
+
} catch {
|
|
955
|
+
return " Local main no longer resolves; inspect its ref and worktree before retrying.";
|
|
956
|
+
}
|
|
957
|
+
try {
|
|
958
|
+
await verifyMainWorktree(
|
|
959
|
+
smartshellArg,
|
|
960
|
+
releaseCwdArg,
|
|
961
|
+
mainOid,
|
|
962
|
+
destinationGitDirArg,
|
|
963
|
+
);
|
|
964
|
+
return ` Local main remains at ${mainOid} with a verified clean worktree.`;
|
|
965
|
+
} catch {
|
|
966
|
+
return ` Local main points to ${mainOid}, but its index or worktree could not be verified; inspect it before retrying.`;
|
|
967
|
+
}
|
|
968
|
+
};
|
|
969
|
+
|
|
970
|
+
const compensateFailedLocalMainTransition = async (
|
|
971
|
+
smartshellArg: TSmartshell,
|
|
972
|
+
contextArg: IReleaseBranchContext,
|
|
973
|
+
releaseCwdArg: string,
|
|
974
|
+
destinationGitDirArg: string,
|
|
975
|
+
): Promise<string> => {
|
|
976
|
+
if (!contextArg.localMainOid) {
|
|
977
|
+
return " Local main state is unknown; inspect it before retrying.";
|
|
978
|
+
}
|
|
979
|
+
const restoreResult = await runGit(smartshellArg, releaseCwdArg, [
|
|
980
|
+
"update-ref",
|
|
981
|
+
"-m",
|
|
982
|
+
"gitzone release integration rollback",
|
|
983
|
+
mainBranchRef,
|
|
984
|
+
contextArg.localMainOid,
|
|
985
|
+
contextArg.sourceOid,
|
|
986
|
+
]);
|
|
987
|
+
const localState = await describeLocalMainState(
|
|
988
|
+
smartshellArg,
|
|
989
|
+
contextArg,
|
|
990
|
+
releaseCwdArg,
|
|
991
|
+
destinationGitDirArg,
|
|
992
|
+
);
|
|
993
|
+
if (restoreResult.exitCode === 0) {
|
|
994
|
+
return ` Local main ref was restored after the worktree update failed.${localState}`;
|
|
995
|
+
}
|
|
996
|
+
return ` Local main changed before its ref could be restored.${localState}`;
|
|
997
|
+
};
|
|
998
|
+
|
|
999
|
+
const rollbackLocalMain = async (
|
|
1000
|
+
smartshellArg: TSmartshell,
|
|
1001
|
+
contextArg: IReleaseBranchContext,
|
|
1002
|
+
releaseCwdArg: string,
|
|
1003
|
+
destinationGitDirArg: string,
|
|
1004
|
+
): Promise<string> => {
|
|
1005
|
+
if (!contextArg.localMainOid) {
|
|
1006
|
+
return " Local main state is unknown; inspect it before retrying.";
|
|
1007
|
+
}
|
|
1008
|
+
let refRestored = false;
|
|
1009
|
+
try {
|
|
1010
|
+
await verifyMainWorktree(
|
|
1011
|
+
smartshellArg,
|
|
1012
|
+
releaseCwdArg,
|
|
1013
|
+
contextArg.sourceOid,
|
|
1014
|
+
destinationGitDirArg,
|
|
1015
|
+
);
|
|
1016
|
+
requireGitSuccess(
|
|
1017
|
+
await runGit(smartshellArg, releaseCwdArg, [
|
|
1018
|
+
"update-ref",
|
|
1019
|
+
mainBranchRef,
|
|
1020
|
+
contextArg.localMainOid,
|
|
1021
|
+
contextArg.sourceOid,
|
|
1022
|
+
]),
|
|
1023
|
+
"Unable to restore local main after failed release integration.",
|
|
1024
|
+
);
|
|
1025
|
+
refRestored = true;
|
|
1026
|
+
requireGitSuccess(
|
|
1027
|
+
await runGit(smartshellArg, releaseCwdArg, [
|
|
1028
|
+
"read-tree",
|
|
1029
|
+
"-m",
|
|
1030
|
+
"-u",
|
|
1031
|
+
contextArg.sourceOid,
|
|
1032
|
+
contextArg.localMainOid,
|
|
1033
|
+
]),
|
|
1034
|
+
"Unable to restore the local main worktree after failed release integration.",
|
|
1035
|
+
);
|
|
1036
|
+
await verifyMainWorktree(
|
|
1037
|
+
smartshellArg,
|
|
1038
|
+
releaseCwdArg,
|
|
1039
|
+
contextArg.localMainOid,
|
|
1040
|
+
destinationGitDirArg,
|
|
1041
|
+
);
|
|
1042
|
+
return " Local main was restored.";
|
|
1043
|
+
} catch {
|
|
1044
|
+
if (refRestored) {
|
|
1045
|
+
await runGit(smartshellArg, releaseCwdArg, [
|
|
1046
|
+
"update-ref",
|
|
1047
|
+
mainBranchRef,
|
|
1048
|
+
contextArg.sourceOid,
|
|
1049
|
+
contextArg.localMainOid,
|
|
1050
|
+
]);
|
|
1051
|
+
}
|
|
1052
|
+
return describeLocalMainState(
|
|
1053
|
+
smartshellArg,
|
|
1054
|
+
contextArg,
|
|
1055
|
+
releaseCwdArg,
|
|
1056
|
+
destinationGitDirArg,
|
|
1057
|
+
);
|
|
1058
|
+
}
|
|
1059
|
+
};
|
|
1060
|
+
|
|
1061
|
+
export async function integrateReleaseBranch(
|
|
1062
|
+
smartshellArg: TSmartshell,
|
|
1063
|
+
contextArg: IReleaseBranchContext,
|
|
1064
|
+
): Promise<IIntegratedReleaseBranch> {
|
|
1065
|
+
if (contextArg.sourceBranch === mainBranch) {
|
|
1066
|
+
throw new Error(
|
|
1067
|
+
"The release branch is already main and does not require integration.",
|
|
1068
|
+
);
|
|
1069
|
+
}
|
|
1070
|
+
if (
|
|
1071
|
+
!contextArg.pushUrl ||
|
|
1072
|
+
!contextArg.localMainOid ||
|
|
1073
|
+
!contextArg.remoteMainOid
|
|
1074
|
+
) {
|
|
1075
|
+
throw new Error(
|
|
1076
|
+
"Release merge preparation is incomplete. Restart the release.",
|
|
1077
|
+
);
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
await verifySourceState(smartshellArg, contextArg);
|
|
1081
|
+
const currentPushUrl = await resolvePushUrl(
|
|
1082
|
+
smartshellArg,
|
|
1083
|
+
contextArg.sourceCwd,
|
|
1084
|
+
contextArg.gitRemote,
|
|
1085
|
+
);
|
|
1086
|
+
if (currentPushUrl !== contextArg.pushUrl) {
|
|
1087
|
+
throw new Error(
|
|
1088
|
+
"The Git push destination changed before integration. No local branch was changed.",
|
|
1089
|
+
);
|
|
1090
|
+
}
|
|
1091
|
+
const currentRemoteMainOid = await resolveRemoteMainOid(
|
|
1092
|
+
smartshellArg,
|
|
1093
|
+
contextArg.sourceCwd,
|
|
1094
|
+
contextArg.pushUrl,
|
|
1095
|
+
);
|
|
1096
|
+
if (currentRemoteMainOid !== contextArg.remoteMainOid) {
|
|
1097
|
+
throw new Error(
|
|
1098
|
+
"Remote main changed before integration. Fetch, rebase the feature branch cleanly, and retry.",
|
|
1099
|
+
);
|
|
1100
|
+
}
|
|
1101
|
+
const currentLocalMainOid = await resolveCommit(
|
|
1102
|
+
smartshellArg,
|
|
1103
|
+
contextArg.sourceCwd,
|
|
1104
|
+
mainBranchRef,
|
|
1105
|
+
"Local main disappeared during release preparation.",
|
|
1106
|
+
);
|
|
1107
|
+
if (currentLocalMainOid !== contextArg.localMainOid) {
|
|
1108
|
+
throw new Error(
|
|
1109
|
+
"Local main changed before integration. Rebase the feature branch cleanly and retry.",
|
|
1110
|
+
);
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
const currentMainWorktree = await resolveMainWorktree(
|
|
1114
|
+
smartshellArg,
|
|
1115
|
+
contextArg.sourceCwd,
|
|
1116
|
+
contextArg.localMainOid,
|
|
1117
|
+
);
|
|
1118
|
+
if (
|
|
1119
|
+
currentMainWorktree.path !== contextArg.mainWorktreePath ||
|
|
1120
|
+
currentMainWorktree.gitDir !== contextArg.mainWorktreeGitDir
|
|
1121
|
+
) {
|
|
1122
|
+
throw new Error(
|
|
1123
|
+
"Main worktree ownership changed during release preparation. Retry from a stable worktree layout.",
|
|
1124
|
+
);
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
const releaseCwd = contextArg.mainWorktreePath;
|
|
1128
|
+
if (!releaseCwd) {
|
|
1129
|
+
throw new Error(
|
|
1130
|
+
"The required main worktree disappeared before integration. No local branch was changed.",
|
|
1131
|
+
);
|
|
1132
|
+
}
|
|
1133
|
+
const destinationGitDir = await verifyMainWorktree(
|
|
1134
|
+
smartshellArg,
|
|
1135
|
+
releaseCwd,
|
|
1136
|
+
contextArg.localMainOid,
|
|
1137
|
+
contextArg.mainWorktreeGitDir,
|
|
1138
|
+
);
|
|
1139
|
+
|
|
1140
|
+
const refAdvanceResult = await runGit(smartshellArg, releaseCwd, [
|
|
1141
|
+
"update-ref",
|
|
1142
|
+
"-m",
|
|
1143
|
+
"gitzone release integration",
|
|
1144
|
+
mainBranchRef,
|
|
1145
|
+
contextArg.sourceOid,
|
|
1146
|
+
contextArg.localMainOid,
|
|
1147
|
+
]);
|
|
1148
|
+
if (refAdvanceResult.exitCode !== 0) {
|
|
1149
|
+
throw new Error(
|
|
1150
|
+
"Local main changed before integration. No local ref or worktree rollback was attempted.",
|
|
1151
|
+
);
|
|
1152
|
+
}
|
|
1153
|
+
const worktreeAdvanceResult = await runGit(smartshellArg, releaseCwd, [
|
|
1154
|
+
"read-tree",
|
|
1155
|
+
"-m",
|
|
1156
|
+
"-u",
|
|
1157
|
+
contextArg.localMainOid,
|
|
1158
|
+
contextArg.sourceOid,
|
|
1159
|
+
]);
|
|
1160
|
+
if (worktreeAdvanceResult.exitCode !== 0) {
|
|
1161
|
+
const rollbackState = await compensateFailedLocalMainTransition(
|
|
1162
|
+
smartshellArg,
|
|
1163
|
+
contextArg,
|
|
1164
|
+
releaseCwd,
|
|
1165
|
+
destinationGitDir,
|
|
1166
|
+
);
|
|
1167
|
+
throw new Error(
|
|
1168
|
+
`Local main advanced, but its index and worktree could not be updated. No remote ref or release metadata was changed.${rollbackState}`,
|
|
1169
|
+
);
|
|
1170
|
+
}
|
|
1171
|
+
try {
|
|
1172
|
+
await verifyMainWorktree(
|
|
1173
|
+
smartshellArg,
|
|
1174
|
+
releaseCwd,
|
|
1175
|
+
contextArg.sourceOid,
|
|
1176
|
+
destinationGitDir,
|
|
1177
|
+
);
|
|
1178
|
+
} catch {
|
|
1179
|
+
const rollbackState = await rollbackLocalMain(
|
|
1180
|
+
smartshellArg,
|
|
1181
|
+
contextArg,
|
|
1182
|
+
releaseCwd,
|
|
1183
|
+
destinationGitDir,
|
|
1184
|
+
);
|
|
1185
|
+
throw new Error(
|
|
1186
|
+
`Local main could not be verified after integration. No remote ref or release metadata was changed.${rollbackState}`,
|
|
1187
|
+
);
|
|
1188
|
+
}
|
|
1189
|
+
const sourceRefOid = await resolveCommit(
|
|
1190
|
+
smartshellArg,
|
|
1191
|
+
releaseCwd,
|
|
1192
|
+
contextArg.sourceRef,
|
|
1193
|
+
"The release source branch disappeared during integration.",
|
|
1194
|
+
);
|
|
1195
|
+
if (sourceRefOid !== contextArg.sourceOid) {
|
|
1196
|
+
const rollbackState = await rollbackLocalMain(
|
|
1197
|
+
smartshellArg,
|
|
1198
|
+
contextArg,
|
|
1199
|
+
releaseCwd,
|
|
1200
|
+
destinationGitDir,
|
|
1201
|
+
);
|
|
1202
|
+
throw new Error(
|
|
1203
|
+
`The release source branch changed during integration. Release metadata was not created.${rollbackState}`,
|
|
1204
|
+
);
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
const pushResult = await runGit(smartshellArg, releaseCwd, [
|
|
1208
|
+
"push",
|
|
1209
|
+
"--no-follow-tags",
|
|
1210
|
+
`--force-with-lease=${mainBranchRef}:${contextArg.remoteMainOid}`,
|
|
1211
|
+
contextArg.pushUrl,
|
|
1212
|
+
`${contextArg.sourceOid}:${mainBranchRef}`,
|
|
1213
|
+
]);
|
|
1214
|
+
if (pushResult.exitCode !== 0) {
|
|
1215
|
+
let remoteAfterPush: string | undefined;
|
|
1216
|
+
try {
|
|
1217
|
+
remoteAfterPush = await resolveRemoteMainOid(
|
|
1218
|
+
smartshellArg,
|
|
1219
|
+
contextArg.sourceCwd,
|
|
1220
|
+
contextArg.pushUrl,
|
|
1221
|
+
);
|
|
1222
|
+
} catch {
|
|
1223
|
+
remoteAfterPush = undefined;
|
|
1224
|
+
}
|
|
1225
|
+
if (remoteAfterPush !== contextArg.sourceOid) {
|
|
1226
|
+
const rollbackState = await rollbackLocalMain(
|
|
1227
|
+
smartshellArg,
|
|
1228
|
+
contextArg,
|
|
1229
|
+
releaseCwd,
|
|
1230
|
+
destinationGitDir,
|
|
1231
|
+
);
|
|
1232
|
+
throw new Error(
|
|
1233
|
+
`Integrated main could not be confirmed at the remote. No release metadata was created.${rollbackState}${
|
|
1234
|
+
remoteAfterPush
|
|
1235
|
+
? ""
|
|
1236
|
+
: " The remote outcome is unknown; inspect refs/heads/main before retrying."
|
|
1237
|
+
}`,
|
|
1238
|
+
);
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
await verifyMainWorktree(
|
|
1242
|
+
smartshellArg,
|
|
1243
|
+
releaseCwd,
|
|
1244
|
+
contextArg.sourceOid,
|
|
1245
|
+
destinationGitDir,
|
|
1246
|
+
);
|
|
1247
|
+
let pushedRemoteMainOid: string;
|
|
1248
|
+
try {
|
|
1249
|
+
pushedRemoteMainOid = await resolveRemoteMainOid(
|
|
1250
|
+
smartshellArg,
|
|
1251
|
+
contextArg.sourceCwd,
|
|
1252
|
+
contextArg.pushUrl,
|
|
1253
|
+
);
|
|
1254
|
+
} catch {
|
|
1255
|
+
const rollbackState = await rollbackLocalMain(
|
|
1256
|
+
smartshellArg,
|
|
1257
|
+
contextArg,
|
|
1258
|
+
releaseCwd,
|
|
1259
|
+
destinationGitDir,
|
|
1260
|
+
);
|
|
1261
|
+
throw new Error(
|
|
1262
|
+
`The pre-release push outcome could not be verified. No release metadata was created.${rollbackState} Inspect remote refs/heads/main before retrying.`,
|
|
1263
|
+
);
|
|
1264
|
+
}
|
|
1265
|
+
if (pushedRemoteMainOid !== contextArg.sourceOid) {
|
|
1266
|
+
const rollbackState = await rollbackLocalMain(
|
|
1267
|
+
smartshellArg,
|
|
1268
|
+
contextArg,
|
|
1269
|
+
releaseCwd,
|
|
1270
|
+
destinationGitDir,
|
|
1271
|
+
);
|
|
1272
|
+
throw new Error(
|
|
1273
|
+
`Remote main did not retain the integrated release commit. No release metadata was created.${rollbackState}`,
|
|
1274
|
+
);
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
return {
|
|
1278
|
+
releaseCwd,
|
|
1279
|
+
pushUrl: contextArg.pushUrl,
|
|
1280
|
+
expectedRemoteMainOid: contextArg.sourceOid,
|
|
1281
|
+
};
|
|
1282
|
+
}
|