gitxplain 0.1.0 → 0.1.3
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/.env.example +5 -10
- package/IMPLEMENTATION.md +225 -0
- package/README.md +154 -0
- package/cli/index.js +446 -19
- package/cli/services/aiService.js +2 -2
- package/cli/services/chatService.js +663 -0
- package/cli/services/commitService.js +379 -0
- package/cli/services/envLoader.js +33 -0
- package/cli/services/gitConnectionService.js +267 -0
- package/cli/services/gitService.js +590 -0
- package/cli/services/mergeService.js +609 -0
- package/cli/services/outputFormatter.js +217 -11
- package/cli/services/promptService.js +66 -2
- package/cli/services/splitService.js +472 -0
- package/package.json +4 -3
- package/prompts/commit.txt +57 -0
- package/prompts/split.txt +44 -0
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
import {
|
|
3
|
+
deletePaths,
|
|
4
|
+
getCurrentBranchName,
|
|
5
|
+
getCurrentHeadSha,
|
|
6
|
+
getDefaultBaseRef,
|
|
7
|
+
getMergeBase,
|
|
8
|
+
gitCheckout,
|
|
9
|
+
gitCheckoutOrphan,
|
|
10
|
+
gitCherryPickAbort,
|
|
11
|
+
gitCherryPickNoCommit,
|
|
12
|
+
gitCommit,
|
|
13
|
+
gitCreateAnnotatedTag,
|
|
14
|
+
gitDeleteBranch,
|
|
15
|
+
gitDeleteTag,
|
|
16
|
+
gitRemoveCachedAll,
|
|
17
|
+
gitResetHard,
|
|
18
|
+
isWorkingTreeClean,
|
|
19
|
+
listBranchCommits,
|
|
20
|
+
listCommitsAfter,
|
|
21
|
+
listFilesInRef,
|
|
22
|
+
listTags,
|
|
23
|
+
localBranchExists,
|
|
24
|
+
resolveCommitSha,
|
|
25
|
+
runGitCommand
|
|
26
|
+
} from "./gitService.js";
|
|
27
|
+
|
|
28
|
+
const ANSI = {
|
|
29
|
+
reset: "\u001b[0m",
|
|
30
|
+
bold: "\u001b[1m",
|
|
31
|
+
cyan: "\u001b[36m",
|
|
32
|
+
yellow: "\u001b[33m",
|
|
33
|
+
green: "\u001b[32m"
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const RELEASE_BRANCH = "release";
|
|
37
|
+
const VERSION_PATTERN = /\b\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?\b/g;
|
|
38
|
+
const RELEASE_SUBJECT_PATTERN = /^release\s+(.+)$/i;
|
|
39
|
+
const SEMVER_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
40
|
+
const INTEGER_PATTERN = /^\d+$/;
|
|
41
|
+
const TAG_VERSION_PATTERN = /^v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?|\d+)$/;
|
|
42
|
+
|
|
43
|
+
function supportsColor() {
|
|
44
|
+
return Boolean(process.stdout?.isTTY) && process.env.NO_COLOR == null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function colorize(text, color) {
|
|
48
|
+
if (!supportsColor()) {
|
|
49
|
+
return text;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return `${color}${text}${ANSI.reset}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function unique(values) {
|
|
56
|
+
return [...new Set(values)];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function extractVersions(line) {
|
|
60
|
+
return unique(line.match(VERSION_PATTERN) ?? []);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function stripDiffPrefix(line) {
|
|
64
|
+
return line.slice(1).trim();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function parseDiffPath(line) {
|
|
68
|
+
const match = line.match(/^diff --git a\/(.+?) b\/(.+)$/);
|
|
69
|
+
return match ? match[2] : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isExactFilename(filePath, filename) {
|
|
73
|
+
return filePath === filename || filePath.endsWith(`/${filename}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function extractVersionCandidate(filePath, line) {
|
|
77
|
+
const trimmed = line.trim();
|
|
78
|
+
|
|
79
|
+
if (filePath == null || trimmed === "") {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (isExactFilename(filePath, "package.json")) {
|
|
84
|
+
return trimmed.match(/^"version"\s*:\s*"([^"]+)"[,]?$/)?.[1] ?? null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (isExactFilename(filePath, "pubspec.yaml")) {
|
|
88
|
+
return trimmed.match(/^version:\s*([^\s#]+)$/)?.[1] ?? null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (isExactFilename(filePath, "Cargo.toml")) {
|
|
92
|
+
return trimmed.match(/^version\s*=\s*"([^"]+)"$/)?.[1] ?? null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (isExactFilename(filePath, "pom.xml")) {
|
|
96
|
+
return trimmed.match(/^<version>([^<]+)<\/version>$/)?.[1] ?? null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (filePath.endsWith(".csproj")) {
|
|
100
|
+
return (
|
|
101
|
+
trimmed.match(/^<Version>([^<]+)<\/Version>$/)?.[1] ??
|
|
102
|
+
trimmed.match(/^<ApplicationDisplayVersion>([^<]+)<\/ApplicationDisplayVersion>$/)?.[1] ??
|
|
103
|
+
trimmed.match(/^<ApplicationVersion>([^<]+)<\/ApplicationVersion>$/)?.[1] ??
|
|
104
|
+
null
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (isExactFilename(filePath, "Info.plist")) {
|
|
109
|
+
return (
|
|
110
|
+
trimmed.match(/^<string>([^<]+)<\/string>$/)?.[1] ??
|
|
111
|
+
null
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (filePath.endsWith("AndroidManifest.xml")) {
|
|
116
|
+
return (
|
|
117
|
+
trimmed.match(/versionName="([^"]+)"/)?.[1] ??
|
|
118
|
+
trimmed.match(/versionCode="([^"]+)"/)?.[1] ??
|
|
119
|
+
null
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (filePath.endsWith("build.gradle") || filePath.endsWith("build.gradle.kts")) {
|
|
124
|
+
return (
|
|
125
|
+
trimmed.match(/^versionName\s*[= ]\s*["']?([^"'\s]+)["']?$/)?.[1] ??
|
|
126
|
+
trimmed.match(/^versionCode\s*[= ]\s*["']?([^"'\s]+)["']?$/)?.[1] ??
|
|
127
|
+
trimmed.match(/^version\s*=\s*["']([^"']+)["']$/)?.[1] ??
|
|
128
|
+
null
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (isExactFilename(filePath, "gradle.properties")) {
|
|
133
|
+
return (
|
|
134
|
+
trimmed.match(/^(?:VERSION_NAME|versionName)\s*=\s*(\S+)$/)?.[1] ??
|
|
135
|
+
trimmed.match(/^(?:VERSION_CODE|versionCode)\s*=\s*(\S+)$/)?.[1] ??
|
|
136
|
+
null
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (
|
|
141
|
+
isExactFilename(filePath, "VERSION") ||
|
|
142
|
+
isExactFilename(filePath, ".version") ||
|
|
143
|
+
isExactFilename(filePath, "version.txt")
|
|
144
|
+
) {
|
|
145
|
+
return (SEMVER_PATTERN.test(trimmed) || INTEGER_PATTERN.test(trimmed)) ? trimmed : null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function rankVersionValue(value) {
|
|
152
|
+
if (SEMVER_PATTERN.test(value)) {
|
|
153
|
+
return 2;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (INTEGER_PATTERN.test(value)) {
|
|
157
|
+
return 1;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return 0;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function selectReleaseVersion(values) {
|
|
164
|
+
return [...values].sort((left, right) => rankVersionValue(right) - rankVersionValue(left))[0] ?? null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function detectVersionChanges(diff) {
|
|
168
|
+
const removedVersions = [];
|
|
169
|
+
const addedVersions = [];
|
|
170
|
+
let currentFile = null;
|
|
171
|
+
|
|
172
|
+
for (const line of diff.split("\n")) {
|
|
173
|
+
const diffPath = parseDiffPath(line);
|
|
174
|
+
if (diffPath) {
|
|
175
|
+
currentFile = diffPath;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (line.startsWith("---") || line.startsWith("+++")) {
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (line.startsWith("-")) {
|
|
184
|
+
const candidate = extractVersionCandidate(currentFile, stripDiffPrefix(line));
|
|
185
|
+
if (candidate) {
|
|
186
|
+
removedVersions.push(candidate);
|
|
187
|
+
}
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (line.startsWith("+")) {
|
|
192
|
+
const candidate = extractVersionCandidate(currentFile, stripDiffPrefix(line));
|
|
193
|
+
if (candidate) {
|
|
194
|
+
addedVersions.push(candidate);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const removed = unique(removedVersions);
|
|
200
|
+
const added = unique(addedVersions);
|
|
201
|
+
const from = removed.filter((version) => !added.includes(version));
|
|
202
|
+
const to = added.filter((version) => !removed.includes(version));
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
from,
|
|
206
|
+
to,
|
|
207
|
+
hasVersionChange: from.length > 0 && to.length > 0,
|
|
208
|
+
releaseVersion: selectReleaseVersion(to)
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function getReleaseVersion(change) {
|
|
213
|
+
return change.releaseVersion ?? null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function getCommitSubject(ref, cwd) {
|
|
217
|
+
return runGitCommand(["log", "-1", "--pretty=format:%s", ref], cwd);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function getCommitFiles(ref, cwd) {
|
|
221
|
+
const output = runGitCommand(["show", "--pretty=format:", "--name-only", ref], cwd);
|
|
222
|
+
return output
|
|
223
|
+
.split("\n")
|
|
224
|
+
.map((line) => line.trim())
|
|
225
|
+
.filter(Boolean);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function getCommitDiff(ref, cwd) {
|
|
229
|
+
return runGitCommand(["show", "--format=", ref], cwd);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function inspectCommit(sha, cwd) {
|
|
233
|
+
const subject = getCommitSubject(sha, cwd);
|
|
234
|
+
const versionChange = detectVersionChanges(getCommitDiff(sha, cwd));
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
sha,
|
|
238
|
+
shortSha: sha.slice(0, 7),
|
|
239
|
+
subject,
|
|
240
|
+
files: getCommitFiles(sha, cwd),
|
|
241
|
+
versionChange,
|
|
242
|
+
releaseVersion: getReleaseVersion(versionChange)
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function summarizeVersionPair(change) {
|
|
247
|
+
return `${change.from.join(", ")} -> ${change.to.join(", ")}`;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function getReleasedVersions(releaseCommits) {
|
|
251
|
+
const explicitVersions = releaseCommits
|
|
252
|
+
.map((commit) => commit.subject.match(RELEASE_SUBJECT_PATTERN)?.[1]?.trim() ?? null)
|
|
253
|
+
.filter(Boolean);
|
|
254
|
+
|
|
255
|
+
const fallbackVersions = releaseCommits
|
|
256
|
+
.map((commit) => commit.releaseVersion)
|
|
257
|
+
.filter(Boolean);
|
|
258
|
+
|
|
259
|
+
return new Set([...explicitVersions, ...fallbackVersions]);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function extractTaggedVersions(tagNames) {
|
|
263
|
+
return new Set(
|
|
264
|
+
tagNames
|
|
265
|
+
.map((tagName) => tagName.match(TAG_VERSION_PATTERN)?.[1] ?? null)
|
|
266
|
+
.filter(Boolean)
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function buildReleaseWindows(sourceCommits) {
|
|
271
|
+
const windows = [];
|
|
272
|
+
let windowStartIndex = 0;
|
|
273
|
+
let activeVersion = null;
|
|
274
|
+
|
|
275
|
+
for (let index = 0; index < sourceCommits.length; index += 1) {
|
|
276
|
+
const commit = sourceCommits[index];
|
|
277
|
+
if (!commit.releaseVersion) {
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (activeVersion == null) {
|
|
282
|
+
activeVersion = commit.releaseVersion;
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (commit.releaseVersion === activeVersion) {
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const previousIndex = index - 1;
|
|
291
|
+
windows.push({
|
|
292
|
+
version: activeVersion,
|
|
293
|
+
commits: sourceCommits.slice(windowStartIndex, previousIndex + 1),
|
|
294
|
+
startRef: sourceCommits[windowStartIndex]?.shortSha ?? null,
|
|
295
|
+
endRef: sourceCommits[previousIndex]?.shortSha ?? null
|
|
296
|
+
});
|
|
297
|
+
windowStartIndex = index;
|
|
298
|
+
activeVersion = commit.releaseVersion;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (activeVersion != null) {
|
|
302
|
+
windows.push({
|
|
303
|
+
version: activeVersion,
|
|
304
|
+
commits: sourceCommits.slice(windowStartIndex),
|
|
305
|
+
startRef: sourceCommits[windowStartIndex]?.shortSha ?? null,
|
|
306
|
+
endRef: sourceCommits.at(-1)?.shortSha ?? null
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return windows;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function selectReleaseWindows(sourceCommits, releaseCommits = []) {
|
|
314
|
+
const windows = buildReleaseWindows(sourceCommits);
|
|
315
|
+
const releasedVersions = getReleasedVersions(releaseCommits);
|
|
316
|
+
const unreleasedWindows = windows.filter((window) => !releasedVersions.has(window.version));
|
|
317
|
+
|
|
318
|
+
const selectedWindows =
|
|
319
|
+
releasedVersions.size === 0
|
|
320
|
+
? unreleasedWindows
|
|
321
|
+
: unreleasedWindows.length > 0
|
|
322
|
+
? [unreleasedWindows.at(-1)]
|
|
323
|
+
: [];
|
|
324
|
+
|
|
325
|
+
return {
|
|
326
|
+
windows: selectedWindows,
|
|
327
|
+
releasedVersions: [...releasedVersions],
|
|
328
|
+
latestDetectedVersion: windows.at(-1)?.version ?? null
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function selectReleaseTags(sourceCommits, existingTagNames = []) {
|
|
333
|
+
const windows = buildReleaseWindows(sourceCommits);
|
|
334
|
+
const taggedVersions = extractTaggedVersions(existingTagNames);
|
|
335
|
+
const tags = windows
|
|
336
|
+
.filter((window) => !taggedVersions.has(window.version))
|
|
337
|
+
.map((window) => {
|
|
338
|
+
const targetCommit = window.commits.at(-1) ?? null;
|
|
339
|
+
return {
|
|
340
|
+
...window,
|
|
341
|
+
tagName: window.version,
|
|
342
|
+
targetSha: targetCommit?.sha ?? null,
|
|
343
|
+
targetShortSha: targetCommit?.shortSha ?? null,
|
|
344
|
+
targetSubject: targetCommit?.subject ?? null
|
|
345
|
+
};
|
|
346
|
+
})
|
|
347
|
+
.filter((tag) => tag.targetSha != null);
|
|
348
|
+
|
|
349
|
+
return {
|
|
350
|
+
tags,
|
|
351
|
+
taggedVersions: [...taggedVersions],
|
|
352
|
+
latestDetectedVersion: windows.at(-1)?.version ?? null
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function getReleaseTrackSourceCommitShas(releaseExists, baseRef, cwd) {
|
|
357
|
+
if (!releaseExists) {
|
|
358
|
+
return {
|
|
359
|
+
mergeBase: null,
|
|
360
|
+
sourceCommitShas: listBranchCommits("HEAD", cwd)
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
try {
|
|
365
|
+
const mergeBase = getMergeBase(baseRef, "HEAD", cwd);
|
|
366
|
+
return {
|
|
367
|
+
mergeBase,
|
|
368
|
+
sourceCommitShas: listCommitsAfter(mergeBase, "HEAD", cwd)
|
|
369
|
+
};
|
|
370
|
+
} catch {
|
|
371
|
+
return {
|
|
372
|
+
mergeBase: null,
|
|
373
|
+
sourceCommitShas: listBranchCommits("HEAD", cwd)
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export function buildReleaseMergePlan(cwd) {
|
|
379
|
+
const sourceBranch = getCurrentBranchName(cwd);
|
|
380
|
+
if (sourceBranch === RELEASE_BRANCH) {
|
|
381
|
+
throw new Error(`Already on "${RELEASE_BRANCH}". Switch to a source branch before running --merge.`);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const releaseExists = localBranchExists(RELEASE_BRANCH, cwd);
|
|
385
|
+
const baseRef = releaseExists ? RELEASE_BRANCH : getDefaultBaseRef(cwd);
|
|
386
|
+
const { mergeBase, sourceCommitShas } = getReleaseTrackSourceCommitShas(releaseExists, baseRef, cwd);
|
|
387
|
+
const sourceCommits = sourceCommitShas.map((sha) => inspectCommit(sha, cwd));
|
|
388
|
+
const releaseCommits = releaseExists ? listBranchCommits(RELEASE_BRANCH, cwd).map((sha) => inspectCommit(sha, cwd)) : [];
|
|
389
|
+
const selection = selectReleaseWindows(sourceCommits, releaseCommits);
|
|
390
|
+
|
|
391
|
+
return {
|
|
392
|
+
releaseBranch: RELEASE_BRANCH,
|
|
393
|
+
sourceBranch,
|
|
394
|
+
baseRef,
|
|
395
|
+
mergeBase,
|
|
396
|
+
releaseExists,
|
|
397
|
+
releasedVersions: selection.releasedVersions,
|
|
398
|
+
latestDetectedVersion: selection.latestDetectedVersion,
|
|
399
|
+
windows: selection.windows,
|
|
400
|
+
createFromRef: releaseExists ? RELEASE_BRANCH : null
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export function buildReleaseTagPlan(cwd) {
|
|
405
|
+
const sourceBranch = getCurrentBranchName(cwd);
|
|
406
|
+
if (sourceBranch === RELEASE_BRANCH) {
|
|
407
|
+
throw new Error(`Already on "${RELEASE_BRANCH}". Switch to a source branch before running --tag.`);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const releaseExists = localBranchExists(RELEASE_BRANCH, cwd);
|
|
411
|
+
const baseRef = releaseExists ? RELEASE_BRANCH : getDefaultBaseRef(cwd);
|
|
412
|
+
const { mergeBase, sourceCommitShas } = getReleaseTrackSourceCommitShas(releaseExists, baseRef, cwd);
|
|
413
|
+
const sourceCommits = sourceCommitShas.map((sha) => inspectCommit(sha, cwd));
|
|
414
|
+
const selection = selectReleaseTags(sourceCommits, listTags(cwd));
|
|
415
|
+
|
|
416
|
+
return {
|
|
417
|
+
sourceBranch,
|
|
418
|
+
baseRef,
|
|
419
|
+
mergeBase,
|
|
420
|
+
releaseExists,
|
|
421
|
+
taggedVersions: selection.taggedVersions,
|
|
422
|
+
latestDetectedVersion: selection.latestDetectedVersion,
|
|
423
|
+
tags: selection.tags
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export function finalizeReleaseMergePlan(plan) {
|
|
428
|
+
return {
|
|
429
|
+
...plan,
|
|
430
|
+
totalCommits: plan.windows.reduce((count, window) => count + window.commits.length, 0)
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
export function finalizeReleaseTagPlan(plan) {
|
|
435
|
+
return {
|
|
436
|
+
...plan,
|
|
437
|
+
totalCommits: plan.tags.reduce((count, tag) => count + tag.commits.length, 0)
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
export function formatReleaseMergePlan(plan) {
|
|
442
|
+
const lines = [
|
|
443
|
+
colorize("Release Merge Plan", ANSI.bold + ANSI.cyan),
|
|
444
|
+
`${colorize("Source Branch:", ANSI.bold + ANSI.cyan)} ${plan.sourceBranch}`,
|
|
445
|
+
`${colorize("Target Branch:", ANSI.bold + ANSI.cyan)} ${plan.releaseBranch}`,
|
|
446
|
+
`${colorize("Base Ref:", ANSI.bold + ANSI.cyan)} ${plan.baseRef}`,
|
|
447
|
+
`${colorize("Released Versions:", ANSI.bold + ANSI.cyan)} ${
|
|
448
|
+
plan.releasedVersions.length > 0 ? plan.releasedVersions.join(", ") : "none"
|
|
449
|
+
}`,
|
|
450
|
+
`${colorize("Latest Detected Version:", ANSI.bold + ANSI.cyan)} ${plan.latestDetectedVersion ?? "none"}`
|
|
451
|
+
];
|
|
452
|
+
|
|
453
|
+
if (plan.windows.length === 0) {
|
|
454
|
+
lines.push(colorize("No unreleased release commits detected. Nothing to merge.", ANSI.green));
|
|
455
|
+
return lines.join("\n");
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
for (const window of plan.windows) {
|
|
459
|
+
lines.push("");
|
|
460
|
+
lines.push(colorize(`release ${window.version}`, ANSI.bold + ANSI.yellow));
|
|
461
|
+
lines.push(`${colorize("Commit Range:", ANSI.bold + ANSI.cyan)} ${window.startRef}..${window.endRef}`);
|
|
462
|
+
|
|
463
|
+
for (const commit of window.commits) {
|
|
464
|
+
lines.push(`${colorize(commit.shortSha, ANSI.bold + ANSI.cyan)} ${commit.subject}`);
|
|
465
|
+
if (commit.versionChange.hasVersionChange) {
|
|
466
|
+
lines.push(` ${colorize("Version:", ANSI.bold + ANSI.cyan)} ${summarizeVersionPair(commit.versionChange)}`);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
return lines.join("\n");
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
export function formatReleaseTagPlan(plan) {
|
|
475
|
+
const lines = [
|
|
476
|
+
colorize("Release Tag Plan", ANSI.bold + ANSI.cyan),
|
|
477
|
+
`${colorize("Source Branch:", ANSI.bold + ANSI.cyan)} ${plan.sourceBranch}`,
|
|
478
|
+
`${colorize("Base Ref:", ANSI.bold + ANSI.cyan)} ${plan.baseRef}`,
|
|
479
|
+
`${colorize("Tagged Versions:", ANSI.bold + ANSI.cyan)} ${
|
|
480
|
+
plan.taggedVersions.length > 0 ? plan.taggedVersions.join(", ") : "none"
|
|
481
|
+
}`,
|
|
482
|
+
`${colorize("Latest Detected Version:", ANSI.bold + ANSI.cyan)} ${plan.latestDetectedVersion ?? "none"}`
|
|
483
|
+
];
|
|
484
|
+
|
|
485
|
+
if (plan.tags.length === 0) {
|
|
486
|
+
lines.push(colorize("No unreleased release tags detected. Nothing to tag.", ANSI.green));
|
|
487
|
+
return lines.join("\n");
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
for (const tag of plan.tags) {
|
|
491
|
+
lines.push("");
|
|
492
|
+
lines.push(colorize(`tag ${tag.tagName}`, ANSI.bold + ANSI.yellow));
|
|
493
|
+
lines.push(`${colorize("Commit Range:", ANSI.bold + ANSI.cyan)} ${tag.startRef}..${tag.endRef}`);
|
|
494
|
+
lines.push(`${colorize("Target Commit:", ANSI.bold + ANSI.cyan)} ${tag.targetShortSha} ${tag.targetSubject}`);
|
|
495
|
+
|
|
496
|
+
for (const commit of tag.commits) {
|
|
497
|
+
lines.push(`${colorize(commit.shortSha, ANSI.bold + ANSI.cyan)} ${commit.subject}`);
|
|
498
|
+
if (commit.versionChange.hasVersionChange) {
|
|
499
|
+
lines.push(` ${colorize("Version:", ANSI.bold + ANSI.cyan)} ${summarizeVersionPair(commit.versionChange)}`);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
return lines.join("\n");
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function buildRecoveryMessage({ originalBranch, originalReleaseSha, createdReleaseBranch }) {
|
|
508
|
+
const lines = ["Release promotion failed. Recovery steps:"];
|
|
509
|
+
|
|
510
|
+
if (createdReleaseBranch) {
|
|
511
|
+
lines.push(`- Return to ${originalBranch} with \`git checkout ${originalBranch}\``);
|
|
512
|
+
lines.push(`- Delete the temporary ${RELEASE_BRANCH} branch with \`git branch -D ${RELEASE_BRANCH}\``);
|
|
513
|
+
return lines.join("\n");
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
lines.push(`- Reset ${RELEASE_BRANCH} back to ${originalReleaseSha} with \`git reset --hard ${originalReleaseSha}\``);
|
|
517
|
+
lines.push(`- Return to ${originalBranch} with \`git checkout ${originalBranch}\``);
|
|
518
|
+
return lines.join("\n");
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
export function executeReleaseMerge(plan, cwd) {
|
|
522
|
+
if (plan.windows.length === 0) {
|
|
523
|
+
throw new Error("No unreleased release commits detected. Nothing to merge.");
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
if (!isWorkingTreeClean(cwd)) {
|
|
527
|
+
throw new Error("Working tree must be clean before executing a release merge.");
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const originalBranch = getCurrentBranchName(cwd);
|
|
531
|
+
const releaseExists = localBranchExists(RELEASE_BRANCH, cwd);
|
|
532
|
+
const originalReleaseSha = releaseExists ? resolveCommitSha(RELEASE_BRANCH, cwd) : null;
|
|
533
|
+
const originalHeadSha = getCurrentHeadSha(cwd);
|
|
534
|
+
const originalHeadFiles = releaseExists ? [] : listFilesInRef("HEAD", cwd);
|
|
535
|
+
|
|
536
|
+
try {
|
|
537
|
+
if (releaseExists) {
|
|
538
|
+
gitCheckout(RELEASE_BRANCH, cwd);
|
|
539
|
+
} else {
|
|
540
|
+
gitCheckoutOrphan(RELEASE_BRANCH, cwd);
|
|
541
|
+
gitRemoveCachedAll(cwd);
|
|
542
|
+
deletePaths(originalHeadFiles, cwd);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
for (const window of plan.windows) {
|
|
546
|
+
for (const commit of window.commits) {
|
|
547
|
+
gitCherryPickNoCommit(commit.sha, cwd);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
gitCommit(`release ${window.version}`, cwd);
|
|
551
|
+
}
|
|
552
|
+
} catch (error) {
|
|
553
|
+
gitCherryPickAbort(cwd);
|
|
554
|
+
|
|
555
|
+
try {
|
|
556
|
+
if (releaseExists) {
|
|
557
|
+
gitResetHard(originalReleaseSha, cwd);
|
|
558
|
+
gitCheckout(originalBranch, cwd);
|
|
559
|
+
} else {
|
|
560
|
+
gitCheckout(originalBranch, cwd);
|
|
561
|
+
gitDeleteBranch(RELEASE_BRANCH, cwd);
|
|
562
|
+
}
|
|
563
|
+
} catch {
|
|
564
|
+
// Preserve original failure and print recovery guidance below.
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
console.error(error.message);
|
|
568
|
+
console.error(
|
|
569
|
+
buildRecoveryMessage({
|
|
570
|
+
originalBranch,
|
|
571
|
+
originalReleaseSha,
|
|
572
|
+
createdReleaseBranch: !releaseExists
|
|
573
|
+
})
|
|
574
|
+
);
|
|
575
|
+
throw new Error("Release merge aborted.");
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const updatedReleaseSha = getCurrentHeadSha(cwd);
|
|
579
|
+
if (updatedReleaseSha === originalHeadSha) {
|
|
580
|
+
throw new Error("Release merge did not create any new commits.");
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
export function executeReleaseTagPlan(plan, cwd) {
|
|
585
|
+
if (plan.tags.length === 0) {
|
|
586
|
+
throw new Error("No unreleased release tags detected. Nothing to tag.");
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const createdTags = [];
|
|
590
|
+
|
|
591
|
+
try {
|
|
592
|
+
for (const tag of plan.tags) {
|
|
593
|
+
gitCreateAnnotatedTag(tag.tagName, tag.targetSha, `release ${tag.tagName}`, cwd);
|
|
594
|
+
createdTags.push(tag.tagName);
|
|
595
|
+
}
|
|
596
|
+
} catch (error) {
|
|
597
|
+
for (const tagName of createdTags.reverse()) {
|
|
598
|
+
try {
|
|
599
|
+
gitDeleteTag(tagName, cwd);
|
|
600
|
+
} catch {
|
|
601
|
+
// Preserve the original failure; partial cleanup is best-effort.
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
throw error;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
export { RELEASE_BRANCH };
|