claude-git-hooks 2.21.0 → 2.30.1

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,740 @@
1
+ /**
2
+ * File: back-merge.js
3
+ * Purpose: back-merge command — post-deploy branch synchronization
4
+ *
5
+ * Flow:
6
+ * 1. Parse args
7
+ * 2. Validate: git repo, clean tree, warn if not on into-branch
8
+ * 3. Fetch remote
9
+ * 4. Detect version from latest local tag
10
+ * 5. Check divergence: behind=0 → already synced
11
+ * 6. Find RC branch(es) in remote for cleanup
12
+ * 7. [--dry-run] Preview + return
13
+ * 8. Confirm with user (show planned actions)
14
+ * 9. Tag release: push existing local tag; create locally if missing (unless --skip-tag)
15
+ * 10. Shadow reset: runShadow(['reset']) (unless --skip-shadow)
16
+ * 11. Execute merge: checkout into, git merge --no-commit --no-ff --no-verify origin/from
17
+ * 12. Handle conflicts:
18
+ * - version files → git checkout --theirs + stage
19
+ * - CHANGELOG → stage as-is (conflict markers) + warn + prompt continue/abort
20
+ * - other → git merge --abort + instruct TL
21
+ * 13. Commit: '[backmerge] Merge {from} (v{version}) into {into}'
22
+ * 14. Verify sync: getDivergence behind=0
23
+ * 15. Push into-branch → detect branch protection + show advice
24
+ * 16. Delete RC branch(es) from remote
25
+ * 17. Revert follow-up: read revert-log, filter by RC, prompt, apply git revert per entry
26
+ * 18. Display summary
27
+ */
28
+
29
+ import { execSync } from 'child_process';
30
+ import fs from 'fs';
31
+ import path from 'path';
32
+ import {
33
+ getCurrentBranch,
34
+ getRepoRoot,
35
+ isWorkingDirectoryClean,
36
+ fetchRemote,
37
+ getDivergence,
38
+ getRemoteBranches,
39
+ checkoutBranch,
40
+ pushBranch,
41
+ deleteRemoteBranch,
42
+ createCommit
43
+ } from '../utils/git-operations.js';
44
+ import {
45
+ getLatestLocalTag,
46
+ parseTagVersion,
47
+ tagExists,
48
+ createTag,
49
+ pushTags
50
+ } from '../utils/git-tag-manager.js';
51
+ import { runShadow } from './shadow.js';
52
+ import {
53
+ showInfo,
54
+ showSuccess,
55
+ showError,
56
+ showWarning,
57
+ promptConfirmation
58
+ } from '../utils/interactive-ui.js';
59
+ import logger from '../utils/logger.js';
60
+ import { colors, error, checkGitRepo } from './helpers.js';
61
+
62
+ /** Default source branch for back-merge */
63
+ const DEFAULT_FROM = 'main';
64
+
65
+ /** Default destination branch for back-merge */
66
+ const DEFAULT_INTO = 'develop';
67
+
68
+ /** Relative path of the revert log file */
69
+ const REVERT_LOG_RELATIVE_PATH = '.claude/revert-log.json';
70
+
71
+ /**
72
+ * Version file basenames — conflicts auto-resolved by accepting source (theirs)
73
+ * Mirrors shadow.js to keep conflict resolution consistent
74
+ */
75
+ const VERSION_FILE_NAMES = new Set([
76
+ 'package.json',
77
+ 'package-lock.json',
78
+ 'pom.xml',
79
+ 'build.gradle',
80
+ 'build.gradle.kts',
81
+ 'VERSION',
82
+ 'version.txt'
83
+ ]);
84
+
85
+ // ─── Helpers ────────────────────────────────────────────────────────────────
86
+
87
+ /**
88
+ * Returns true if the given repo-relative path is a version file
89
+ * @param {string} filePath
90
+ * @returns {boolean}
91
+ */
92
+ function _isVersionFile(filePath) {
93
+ const basename = path.basename(filePath);
94
+ return VERSION_FILE_NAMES.has(basename) || basename.endsWith('.version');
95
+ }
96
+
97
+ /**
98
+ * Returns true if the given repo-relative path is a CHANGELOG file
99
+ * @param {string} filePath
100
+ * @returns {boolean}
101
+ */
102
+ function _isChangelogFile(filePath) {
103
+ const basename = path.basename(filePath).toLowerCase();
104
+ return basename.startsWith('changelog');
105
+ }
106
+
107
+ // ─── Argument parsing ────────────────────────────────────────────────────────
108
+
109
+ /**
110
+ * Parse CLI args into structured options
111
+ * @param {string[]} args
112
+ * @returns {{ from: string, into: string, skipTag: boolean, skipShadow: boolean, dryRun: boolean }}
113
+ */
114
+ function _parseArgs(args) {
115
+ const opts = {
116
+ from: DEFAULT_FROM,
117
+ into: DEFAULT_INTO,
118
+ skipTag: false,
119
+ skipShadow: false,
120
+ dryRun: false
121
+ };
122
+
123
+ for (let i = 0; i < args.length; i++) {
124
+ const arg = args[i];
125
+ if (arg === '--from' && args[i + 1]) {
126
+ opts.from = args[++i];
127
+ } else if (arg === '--into' && args[i + 1]) {
128
+ opts.into = args[++i];
129
+ } else if (arg === '--skip-tag') {
130
+ opts.skipTag = true;
131
+ } else if (arg === '--skip-shadow') {
132
+ opts.skipShadow = true;
133
+ } else if (arg === '--dry-run') {
134
+ opts.dryRun = true;
135
+ }
136
+ }
137
+
138
+ return opts;
139
+ }
140
+
141
+ // ─── Version detection ───────────────────────────────────────────────────────
142
+
143
+ /**
144
+ * Detect release version from the latest local tag
145
+ * @returns {{ version: string|null, tagName: string|null }}
146
+ */
147
+ function _detectVersion() {
148
+ const latestTag = getLatestLocalTag();
149
+ if (!latestTag) {
150
+ logger.debug('back-merge - _detectVersion', 'No local tags found');
151
+ return { version: null, tagName: null };
152
+ }
153
+ const version = parseTagVersion(latestTag);
154
+ logger.debug('back-merge - _detectVersion', 'Version detected from local tag', {
155
+ latestTag,
156
+ version
157
+ });
158
+ return { version, tagName: latestTag };
159
+ }
160
+
161
+ // ─── RC branch discovery ─────────────────────────────────────────────────────
162
+
163
+ /**
164
+ * Find release-candidate branches in remote that match the given version (or any RC if no version)
165
+ * @param {string|null} version - Version string to match (e.g. '2.28.0'), or null to find all
166
+ * @returns {string[]} Matching RC branch names (without remote/ prefix)
167
+ */
168
+ function _findRCBranches(version) {
169
+ const remoteBranches = getRemoteBranches();
170
+ const allRC = remoteBranches.filter((b) => b.startsWith('release-candidate/'));
171
+ if (!version) return allRC;
172
+ return allRC.filter((b) => b.includes(version));
173
+ }
174
+
175
+ // ─── Conflict resolution ─────────────────────────────────────────────────────
176
+
177
+ /**
178
+ * Abort an in-progress merge and return to the previous branch
179
+ * @param {string} previousBranch
180
+ */
181
+ function _abortMergeAndReturn(previousBranch) {
182
+ try {
183
+ execSync('git merge --abort', {
184
+ encoding: 'utf8',
185
+ stdio: ['pipe', 'pipe', 'pipe']
186
+ });
187
+ logger.debug('back-merge - _abortMergeAndReturn', 'Merge aborted');
188
+ } catch {
189
+ // merge may not be in progress — ignore
190
+ }
191
+ try {
192
+ checkoutBranch(previousBranch);
193
+ } catch {
194
+ // best-effort return
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Resolve merge conflicts using team-standard patterns.
200
+ * Returns true if all conflicts were resolved (or if CHANGELOG conflicts need user decision),
201
+ * false if there are unresolvable conflicts.
202
+ *
203
+ * @param {string} previousBranch - Branch to return to on abort
204
+ * @returns {Promise<boolean>} true = proceed with commit, false = aborted
205
+ */
206
+ async function _resolveConflicts(previousBranch) {
207
+ let conflictedFiles = [];
208
+ try {
209
+ const raw = execSync('git diff --name-only --diff-filter=U', {
210
+ encoding: 'utf8',
211
+ stdio: ['pipe', 'pipe', 'pipe']
212
+ }).trim();
213
+ conflictedFiles = raw ? raw.split(/\r?\n/).filter(Boolean) : [];
214
+ } catch (e) {
215
+ showWarning(`Could not list conflicted files: ${e.message}`);
216
+ _abortMergeAndReturn(previousBranch);
217
+ return false;
218
+ }
219
+
220
+ if (conflictedFiles.length === 0) {
221
+ showError('Merge failed — could not determine cause. Check git status.');
222
+ showWarning('You may need to run: git merge --abort');
223
+ try {
224
+ checkoutBranch(previousBranch);
225
+ } catch {
226
+ // best-effort
227
+ }
228
+ return false;
229
+ }
230
+
231
+ logger.debug('back-merge - _resolveConflicts', 'Conflicts detected', { conflictedFiles });
232
+
233
+ const versionConflicts = conflictedFiles.filter(_isVersionFile);
234
+ const changelogConflicts = conflictedFiles.filter(_isChangelogFile);
235
+ const otherConflicts = conflictedFiles.filter(
236
+ (f) => !_isVersionFile(f) && !_isChangelogFile(f)
237
+ );
238
+
239
+ // Other conflicts — cannot auto-resolve: abort and instruct TL
240
+ if (otherConflicts.length > 0) {
241
+ showWarning('⚠️ Merge conflict on non-auto-resolvable files — aborting merge.');
242
+ showWarning('Files requiring manual resolution:');
243
+ otherConflicts.forEach((f) => showWarning(` ${f}`));
244
+ console.log('');
245
+ console.log('To resolve manually:');
246
+ console.log(` 1. git checkout ${DEFAULT_INTO} && git merge --no-verify --no-ff origin/${DEFAULT_FROM}`);
247
+ console.log(' 2. Resolve conflicts in the listed files');
248
+ console.log(' 3. git add . && git commit --no-verify');
249
+ _abortMergeAndReturn(previousBranch);
250
+ return false;
251
+ }
252
+
253
+ // Auto-resolve version file conflicts: accept source (theirs = from-branch = main)
254
+ for (const f of versionConflicts) {
255
+ try {
256
+ execSync(`git checkout --theirs -- "${f}"`, {
257
+ encoding: 'utf8',
258
+ stdio: ['pipe', 'pipe', 'pipe']
259
+ });
260
+ execSync(`git add -- "${f}"`, {
261
+ encoding: 'utf8',
262
+ stdio: ['pipe', 'pipe', 'pipe']
263
+ });
264
+ logger.debug('back-merge - _resolveConflicts', 'Resolved version conflict (theirs)', {
265
+ file: f
266
+ });
267
+ } catch (e) {
268
+ showWarning(`Could not auto-resolve version conflict in '${f}': ${e.message}`);
269
+ _abortMergeAndReturn(previousBranch);
270
+ return false;
271
+ }
272
+ }
273
+
274
+ if (versionConflicts.length > 0) {
275
+ showInfo(
276
+ `Auto-resolved ${versionConflicts.length} version file conflict(s) — accepted source (main) version.`
277
+ );
278
+ }
279
+
280
+ // CHANGELOG conflicts: stage as-is (conflict markers remain), warn TL, ask to continue
281
+ for (const f of changelogConflicts) {
282
+ try {
283
+ execSync(`git add -- "${f}"`, {
284
+ encoding: 'utf8',
285
+ stdio: ['pipe', 'pipe', 'pipe']
286
+ });
287
+ showWarning(
288
+ `⚠️ ${f} has conflict markers — both sides staged. ` +
289
+ 'Resolve manually after back-merge (destination=Unreleased on top, source=release below).'
290
+ );
291
+ logger.debug('back-merge - _resolveConflicts', 'Staged CHANGELOG with conflict markers', {
292
+ file: f
293
+ });
294
+ } catch (e) {
295
+ showWarning(`Could not stage '${f}' for commit: ${e.message}`);
296
+ _abortMergeAndReturn(previousBranch);
297
+ return false;
298
+ }
299
+ }
300
+
301
+ // Ask user permission to continue with CHANGELOG conflict markers
302
+ if (changelogConflicts.length > 0) {
303
+ console.log('');
304
+ const proceed = await promptConfirmation(
305
+ 'CHANGELOG has conflict markers. Continue with merge commit (you will fix markers after)?',
306
+ false
307
+ );
308
+ if (!proceed) {
309
+ showInfo('Aborting merge — resolve CHANGELOG conflicts manually.');
310
+ _abortMergeAndReturn(previousBranch);
311
+ return false;
312
+ }
313
+ }
314
+
315
+ return true;
316
+ }
317
+
318
+ // ─── Revert log ──────────────────────────────────────────────────────────────
319
+
320
+ /**
321
+ * Read .claude/revert-log.json
322
+ * @param {string} repoRoot
323
+ * @returns {Array}
324
+ */
325
+ function _readRevertLog(repoRoot) {
326
+ const logPath = path.join(repoRoot, REVERT_LOG_RELATIVE_PATH);
327
+ if (!fs.existsSync(logPath)) return [];
328
+ try {
329
+ return JSON.parse(fs.readFileSync(logPath, 'utf8'));
330
+ } catch {
331
+ return [];
332
+ }
333
+ }
334
+
335
+ /**
336
+ * Write updated revert log (removes processed entries)
337
+ * @param {string} repoRoot
338
+ * @param {Array} entries
339
+ */
340
+ function _writeRevertLog(repoRoot, entries) {
341
+ const logPath = path.join(repoRoot, REVERT_LOG_RELATIVE_PATH);
342
+ fs.writeFileSync(logPath, JSON.stringify(entries, null, 2), 'utf8');
343
+ }
344
+
345
+ /**
346
+ * Offer to revert-the-revert for features that were reverted from the closed RC.
347
+ * Applies `git revert --no-edit <revertHash>` for each confirmed entry.
348
+ *
349
+ * @param {string} rcBranch - The RC branch being closed (for filtering)
350
+ * @param {string} repoRoot
351
+ * @returns {Promise<void>}
352
+ */
353
+ async function _revertFollowup(rcBranch, repoRoot) {
354
+ const allEntries = _readRevertLog(repoRoot);
355
+ const rcEntries = allEntries.filter((e) => e.rcBranch === rcBranch);
356
+
357
+ if (rcEntries.length === 0) {
358
+ logger.debug('back-merge - _revertFollowup', 'No revert-log entries for this RC', {
359
+ rcBranch
360
+ });
361
+ return;
362
+ }
363
+
364
+ console.log('');
365
+ console.log('┌─────────────────────────────────────────────────────────────────────────┐');
366
+ console.log(` 🔄 Reverted features from ${rcBranch}`);
367
+ console.log('├─────────────────────────────────────────────────────────────────────────┤');
368
+ rcEntries.forEach((e) => {
369
+ const date = e.timestamp ? e.timestamp.substring(0, 10) : '?';
370
+ console.log(` • ${e.taskId.padEnd(12)} reverted ${e.originalHash.substring(0, 7)} on ${date}`);
371
+ console.log(` Revert commit: ${e.revertHash.substring(0, 7)}`);
372
+ });
373
+ console.log('└─────────────────────────────────────────────────────────────────────────┘');
374
+ console.log('');
375
+
376
+ const apply = await promptConfirmation(
377
+ `Apply revert-the-revert for ${rcEntries.length} feature(s)? (restores them for next sprint)`,
378
+ false
379
+ );
380
+
381
+ if (!apply) {
382
+ console.log('');
383
+ showInfo('Skipped. To restore manually:');
384
+ rcEntries.forEach((e) => {
385
+ console.log(` git revert --no-edit ${e.revertHash.substring(0, 7)} # Restores ${e.taskId}`);
386
+ });
387
+ console.log('');
388
+ return;
389
+ }
390
+
391
+ console.log('');
392
+ let applied = 0;
393
+ for (const entry of rcEntries) {
394
+ try {
395
+ execSync(`git revert --no-edit ${entry.revertHash}`, {
396
+ encoding: 'utf8',
397
+ stdio: 'inherit'
398
+ });
399
+ showSuccess(`✓ Restored ${entry.taskId} (reverted ${entry.revertHash.substring(0, 7)})`);
400
+ applied++;
401
+ } catch (e) {
402
+ showWarning(
403
+ `⚠️ Could not revert-the-revert for ${entry.taskId}: ${e.message}`
404
+ );
405
+ showWarning(` Resolve manually: git revert --no-edit ${entry.revertHash.substring(0, 7)}`);
406
+ }
407
+ }
408
+
409
+ // Remove processed entries from revert-log
410
+ const remaining = allEntries.filter((e) => e.rcBranch !== rcBranch);
411
+ try {
412
+ _writeRevertLog(repoRoot, remaining);
413
+ logger.debug('back-merge - _revertFollowup', 'Cleaned up revert-log entries', {
414
+ removed: rcEntries.length,
415
+ remaining: remaining.length
416
+ });
417
+ } catch (e) {
418
+ showWarning(`Could not update revert-log: ${e.message}`);
419
+ }
420
+
421
+ if (applied > 0) {
422
+ showSuccess(`✓ Applied ${applied}/${rcEntries.length} revert-the-revert(s)`);
423
+ }
424
+ }
425
+
426
+ // ─── Main command ─────────────────────────────────────────────────────────────
427
+
428
+ /**
429
+ * Main entry point for `claude-hooks back-merge`
430
+ * @param {string[]} args - CLI args after 'back-merge'
431
+ */
432
+ export async function runBackMerge(args) {
433
+ logger.debug('back-merge', 'Starting back-merge command', { args });
434
+
435
+ const opts = _parseArgs(args);
436
+
437
+ showInfo(`🔀 Post-deploy back-merge: ${opts.from} → ${opts.into}`);
438
+ console.log('');
439
+
440
+ // 1. Validate: git repo
441
+ if (!checkGitRepo()) {
442
+ error('Not a git repository.');
443
+ process.exit(1);
444
+ }
445
+
446
+ // 2. Validate: clean tree
447
+ if (!isWorkingDirectoryClean()) {
448
+ error(
449
+ 'Working directory has uncommitted changes.\n' +
450
+ ' Please commit or stash your changes before running back-merge.'
451
+ );
452
+ process.exit(1);
453
+ }
454
+
455
+ // 3. Warn if not on the into-branch
456
+ const currentBranch = getCurrentBranch();
457
+ if (currentBranch !== opts.into) {
458
+ showWarning(`You are on '${currentBranch}', not '${opts.into}'.`);
459
+ const continueAnyway = await promptConfirmation(
460
+ `Continue? (command will checkout '${opts.into}' automatically)`,
461
+ false
462
+ );
463
+ if (!continueAnyway) {
464
+ showInfo('Aborted.');
465
+ process.exit(0);
466
+ }
467
+ console.log('');
468
+ }
469
+
470
+ // 4. Fetch remote (warn on failure, don't abort)
471
+ try {
472
+ fetchRemote();
473
+ showSuccess('✓ Fetched from remote');
474
+ } catch (e) {
475
+ showWarning(`Could not fetch from remote: ${e.message} — results may be stale`);
476
+ }
477
+ console.log('');
478
+
479
+ // 5. Detect version from latest local tag
480
+ const { version, tagName } = _detectVersion();
481
+ if (!version) {
482
+ showWarning('Could not detect release version from local tags — tag step will be skipped.');
483
+ } else {
484
+ showInfo(`Detected release version: ${version} (${tagName})`);
485
+ }
486
+ const effectiveSkipTag = opts.skipTag || !version;
487
+
488
+ // 6. Check if merge is needed: from behind into?
489
+ let divergence;
490
+ try {
491
+ divergence = getDivergence(opts.into, `origin/${opts.from}`);
492
+ } catch (e) {
493
+ showWarning(`Could not check divergence: ${e.message}`);
494
+ divergence = { ahead: 0, behind: 1 }; // assume merge needed
495
+ }
496
+
497
+ if (divergence.behind === 0) {
498
+ showSuccess(`'${opts.into}' is already up-to-date with 'origin/${opts.from}' — nothing to merge.`);
499
+ console.log('');
500
+ process.exit(0);
501
+ }
502
+
503
+ showInfo(`'${opts.into}' is ${divergence.behind} commit(s) behind 'origin/${opts.from}'`);
504
+ console.log('');
505
+
506
+ // 7. Find RC branches for cleanup
507
+ const rcBranches = _findRCBranches(version);
508
+ logger.debug('back-merge', 'RC branches found for cleanup', { rcBranches });
509
+
510
+ // 8. Dry-run: preview actions and return
511
+ if (opts.dryRun) {
512
+ console.log(`${colors.yellow}═════════════════════════════════════════════════${colors.reset}`);
513
+ console.log(`${colors.yellow} DRY RUN — No changes will be made ${colors.reset}`);
514
+ console.log(`${colors.yellow}═════════════════════════════════════════════════${colors.reset}`);
515
+ console.log('');
516
+ console.log(`${colors.blue}Source:${colors.reset} origin/${opts.from}`);
517
+ console.log(`${colors.blue}Destination:${colors.reset} ${opts.into} (${divergence.behind} commit(s) behind)`);
518
+
519
+ if (effectiveSkipTag) {
520
+ console.log(`${colors.blue}Tag:${colors.reset} skipped${!version ? ' (no local tag found)' : ' (--skip-tag)'}`);
521
+ } else {
522
+ console.log(`${colors.blue}Tag:${colors.reset} push ${tagName} to remote`);
523
+ }
524
+
525
+ console.log(
526
+ `${colors.blue}Shadow reset:${colors.reset} ${opts.skipShadow ? 'skipped (--skip-shadow)' : 'yes → reset shadow to main'}`
527
+ );
528
+ console.log(`${colors.blue}Merge commit:${colors.reset} [backmerge] Merge ${opts.from}${version ? ` (v${version})` : ''} into ${opts.into}`);
529
+
530
+ if (rcBranches.length > 0) {
531
+ console.log(`${colors.blue}RC cleanup:${colors.reset} delete ${rcBranches.join(', ')}`);
532
+ } else {
533
+ console.log(`${colors.blue}RC cleanup:${colors.reset} no RC branch found to delete`);
534
+ }
535
+ console.log('');
536
+ console.log(`${colors.yellow}Run without --dry-run to apply these changes${colors.reset}`);
537
+ console.log('');
538
+ return;
539
+ }
540
+
541
+ // 9. Confirm with user
542
+ console.log('Planned actions:');
543
+ if (!effectiveSkipTag) console.log(` 1. Push release tag ${tagName} to remote`);
544
+ if (!opts.skipShadow) console.log(` ${effectiveSkipTag ? 1 : 2}. Reset shadow branch to main`);
545
+ const mergeStep = (effectiveSkipTag ? 0 : 1) + (opts.skipShadow ? 0 : 1) + 1;
546
+ console.log(` ${mergeStep}. Merge origin/${opts.from} into ${opts.into}`);
547
+ console.log(` ${mergeStep + 1}. Push ${opts.into} to remote`);
548
+ if (rcBranches.length > 0) {
549
+ console.log(` ${mergeStep + 2}. Delete RC branch(es): ${rcBranches.join(', ')}`);
550
+ }
551
+ console.log('');
552
+
553
+ const confirmed = await promptConfirmation('Proceed with back-merge?', true);
554
+ if (!confirmed) {
555
+ showInfo('Back-merge cancelled.');
556
+ process.exit(0);
557
+ }
558
+ console.log('');
559
+
560
+ // 10. Tag release (push existing local tag or create + push)
561
+ let tagStatus = 'skipped';
562
+ if (!effectiveSkipTag) {
563
+ showInfo(`Tagging release ${tagName}...`);
564
+ try {
565
+ const alreadyOnRemote = await tagExists(tagName, 'remote');
566
+ if (alreadyOnRemote) {
567
+ showWarning(`Tag ${tagName} already exists on remote — skipping push.`);
568
+ tagStatus = `${tagName} already on remote`;
569
+ } else {
570
+ const alreadyLocal = await tagExists(tagName, 'local');
571
+ if (!alreadyLocal) {
572
+ // Tag was not created by create-release — create it now
573
+ const createResult = createTag(version, `Release version ${version}`);
574
+ if (!createResult.success) {
575
+ showWarning(`Could not create tag locally: ${createResult.error}`);
576
+ }
577
+ }
578
+ const pushResult = pushTags(null, tagName);
579
+ if (pushResult.success) {
580
+ showSuccess(`✓ Tag ${tagName} pushed to remote`);
581
+ tagStatus = `${tagName} pushed`;
582
+ } else {
583
+ showWarning(`Could not push tag: ${pushResult.error}`);
584
+ showWarning(` Push manually: git push origin ${tagName}`);
585
+ tagStatus = 'push failed';
586
+ }
587
+ }
588
+ } catch (e) {
589
+ showWarning(`Tag step failed: ${e.message}`);
590
+ tagStatus = 'failed';
591
+ }
592
+ console.log('');
593
+ }
594
+
595
+ // 11. Shadow reset (unless --skip-shadow)
596
+ let shadowStatus = 'skipped';
597
+ if (!opts.skipShadow) {
598
+ showInfo('Resetting shadow to main...');
599
+ console.log('');
600
+ try {
601
+ await runShadow(['reset']);
602
+ shadowStatus = 'reset to main';
603
+ } catch (e) {
604
+ showWarning(`Shadow reset failed: ${e.message}`);
605
+ showWarning(' Reset manually: claude-hooks shadow reset');
606
+ shadowStatus = 'failed';
607
+ }
608
+ console.log('');
609
+ }
610
+
611
+ // 12. Execute merge
612
+ showInfo(`Merging origin/${opts.from} into ${opts.into}...`);
613
+ const previousBranch = getCurrentBranch();
614
+ try {
615
+ checkoutBranch(opts.into);
616
+ } catch (e) {
617
+ showError(`Could not checkout '${opts.into}': ${e.message}`);
618
+ process.exit(1);
619
+ }
620
+
621
+ let mergeSucceeded = true;
622
+ try {
623
+ execSync(`git merge --no-commit --no-ff --no-verify origin/${opts.from}`, {
624
+ encoding: 'utf8',
625
+ stdio: ['pipe', 'pipe', 'pipe']
626
+ });
627
+ } catch {
628
+ mergeSucceeded = false;
629
+ }
630
+
631
+ // 13. Handle conflicts if merge had issues
632
+ if (!mergeSucceeded) {
633
+ const resolved = await _resolveConflicts(previousBranch);
634
+ if (!resolved) {
635
+ showError('Back-merge aborted due to unresolvable conflicts.');
636
+ process.exit(1);
637
+ }
638
+ }
639
+
640
+ // 14. Commit the merge
641
+ const versionSuffix = version ? ` (v${version})` : '';
642
+ const commitMsg = `[backmerge] Merge ${opts.from}${versionSuffix} into ${opts.into}`;
643
+ const commitResult = createCommit(commitMsg, { noVerify: true });
644
+ if (!commitResult.success) {
645
+ showError(`Failed to create merge commit: ${commitResult.error}`);
646
+ showWarning('The merge is staged — commit manually:');
647
+ showWarning(` git commit --no-verify -m "${commitMsg}"`);
648
+ process.exit(1);
649
+ }
650
+ showSuccess(`✓ Merge committed: ${commitMsg}`);
651
+ console.log('');
652
+
653
+ // 15. Verify sync
654
+ try {
655
+ const postMergeDivergence = getDivergence(opts.into, `origin/${opts.from}`);
656
+ if (postMergeDivergence.behind === 0) {
657
+ showSuccess(`✓ '${opts.into}' is now in sync with 'origin/${opts.from}'`);
658
+ } else {
659
+ showWarning(
660
+ `'${opts.into}' still ${postMergeDivergence.behind} commit(s) behind 'origin/${opts.from}' — verify manually.`
661
+ );
662
+ }
663
+ } catch (e) {
664
+ showWarning(`Could not verify sync: ${e.message}`);
665
+ }
666
+ console.log('');
667
+
668
+ // 16. Push into-branch
669
+ showInfo(`Pushing ${opts.into} to remote...`);
670
+ const pushResult = pushBranch(opts.into);
671
+ let pushStatus;
672
+ if (pushResult.success) {
673
+ showSuccess(`✓ Pushed ${opts.into} to remote`);
674
+ pushStatus = 'pushed';
675
+ } else {
676
+ const isProtected =
677
+ pushResult.error &&
678
+ (pushResult.error.includes('protected') ||
679
+ pushResult.error.includes('hook declined') ||
680
+ pushResult.error.includes('cannot push'));
681
+ if (isProtected) {
682
+ showError(`Push blocked — '${opts.into}' is protected.`);
683
+ console.log('');
684
+ console.log(
685
+ `'${opts.into}' is a protected branch — ask your Tech Lead to temporarily allow pushes, or raise a PR:`
686
+ );
687
+ console.log(` gh pr create --base ${opts.into} --head ${opts.from} --title "${commitMsg}"`);
688
+ } else {
689
+ showError(`Push failed: ${pushResult.error}`);
690
+ console.log('');
691
+ console.log('Push manually when ready:');
692
+ console.log(` git push origin ${opts.into}`);
693
+ }
694
+ pushStatus = isProtected ? 'blocked (branch protection)' : 'failed';
695
+ }
696
+ console.log('');
697
+
698
+ // 17. Delete RC branch(es)
699
+ const deletedRC = [];
700
+ const failedRC = [];
701
+ for (const rc of rcBranches) {
702
+ showInfo(`Deleting remote branch ${rc}...`);
703
+ try {
704
+ deleteRemoteBranch(rc);
705
+ showSuccess(`✓ Deleted ${rc}`);
706
+ deletedRC.push(rc);
707
+ } catch (e) {
708
+ showWarning(`Could not delete ${rc}: ${e.message}`);
709
+ showWarning(` Delete manually: git push origin --delete ${rc}`);
710
+ failedRC.push(rc);
711
+ }
712
+ }
713
+ if (rcBranches.length > 0) console.log('');
714
+
715
+ // 18. Revert follow-up
716
+ const repoRoot = getRepoRoot();
717
+ const rcBranchForLog = rcBranches.length > 0 ? rcBranches[0] : null;
718
+ if (rcBranchForLog) {
719
+ await _revertFollowup(rcBranchForLog, repoRoot);
720
+ }
721
+
722
+ // 19. Summary
723
+ console.log('');
724
+ console.log(`${colors.green}═════════════════════════════════════════════════${colors.reset}`);
725
+ console.log(`${colors.green} Back-merge Complete ✅ ${colors.reset}`);
726
+ console.log(`${colors.green}═════════════════════════════════════════════════${colors.reset}`);
727
+ console.log('');
728
+ console.log(`${colors.blue}Merged:${colors.reset} origin/${opts.from} → ${opts.into}`);
729
+ console.log(`${colors.blue}Tag:${colors.reset} ${tagStatus}`);
730
+ console.log(`${colors.blue}Shadow:${colors.reset} ${shadowStatus}`);
731
+ console.log(`${colors.blue}Push:${colors.reset} ${pushStatus}`);
732
+ if (rcBranches.length > 0) {
733
+ const cleanupStatus =
734
+ failedRC.length === 0
735
+ ? `deleted (${deletedRC.join(', ')})`
736
+ : `partial — ${failedRC.join(', ')} needs manual delete`;
737
+ console.log(`${colors.blue}RC:${colors.reset} ${cleanupStatus}`);
738
+ }
739
+ console.log('');
740
+ }