claude-git-hooks 2.21.0 → 2.30.2

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.
@@ -23,6 +23,21 @@ import { getBranchPushStatus, pushBranch } from '../utils/git-operations.js';
23
23
  import logger from '../utils/logger.js';
24
24
  import { error, checkGitRepo } from './helpers.js';
25
25
 
26
+ /**
27
+ * Detect expected merge strategy from branch naming conventions
28
+ * @param {string} sourceBranch - Source (head) branch name
29
+ * @param {string} targetBranch - Target (base) branch name
30
+ * @returns {'squash'|'merge-commit'|'unknown'}
31
+ */
32
+ function detectMergeStrategy(sourceBranch, targetBranch) {
33
+ if (targetBranch === 'main') return 'merge-commit';
34
+ if (sourceBranch.startsWith('feature/')) return 'squash';
35
+ if (sourceBranch.startsWith('release-fix/')) return 'squash';
36
+ if (sourceBranch.startsWith('release-candidate/')) return 'merge-commit';
37
+ if (sourceBranch.startsWith('hotfix/')) return 'merge-commit';
38
+ return 'unknown';
39
+ }
40
+
26
41
  /**
27
42
  * Create PR command (v2.5.0+ - Octokit-based)
28
43
  * @param {Array<string>} args - Command arguments
@@ -48,8 +63,9 @@ export async function runCreatePr(args) {
48
63
 
49
64
  // Import GitHub API module
50
65
  logger.debug('create-pr', 'Importing GitHub API modules');
51
- const { createPullRequest, validateToken, findExistingPR } =
52
- await import('../utils/github-api.js');
66
+ const { createPullRequest, validateToken, findExistingPR } = await import(
67
+ '../utils/github-api.js'
68
+ );
53
69
  const { parseGitHubRepo } = await import('../utils/github-client.js');
54
70
 
55
71
  showInfo('🚀 Creating Pull Request...');
@@ -552,6 +568,39 @@ export async function runCreatePr(args) {
552
568
  }
553
569
  logger.debug('create-pr', 'Labels determined', { labels, preset: config.preset });
554
570
 
571
+ // Step 9.5: Detect merge strategy
572
+ logger.debug('create-pr', 'Step 9.5: Detecting merge strategy', {
573
+ sourceBranch: currentBranch,
574
+ targetBranch: baseBranch
575
+ });
576
+ let mergeStrategy = detectMergeStrategy(currentBranch, baseBranch);
577
+
578
+ if (mergeStrategy === 'unknown') {
579
+ showWarning('Unknown branch pattern — cannot auto-detect merge strategy');
580
+ console.log('');
581
+ const strategyChoice = await promptMenu(
582
+ 'Select merge strategy:',
583
+ [
584
+ { key: 's', label: 'Squash merge' },
585
+ { key: 'm', label: 'Merge commit' },
586
+ { key: 'k', label: 'Skip (no strategy label)' }
587
+ ],
588
+ 'k'
589
+ );
590
+ if (strategyChoice === 's') mergeStrategy = 'squash';
591
+ else if (strategyChoice === 'm') mergeStrategy = 'merge-commit';
592
+ }
593
+
594
+ if (mergeStrategy !== 'unknown') {
595
+ labels.push(`merge-strategy:${mergeStrategy}`);
596
+ logger.debug('create-pr', 'Merge strategy label added', { mergeStrategy });
597
+ }
598
+
599
+ let finalBody = prBody;
600
+ if (mergeStrategy === 'merge-commit') {
601
+ finalBody = `> ⚠️ This PR must be merged with **merge commit** (not squash)\n\n${prBody}`;
602
+ }
603
+
555
604
  // Step 10: Get reviewers from CODEOWNERS and config
556
605
  logger.debug('create-pr', 'Step 10: Getting reviewers from CODEOWNERS and config');
557
606
  const reviewers = await getReviewersForFiles(filesArray, config.github?.pr);
@@ -563,7 +612,7 @@ export async function runCreatePr(args) {
563
612
  // Step 11: Show PR preview
564
613
  const prData = {
565
614
  title: prTitle,
566
- body: prBody,
615
+ body: finalBody,
567
616
  head: currentBranch,
568
617
  base: baseBranch,
569
618
  labels,
@@ -572,6 +621,16 @@ export async function runCreatePr(args) {
572
621
 
573
622
  showPRPreview(prData);
574
623
 
624
+ let strategyLabel;
625
+ if (mergeStrategy === 'squash') {
626
+ strategyLabel = 'SQUASH MERGE';
627
+ } else if (mergeStrategy === 'merge-commit') {
628
+ strategyLabel = 'MERGE COMMIT';
629
+ } else {
630
+ strategyLabel = 'UNKNOWN';
631
+ }
632
+ showInfo(`Merge strategy: ${strategyLabel} (${currentBranch} → ${baseBranch})`);
633
+
575
634
  // Step 12: Prompt for confirmation
576
635
  const action = await promptMenu(
577
636
  'What would you like to do?',
@@ -0,0 +1,600 @@
1
+ /**
2
+ * File: create-release.js
3
+ * Purpose: create-release command — release-candidate branch creation from develop
4
+ *
5
+ * Workflow:
6
+ * 1. Validate preconditions (clean tree, on develop, up-to-date, no existing RC)
7
+ * 2. Discover version files — abort on mismatch
8
+ * 3. Calculate next version
9
+ * 4. [dry-run] Preview + return
10
+ * 5. Confirm with user
11
+ * 6. Create RC branch from develop
12
+ * 7. Bump version files + commit (--no-verify)
13
+ * 8. [optional] Generate CHANGELOG
14
+ * 9. Create Git tag (skip if already exists)
15
+ * 10. Push RC branch (unless --skip-push)
16
+ * 11. Deploy to shadow (unless --no-shadow or --skip-push)
17
+ * 12. Return to RC branch
18
+ * 13. Display summary
19
+ */
20
+
21
+ import { execSync } from 'child_process';
22
+ import fs from 'fs';
23
+ import path from 'path';
24
+ import {
25
+ discoverVersionFiles,
26
+ incrementVersion,
27
+ updateVersionFiles
28
+ } from '../utils/version-manager.js';
29
+ import { createTag, tagExists, formatTagName } from '../utils/git-tag-manager.js';
30
+ import {
31
+ generateChangelogEntry,
32
+ updateChangelogFile,
33
+ selectChangelogFile
34
+ } from '../utils/changelog-generator.js';
35
+ import {
36
+ getRepoRoot,
37
+ getCurrentBranch,
38
+ verifyRemoteExists,
39
+ getRemoteName,
40
+ fetchRemote,
41
+ getDivergence,
42
+ getRemoteBranches,
43
+ isWorkingDirectoryClean,
44
+ checkoutBranch,
45
+ pushBranch,
46
+ stageFiles,
47
+ createCommit
48
+ } from '../utils/git-operations.js';
49
+ import { runShadow } from './shadow.js';
50
+ import { getConfig } from '../config.js';
51
+ import {
52
+ showInfo,
53
+ showSuccess,
54
+ showError,
55
+ showWarning,
56
+ promptConfirmation
57
+ } from '../utils/interactive-ui.js';
58
+ import logger from '../utils/logger.js';
59
+ import { colors, error, checkGitRepo } from './helpers.js';
60
+
61
+ /** Source branch for RC creation */
62
+ const SOURCE_BRANCH = 'develop';
63
+
64
+ /** RC branch prefix */
65
+ const RC_PREFIX = 'release-candidate';
66
+
67
+ /**
68
+ * Parses command line arguments
69
+ *
70
+ * @param {string[]} args - CLI arguments
71
+ * @returns {Object} Parsed options
72
+ */
73
+ function parseArguments(args) {
74
+ const parsed = {
75
+ bumpType: null,
76
+ noShadow: false,
77
+ dryRun: false,
78
+ skipPush: false,
79
+ updateChangelog: false
80
+ };
81
+
82
+ if (args.length === 0) return parsed;
83
+
84
+ let i = 0;
85
+ const firstArg = args[0].toLowerCase();
86
+ if (['major', 'minor', 'patch'].includes(firstArg)) {
87
+ parsed.bumpType = firstArg;
88
+ i = 1;
89
+ }
90
+
91
+ for (; i < args.length; i++) {
92
+ const arg = args[i];
93
+ if (arg === '--no-shadow') parsed.noShadow = true;
94
+ else if (arg === '--dry-run') parsed.dryRun = true;
95
+ else if (arg === '--skip-push') parsed.skipPush = true;
96
+ else if (arg === '--update-changelog') parsed.updateChangelog = true;
97
+ }
98
+
99
+ return parsed;
100
+ }
101
+
102
+ /**
103
+ * Validates preconditions before creating a release-candidate
104
+ * Checks: git repo, clean tree, on develop, develop up-to-date, develop not behind main, no existing RC
105
+ *
106
+ * @returns {Promise<{ valid: boolean, errors: Array<{ type: string, message: string, fix?: string[] }> }>}
107
+ */
108
+ async function validatePreconditions() {
109
+ const errors = [];
110
+
111
+ if (!checkGitRepo()) {
112
+ errors.push({ type: 'fatal', message: 'Not a git repository' });
113
+ return { valid: false, errors };
114
+ }
115
+
116
+ if (!isWorkingDirectoryClean()) {
117
+ errors.push({
118
+ type: 'fatal',
119
+ message: 'Working directory has uncommitted changes',
120
+ fix: [
121
+ 'Commit your changes: git add . && git commit -m "..."',
122
+ 'Or stash them: git stash'
123
+ ]
124
+ });
125
+ }
126
+
127
+ const currentBranch = getCurrentBranch();
128
+ if (currentBranch !== SOURCE_BRANCH) {
129
+ errors.push({
130
+ type: 'fatal',
131
+ message: `Must be on '${SOURCE_BRANCH}' branch (currently on '${currentBranch}')`,
132
+ fix: [`git checkout ${SOURCE_BRANCH}`]
133
+ });
134
+ // Return early: divergence checks require being on develop
135
+ return { valid: false, errors };
136
+ }
137
+
138
+ try {
139
+ fetchRemote();
140
+ } catch (e) {
141
+ errors.push({
142
+ type: 'warn',
143
+ message: `Could not fetch from remote: ${e.message} — results may be stale`
144
+ });
145
+ }
146
+
147
+ try {
148
+ const devSync = getDivergence(SOURCE_BRANCH, `origin/${SOURCE_BRANCH}`);
149
+ if (devSync.behind > 0) {
150
+ errors.push({
151
+ type: 'fatal',
152
+ message: `Local '${SOURCE_BRANCH}' is ${devSync.behind} commit(s) behind remote`,
153
+ fix: [`git pull origin ${SOURCE_BRANCH}`]
154
+ });
155
+ }
156
+ } catch (e) {
157
+ errors.push({
158
+ type: 'warn',
159
+ message: `Could not check if ${SOURCE_BRANCH} is up-to-date with remote: ${e.message}`
160
+ });
161
+ }
162
+
163
+ try {
164
+ const mainSync = getDivergence(`origin/${SOURCE_BRANCH}`, 'origin/main');
165
+ if (mainSync.behind > 0) {
166
+ errors.push({
167
+ type: 'fatal',
168
+ message: `'${SOURCE_BRANCH}' is missing ${mainSync.behind} commit(s) from 'main' — a back-merge is required`,
169
+ fix: [
170
+ 'Merge main into develop first:',
171
+ ` git checkout ${SOURCE_BRANCH} && git merge origin/main`,
172
+ ' or: claude-hooks back-merge (when available)'
173
+ ]
174
+ });
175
+ }
176
+ } catch (e) {
177
+ errors.push({
178
+ type: 'warn',
179
+ message: `Could not verify ${SOURCE_BRANCH} vs main sync: ${e.message}`
180
+ });
181
+ }
182
+
183
+ const remoteBranches = getRemoteBranches();
184
+ const existingRCs = remoteBranches.filter((b) => b.startsWith(`${RC_PREFIX}/`));
185
+ if (existingRCs.length > 0) {
186
+ errors.push({
187
+ type: 'fatal',
188
+ message: `Existing release-candidate branch found: ${existingRCs.join(', ')}`,
189
+ fix: [
190
+ 'Close the active release-candidate first:',
191
+ ' claude-hooks close-release (when available)',
192
+ ` or delete manually: git push origin --delete ${existingRCs[0]}`
193
+ ]
194
+ });
195
+ }
196
+
197
+ const remoteName = getRemoteName();
198
+ if (!verifyRemoteExists(remoteName)) {
199
+ errors.push({
200
+ type: 'fatal',
201
+ message: `Remote '${remoteName}' does not exist`,
202
+ fix: ['git remote add origin <url>']
203
+ });
204
+ }
205
+
206
+ const fatalErrors = errors.filter((e) => e.type === 'fatal');
207
+ return { valid: fatalErrors.length === 0, errors };
208
+ }
209
+
210
+ /**
211
+ * Displays a dry-run preview of what would be executed
212
+ *
213
+ * @param {Object} info - Preview info
214
+ */
215
+ function showDryRunPreview({ bumpType, currentVersion, nextVersion, rcBranch, options }) {
216
+ const tagName = formatTagName(nextVersion);
217
+
218
+ console.log('');
219
+ console.log(`${colors.yellow}═════════════════════════════════════════════════${colors.reset}`);
220
+ console.log(`${colors.yellow} DRY RUN — No changes will be made ${colors.reset}`);
221
+ console.log(`${colors.yellow}═════════════════════════════════════════════════${colors.reset}`);
222
+ console.log('');
223
+ console.log(`${colors.blue}Operation:${colors.reset} Bump ${bumpType}`);
224
+ console.log(`${colors.blue}Current version:${colors.reset} ${currentVersion}`);
225
+ console.log(`${colors.green}New version:${colors.reset} ${nextVersion}`);
226
+ console.log(`${colors.blue}Branch:${colors.reset} ${rcBranch}`);
227
+ console.log(`${colors.blue}Tag:${colors.reset} ${tagName}`);
228
+ console.log(
229
+ `${colors.blue}Push:${colors.reset} ${options.skipPush ? 'skipped (--skip-push)' : 'yes → origin'}`
230
+ );
231
+ console.log(
232
+ `${colors.blue}Shadow sync:${colors.reset} ${
233
+ options.noShadow
234
+ ? 'skipped (--no-shadow)'
235
+ : options.skipPush
236
+ ? 'skipped (branch not pushed)'
237
+ : 'yes'
238
+ }`
239
+ );
240
+ if (options.updateChangelog) {
241
+ console.log(`${colors.blue}CHANGELOG:${colors.reset} will be generated`);
242
+ }
243
+ console.log('');
244
+ console.log(`${colors.yellow}Run without --dry-run to apply these changes${colors.reset}`);
245
+ console.log('');
246
+ }
247
+
248
+ /**
249
+ * Restores version files from a snapshot map (rollback)
250
+ *
251
+ * @param {Map<string, string>} snapshot - Map of filePath → original content
252
+ */
253
+ function restoreSnapshot(snapshot) {
254
+ snapshot.forEach((content, filePath) => {
255
+ try {
256
+ fs.writeFileSync(filePath, content, 'utf8');
257
+ } catch (e) {
258
+ logger.error('create-release', 'Rollback failed during snapshot restoration', {
259
+ path: filePath,
260
+ operation: 'restoreSnapshot',
261
+ error: e.message
262
+ });
263
+ }
264
+ });
265
+ }
266
+
267
+ /**
268
+ * create-release command
269
+ * Creates a release-candidate branch from develop, bumps version, and deploys to shadow.
270
+ *
271
+ * @param {string[]} args - CLI arguments
272
+ */
273
+ export async function runCreateRelease(args) {
274
+ logger.debug('create-release', 'Starting create-release command', { args });
275
+
276
+ const options = parseArguments(args);
277
+
278
+ if (!options.bumpType) {
279
+ error('Usage: claude-hooks create-release <major|minor|patch> [options]');
280
+ console.log('');
281
+ console.log('Options:');
282
+ console.log(' --no-shadow Skip shadow deployment');
283
+ console.log(' --dry-run Preview changes without executing');
284
+ console.log(' --skip-push Create branch locally only (also skips shadow)');
285
+ console.log(' --update-changelog Generate CHANGELOG entry');
286
+ console.log('');
287
+ console.log('Examples:');
288
+ console.log(' claude-hooks create-release minor');
289
+ console.log(' claude-hooks create-release minor --no-shadow');
290
+ console.log(' claude-hooks create-release minor --dry-run');
291
+ console.log(' claude-hooks create-release patch --update-changelog');
292
+ return;
293
+ }
294
+
295
+ showInfo(`🚀 Creating release-candidate (${options.bumpType})...`);
296
+ console.log('');
297
+
298
+ // Step 1: Validate preconditions
299
+ logger.debug('create-release', 'Step 1: Validating preconditions');
300
+ const { valid, errors } = await validatePreconditions();
301
+
302
+ errors.filter((e) => e.type === 'warn').forEach((e) => showWarning(e.message));
303
+
304
+ if (!valid) {
305
+ const fatalErrors = errors.filter((e) => e.type === 'fatal');
306
+ showError('Precondition check failed:');
307
+ console.log('');
308
+ fatalErrors.forEach((e) => {
309
+ console.log(` ❌ ${e.message}`);
310
+ if (e.fix) {
311
+ e.fix.forEach((line) => console.log(` ${line}`));
312
+ }
313
+ console.log('');
314
+ });
315
+ process.exit(1);
316
+ }
317
+
318
+ showSuccess('Preconditions validated');
319
+ console.log('');
320
+
321
+ // Step 2: Discover version files
322
+ logger.debug('create-release', 'Step 2: Discovering version files');
323
+ const discovery = discoverVersionFiles();
324
+
325
+ if (discovery.files.length === 0) {
326
+ showError('No version files found');
327
+ console.log('');
328
+ console.log('This command requires at least one version file (package.json, pom.xml, etc.)');
329
+ process.exit(1);
330
+ }
331
+
332
+ if (discovery.mismatch) {
333
+ showError('Version mismatch detected — cannot proceed automatically');
334
+ console.log('');
335
+ console.log(` ${'File'.padEnd(40)} ${'Type'.padEnd(18)} Version`);
336
+ console.log(` ${'-'.repeat(40)} ${'-'.repeat(18)} -------`);
337
+ discovery.files.forEach((f) => {
338
+ const marker = f.version !== discovery.resolvedVersion ? '⚠️ ' : ' ';
339
+ console.log(
340
+ ` ${marker}${f.relativePath.padEnd(37)} ${f.projectLabel.padEnd(18)} ${f.version || 'N/A'}`
341
+ );
342
+ });
343
+ console.log('');
344
+ console.log('Fix: align all version files to the same version, then re-run.');
345
+ console.log(' 1. Edit version files manually to match');
346
+ console.log(
347
+ ' 2. Commit: git add . && git commit -m "chore(version): align versions"'
348
+ );
349
+ console.log(` 3. Re-run: claude-hooks create-release ${options.bumpType}`);
350
+ process.exit(1);
351
+ }
352
+
353
+ const currentVersion = discovery.resolvedVersion;
354
+ if (!currentVersion) {
355
+ showError('Could not determine current version from discovered files');
356
+ process.exit(1);
357
+ }
358
+
359
+ // Step 3: Calculate next version
360
+ const nextVersion = incrementVersion(currentVersion, options.bumpType);
361
+ const rcBranch = `${RC_PREFIX}/V${nextVersion}`;
362
+ const tagName = formatTagName(nextVersion);
363
+
364
+ showInfo(`Current version: ${currentVersion}`);
365
+ showSuccess(`Next version: ${nextVersion}`);
366
+ showInfo(`Branch: ${rcBranch}`);
367
+ console.log('');
368
+
369
+ // Step 4: Dry-run
370
+ if (options.dryRun) {
371
+ showDryRunPreview({ bumpType: options.bumpType, currentVersion, nextVersion, rcBranch, options });
372
+ return;
373
+ }
374
+
375
+ // Step 5: Confirm with user
376
+ const confirmed = await promptConfirmation(
377
+ `Create ${rcBranch} and bump version to ${nextVersion}?`,
378
+ true
379
+ );
380
+ if (!confirmed) {
381
+ showInfo('Release creation cancelled');
382
+ process.exit(0);
383
+ }
384
+
385
+ console.log('');
386
+
387
+ try {
388
+ const config = await getConfig();
389
+ const selectedFiles = discovery.files.filter((f) => f.selected);
390
+
391
+ // Step 6: Create RC branch from develop
392
+ logger.debug('create-release', 'Step 6: Creating RC branch', { rcBranch });
393
+ showInfo(`Creating branch ${rcBranch} from ${SOURCE_BRANCH}...`);
394
+ checkoutBranch(rcBranch, { create: true, startPoint: SOURCE_BRANCH });
395
+ showSuccess(`✓ Branch created: ${rcBranch}`);
396
+ console.log('');
397
+
398
+ // Snapshot for rollback
399
+ const snapshot = new Map();
400
+ selectedFiles.forEach((file) => {
401
+ try {
402
+ snapshot.set(file.path, fs.readFileSync(file.path, 'utf8'));
403
+ } catch (e) {
404
+ logger.error('create-release', 'Failed to snapshot file', {
405
+ path: file.path,
406
+ error: e.message
407
+ });
408
+ }
409
+ });
410
+
411
+ // Step 7: Bump version files
412
+ logger.debug('create-release', 'Step 7: Bumping version files');
413
+ showInfo('Updating version files...');
414
+ try {
415
+ updateVersionFiles(selectedFiles, nextVersion);
416
+ selectedFiles.forEach((f) =>
417
+ showSuccess(`✓ Updated ${f.relativePath} → ${nextVersion}`)
418
+ );
419
+ console.log('');
420
+ } catch (e) {
421
+ showError(`Failed to update version files: ${e.message}`);
422
+ showWarning('Rolling back version changes and deleting branch...');
423
+ restoreSnapshot(snapshot);
424
+ try {
425
+ checkoutBranch(SOURCE_BRANCH);
426
+ execSync(`git branch -D "${rcBranch}"`, {
427
+ encoding: 'utf8',
428
+ stdio: ['pipe', 'pipe', 'pipe']
429
+ });
430
+ } catch {
431
+ // best-effort cleanup
432
+ }
433
+ process.exit(1);
434
+ }
435
+
436
+ // Step 8: Optional CHANGELOG
437
+ let selectedChangelogPath = null;
438
+ if (options.updateChangelog) {
439
+ logger.debug('create-release', 'Step 8: Generating CHANGELOG');
440
+ showInfo('Generating CHANGELOG entry...');
441
+ const changelogResult = await generateChangelogEntry({
442
+ version: nextVersion,
443
+ isReleaseVersion: true,
444
+ baseBranch: 'main',
445
+ config
446
+ });
447
+ if (changelogResult.content) {
448
+ selectedChangelogPath = await selectChangelogFile();
449
+ const updated = updateChangelogFile(changelogResult.content, selectedChangelogPath);
450
+ if (updated) {
451
+ showSuccess('✓ CHANGELOG.md updated');
452
+ } else {
453
+ showWarning('⚠ Failed to update CHANGELOG.md');
454
+ }
455
+ } else {
456
+ showWarning('⚠ No commits found for CHANGELOG');
457
+ }
458
+ console.log('');
459
+ }
460
+
461
+ // Stage and commit (--no-verify)
462
+ logger.debug('create-release', 'Staging and committing version bump');
463
+ showInfo('Staging and committing...');
464
+ const filesToStage = selectedFiles.map((f) => f.path);
465
+ if (options.updateChangelog) {
466
+ const changelogPath =
467
+ selectedChangelogPath || path.join(getRepoRoot(), 'CHANGELOG.md');
468
+ if (fs.existsSync(changelogPath)) {
469
+ filesToStage.push(changelogPath);
470
+ }
471
+ }
472
+
473
+ const stageResult = stageFiles(filesToStage);
474
+ if (!stageResult.success) {
475
+ showError(`Failed to stage files: ${stageResult.error}`);
476
+ showWarning('Rolling back...');
477
+ restoreSnapshot(snapshot);
478
+ process.exit(1);
479
+ }
480
+
481
+ const commitResult = createCommit(`chore(version): bump to ${nextVersion}`, {
482
+ noVerify: true
483
+ });
484
+ if (!commitResult.success) {
485
+ showError(`Failed to create commit: ${commitResult.error}`);
486
+ showWarning('Rolling back...');
487
+ restoreSnapshot(snapshot);
488
+ try {
489
+ execSync('git reset HEAD', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
490
+ } catch {
491
+ // best-effort
492
+ }
493
+ process.exit(1);
494
+ }
495
+
496
+ showSuccess('✓ Version bump committed');
497
+ console.log('');
498
+
499
+ // Step 9: Create Git tag (skip silently if already exists)
500
+ logger.debug('create-release', 'Step 9: Creating Git tag', { tagName });
501
+ const tagAlreadyExists = await tagExists(tagName, 'local');
502
+ if (tagAlreadyExists) {
503
+ showWarning(`Tag ${tagName} already exists locally — skipping tag creation`);
504
+ } else {
505
+ const tagResult = createTag(nextVersion, `Release version ${nextVersion}`);
506
+ if (tagResult.success) {
507
+ showSuccess(`✓ Tag created: ${tagName}`);
508
+ } else {
509
+ showWarning(`⚠ Failed to create tag: ${tagResult.error}`);
510
+ }
511
+ }
512
+ console.log('');
513
+
514
+ // Step 10: Push RC branch (unless --skip-push)
515
+ let pushStatus = 'skipped (--skip-push)';
516
+ if (!options.skipPush) {
517
+ showInfo(`Pushing ${rcBranch} to remote...`);
518
+ const pushResult = pushBranch(rcBranch, { setUpstream: true });
519
+ if (pushResult.success) {
520
+ showSuccess(`✓ Pushed ${rcBranch} to remote`);
521
+ pushStatus = 'pushed';
522
+ } else {
523
+ showError(`Failed to push branch: ${pushResult.error}`);
524
+ console.log('');
525
+ console.log('You can push manually:');
526
+ console.log(` git push -u origin ${rcBranch}`);
527
+ pushStatus = 'failed';
528
+ }
529
+ console.log('');
530
+ }
531
+
532
+ // Step 11: Deploy to shadow (unless --no-shadow or --skip-push)
533
+ let shadowStatus = 'skipped';
534
+ if (options.noShadow) {
535
+ showInfo('Shadow deployment skipped (--no-shadow)');
536
+ shadowStatus = 'skipped (--no-shadow)';
537
+ console.log('');
538
+ } else if (options.skipPush || pushStatus === 'failed') {
539
+ showInfo('Shadow deployment skipped — branch not pushed to remote');
540
+ shadowStatus = 'skipped (branch not pushed)';
541
+ console.log('');
542
+ } else {
543
+ showInfo('Deploying to shadow...');
544
+ console.log('');
545
+ try {
546
+ await runShadow(['sync', rcBranch]);
547
+ shadowStatus = 'synced';
548
+ } catch (e) {
549
+ showWarning(`Shadow sync encountered an error: ${e.message}`);
550
+ shadowStatus = 'failed';
551
+ }
552
+ }
553
+
554
+ // Step 12: Ensure we land on RC branch
555
+ const currentBranchAfter = getCurrentBranch();
556
+ if (currentBranchAfter !== rcBranch) {
557
+ try {
558
+ checkoutBranch(rcBranch);
559
+ } catch (e) {
560
+ showWarning(`Could not return to ${rcBranch}: ${e.message}`);
561
+ }
562
+ }
563
+
564
+ // Step 13: Summary
565
+ console.log('');
566
+ console.log(
567
+ `${colors.green}════════════════════════════════════════════════════${colors.reset}`
568
+ );
569
+ console.log(
570
+ `${colors.green} Release Candidate Created ✅ ${colors.reset}`
571
+ );
572
+ console.log(
573
+ `${colors.green}════════════════════════════════════════════════════${colors.reset}`
574
+ );
575
+ console.log('');
576
+ console.log(`${colors.blue}Branch:${colors.reset} ${rcBranch}`);
577
+ console.log(`${colors.blue}Version:${colors.reset} ${nextVersion}`);
578
+ console.log(
579
+ `${colors.blue}Tag:${colors.reset} ${tagAlreadyExists ? `${tagName} (pre-existing, skipped)` : tagName}`
580
+ );
581
+ console.log(`${colors.blue}Push:${colors.reset} ${pushStatus}`);
582
+ console.log(`${colors.blue}Shadow:${colors.reset} ${shadowStatus}`);
583
+ console.log('');
584
+
585
+ if (options.skipPush) {
586
+ console.log('Next steps:');
587
+ console.log(` 1. Push when ready: git push -u origin ${rcBranch}`);
588
+ console.log(` 2. Sync shadow: claude-hooks shadow sync ${rcBranch}`);
589
+ } else {
590
+ console.log('Next steps:');
591
+ console.log(' 1. Verify shadow deployment is running');
592
+ console.log(' 2. Run QA on shadow environment');
593
+ }
594
+ console.log('');
595
+ } catch (e) {
596
+ logger.error('create-release', 'Command failed unexpectedly', e);
597
+ showError(`create-release failed: ${e.message}`);
598
+ process.exit(1);
599
+ }
600
+ }