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.
- package/CHANGELOG.md +138 -6
- package/CLAUDE.md +469 -69
- package/README.md +5 -0
- package/bin/claude-hooks +89 -0
- package/lib/cli-metadata.js +68 -1
- package/lib/commands/analyze-pr.js +19 -24
- package/lib/commands/back-merge.js +740 -0
- package/lib/commands/check-coupling.js +209 -0
- package/lib/commands/close-release.js +485 -0
- package/lib/commands/create-pr.js +62 -3
- package/lib/commands/create-release.js +600 -0
- package/lib/commands/diff-batch-info.js +7 -13
- package/lib/commands/help.js +5 -7
- package/lib/commands/install.js +1 -5
- package/lib/commands/revert-feature.js +436 -0
- package/lib/commands/shadow.js +654 -0
- package/lib/config.js +1 -2
- package/lib/hooks/pre-commit.js +8 -6
- package/lib/utils/authorization.js +429 -0
- package/lib/utils/claude-client.js +14 -7
- package/lib/utils/coupling-detector.js +133 -0
- package/lib/utils/diff-analysis-orchestrator.js +7 -14
- package/lib/utils/git-operations.js +480 -1
- package/lib/utils/github-api.js +182 -0
- package/lib/utils/judge.js +66 -7
- package/lib/utils/linear-connector.js +1 -4
- package/lib/utils/package-info.js +0 -1
- package/lib/utils/token-store.js +5 -3
- package/package.json +69 -69
|
@@ -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
|
+
}
|