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.
@@ -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 };