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.
- package/CHANGELOG.md +148 -6
- package/CLAUDE.md +469 -69
- package/README.md +5 -0
- package/bin/claude-hooks +101 -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
|
@@ -19,19 +19,15 @@ import logger from '../utils/logger.js';
|
|
|
19
19
|
* understand the adaptive batch system behavior without needing debug mode.
|
|
20
20
|
*/
|
|
21
21
|
export async function runDiffBatchInfo() {
|
|
22
|
-
console.log(
|
|
23
|
-
|
|
24
|
-
);
|
|
25
|
-
console.log(
|
|
26
|
-
'║ INTELLIGENT ANALYSIS ORCHESTRATION INFO ║'
|
|
27
|
-
);
|
|
28
|
-
console.log(
|
|
29
|
-
'╚════════════════════════════════════════════════════════════════════╝\n'
|
|
30
|
-
);
|
|
22
|
+
console.log('\n╔════════════════════════════════════════════════════════════════════╗');
|
|
23
|
+
console.log('║ INTELLIGENT ANALYSIS ORCHESTRATION INFO ║');
|
|
24
|
+
console.log('╚════════════════════════════════════════════════════════════════════╝\n');
|
|
31
25
|
|
|
32
26
|
console.log('━━━ ORCHESTRATION CONFIGURATION ━━━');
|
|
33
27
|
console.log(' Orchestration model: opus (internal, not user-configurable)');
|
|
34
|
-
console.log(
|
|
28
|
+
console.log(
|
|
29
|
+
' Orchestrator threshold: 3 files (commits with ≥3 files use intelligent grouping)'
|
|
30
|
+
);
|
|
35
31
|
console.log();
|
|
36
32
|
|
|
37
33
|
console.log('━━━ HOW IT WORKS ━━━');
|
|
@@ -80,9 +76,7 @@ export async function runDiffBatchInfo() {
|
|
|
80
76
|
if (stats.avgOrchestrationTime > 0) {
|
|
81
77
|
const orchSeconds = (stats.avgOrchestrationTime / 1000).toFixed(1);
|
|
82
78
|
console.log(` Avg orchestration overhead: ${orchSeconds}s`);
|
|
83
|
-
console.log(
|
|
84
|
-
' (Orchestrator call for semantic grouping — one-time per commit)'
|
|
85
|
-
);
|
|
79
|
+
console.log(' (Orchestrator call for semantic grouping — one-time per commit)');
|
|
86
80
|
console.log();
|
|
87
81
|
}
|
|
88
82
|
|
package/lib/commands/help.js
CHANGED
|
@@ -105,9 +105,7 @@ function formatCommandLines(cmds) {
|
|
|
105
105
|
name = `${cmd.name}, ${cmd.aliases.join(', ')}`;
|
|
106
106
|
}
|
|
107
107
|
if (cmd.args) {
|
|
108
|
-
const argLabel = cmd.args.values
|
|
109
|
-
? cmd.args.values.join(' | ')
|
|
110
|
-
: cmd.args.name;
|
|
108
|
+
const argLabel = cmd.args.values ? cmd.args.values.join(' | ') : cmd.args.name;
|
|
111
109
|
name += ` [${argLabel}]`;
|
|
112
110
|
}
|
|
113
111
|
if (cmd.subcommands) {
|
|
@@ -220,7 +218,7 @@ async function runAiHelp(question) {
|
|
|
220
218
|
const pkg = getPackageJson();
|
|
221
219
|
const localVersion = `v${pkg.version}`;
|
|
222
220
|
|
|
223
|
-
const response = await executeClaudeWithRetry(prompt, { timeout:
|
|
221
|
+
const response = await executeClaudeWithRetry(prompt, { timeout: 60000 });
|
|
224
222
|
const trimmedResponse = response.trim();
|
|
225
223
|
|
|
226
224
|
// Check for NEED_MORE_CONTEXT second pass
|
|
@@ -308,7 +306,7 @@ async function handleNeedMoreContext(needMoreLine, question, claudeMdContent) {
|
|
|
308
306
|
QUESTION: question
|
|
309
307
|
});
|
|
310
308
|
|
|
311
|
-
const enrichedResponse = await executeClaudeWithRetry(enrichedPrompt, { timeout:
|
|
309
|
+
const enrichedResponse = await executeClaudeWithRetry(enrichedPrompt, { timeout: 60000 });
|
|
312
310
|
return enrichedResponse.trim();
|
|
313
311
|
} catch (error) {
|
|
314
312
|
logger.debug('help - handleNeedMoreContext', 'Enrichment failed', { error: error.message });
|
|
@@ -384,7 +382,7 @@ async function runReportIssue() {
|
|
|
384
382
|
});
|
|
385
383
|
|
|
386
384
|
console.log('\nAnalyzing template...\n');
|
|
387
|
-
const questionsResponse = await executeClaudeWithRetry(questionsPrompt, { timeout:
|
|
385
|
+
const questionsResponse = await executeClaudeWithRetry(questionsPrompt, { timeout: 60000 });
|
|
388
386
|
|
|
389
387
|
// Parse questions JSON from response
|
|
390
388
|
let questions;
|
|
@@ -422,7 +420,7 @@ async function runReportIssue() {
|
|
|
422
420
|
});
|
|
423
421
|
|
|
424
422
|
console.log('\nComposing issue...\n');
|
|
425
|
-
const composeResponse = await executeClaudeWithRetry(composePrompt, { timeout:
|
|
423
|
+
const composeResponse = await executeClaudeWithRetry(composePrompt, { timeout: 60000 });
|
|
426
424
|
|
|
427
425
|
let issueData;
|
|
428
426
|
try {
|
package/lib/commands/install.js
CHANGED
|
@@ -1052,11 +1052,7 @@ export function installCompletions() {
|
|
|
1052
1052
|
appendLineIfMissing(bashrc, '# claude-hooks completions', bashSourceLine);
|
|
1053
1053
|
const bashProfile = path.join(home, '.bash_profile');
|
|
1054
1054
|
if (fs.existsSync(bashProfile)) {
|
|
1055
|
-
appendLineIfMissing(
|
|
1056
|
-
bashProfile,
|
|
1057
|
-
'# claude-hooks completions',
|
|
1058
|
-
bashSourceLine
|
|
1059
|
-
);
|
|
1055
|
+
appendLineIfMissing(bashProfile, '# claude-hooks completions', bashSourceLine);
|
|
1060
1056
|
}
|
|
1061
1057
|
installed++;
|
|
1062
1058
|
} catch (e) {
|
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File: revert-feature.js
|
|
3
|
+
* Purpose: Revert a squash-merged feature by task ID in a release-candidate branch
|
|
4
|
+
*
|
|
5
|
+
* Flow:
|
|
6
|
+
* 1. Validate: on release-candidate/V* branch, working directory clean
|
|
7
|
+
* 2. Search commits by task ID using git log --grep (fixed-string, case-insensitive)
|
|
8
|
+
* 3. Handle 0/1/2+ matches
|
|
9
|
+
* 4. Get target commit files and check coupling with other RC commits
|
|
10
|
+
* 5. Show details + coupling warnings → single confirmation
|
|
11
|
+
* 6. git revert --no-edit <hash>
|
|
12
|
+
* 7. Push to RC branch
|
|
13
|
+
* 8. Write to .claude/revert-log.json (append, array)
|
|
14
|
+
* 9. Optionally sync shadow (--update-shadow)
|
|
15
|
+
* 10. Display summary + revert-the-revert reminder
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { execSync } from 'child_process';
|
|
19
|
+
import fs from 'fs';
|
|
20
|
+
import path from 'path';
|
|
21
|
+
import {
|
|
22
|
+
getCurrentBranch,
|
|
23
|
+
isWorkingDirectoryClean,
|
|
24
|
+
pushBranch,
|
|
25
|
+
getRepoRoot,
|
|
26
|
+
getCommitFiles
|
|
27
|
+
} from '../utils/git-operations.js';
|
|
28
|
+
import { promptConfirmation, promptMenu } from '../utils/interactive-ui.js';
|
|
29
|
+
import logger from '../utils/logger.js';
|
|
30
|
+
|
|
31
|
+
const REVERT_LOG_RELATIVE_PATH = '.claude/revert-log.json';
|
|
32
|
+
|
|
33
|
+
/** Matches Jira/Linear-style task IDs: AUT-3179, ISSUE-95, etc. */
|
|
34
|
+
const TASK_ID_REGEX = /[A-Z]+-\d+/;
|
|
35
|
+
|
|
36
|
+
// ─── Argument parsing ────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Parse CLI args into structured options
|
|
40
|
+
* @param {string[]} args
|
|
41
|
+
* @returns {{ taskId: string|null, updateShadow: boolean, dryRun: boolean }}
|
|
42
|
+
*/
|
|
43
|
+
function _parseArgs(args) {
|
|
44
|
+
const positional = args.filter((a) => !a.startsWith('--'));
|
|
45
|
+
return {
|
|
46
|
+
taskId: positional[0] || null,
|
|
47
|
+
updateShadow: args.includes('--update-shadow'),
|
|
48
|
+
dryRun: args.includes('--dry-run')
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ─── Git log helpers ─────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Search for commits matching a task ID in origin/main..HEAD
|
|
56
|
+
*
|
|
57
|
+
* @param {string} taskId - Task ID to search for (e.g., 'AUT-3179')
|
|
58
|
+
* @returns {Array<{ hash: string, message: string, author: string, date: string }>}
|
|
59
|
+
*/
|
|
60
|
+
function _searchCommits(taskId) {
|
|
61
|
+
logger.debug('revert-feature - _searchCommits', 'Searching for commits', { taskId });
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
// Use | separator; join message parts back together to handle rare | in subjects
|
|
65
|
+
const output = execSync(
|
|
66
|
+
`git log --format="%H|%s|%an|%ai" --fixed-strings -i --grep="${taskId}" origin/main..HEAD`,
|
|
67
|
+
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
68
|
+
).trim();
|
|
69
|
+
|
|
70
|
+
if (!output) return [];
|
|
71
|
+
|
|
72
|
+
return output
|
|
73
|
+
.split('\n')
|
|
74
|
+
.filter((line) => line.trim())
|
|
75
|
+
.map((line) => {
|
|
76
|
+
const parts = line.split('|');
|
|
77
|
+
const hash = parts[0];
|
|
78
|
+
const date = parts[parts.length - 1];
|
|
79
|
+
const author = parts[parts.length - 2];
|
|
80
|
+
const message = parts.slice(1, parts.length - 2).join('|');
|
|
81
|
+
return {
|
|
82
|
+
hash: hash.trim(),
|
|
83
|
+
message: message.trim(),
|
|
84
|
+
author: author.trim(),
|
|
85
|
+
date: date.trim()
|
|
86
|
+
};
|
|
87
|
+
});
|
|
88
|
+
} catch (err) {
|
|
89
|
+
logger.debug('revert-feature - _searchCommits', 'git log failed', { error: err.message });
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Get all non-merge commits in the RC (origin/main..HEAD) as hash+message pairs
|
|
96
|
+
* @returns {Array<{ hash: string, message: string }>}
|
|
97
|
+
*/
|
|
98
|
+
function _getRCCommits() {
|
|
99
|
+
try {
|
|
100
|
+
const output = execSync('git log --format="%H|%s" origin/main..HEAD', {
|
|
101
|
+
encoding: 'utf8',
|
|
102
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
103
|
+
}).trim();
|
|
104
|
+
|
|
105
|
+
if (!output) return [];
|
|
106
|
+
|
|
107
|
+
return output
|
|
108
|
+
.split('\n')
|
|
109
|
+
.filter((line) => line.trim())
|
|
110
|
+
.map((line) => {
|
|
111
|
+
const idx = line.indexOf('|');
|
|
112
|
+
return {
|
|
113
|
+
hash: line.substring(0, idx).trim(),
|
|
114
|
+
message: line.substring(idx + 1).trim()
|
|
115
|
+
};
|
|
116
|
+
});
|
|
117
|
+
} catch (err) {
|
|
118
|
+
logger.debug('revert-feature - _getRCCommits', 'git log failed', { error: err.message });
|
|
119
|
+
return [];
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Get HEAD commit hash
|
|
125
|
+
* @returns {string}
|
|
126
|
+
*/
|
|
127
|
+
function _getHeadHash() {
|
|
128
|
+
return execSync('git rev-parse HEAD', {
|
|
129
|
+
encoding: 'utf8',
|
|
130
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
131
|
+
}).trim();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ─── Coupling detection ──────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Extract a task ID from a commit message (first match wins)
|
|
138
|
+
* @param {string} message
|
|
139
|
+
* @returns {string|null}
|
|
140
|
+
*/
|
|
141
|
+
function _extractTaskId(message) {
|
|
142
|
+
const match = message.match(TASK_ID_REGEX);
|
|
143
|
+
return match ? match[0] : null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Find other RC commits that share files with the target commit
|
|
148
|
+
*
|
|
149
|
+
* @param {string} targetHash - Full hash of the commit being reverted
|
|
150
|
+
* @param {string[]} targetFiles - Files changed by the target commit
|
|
151
|
+
* @returns {Array<{ hash: string, taskId: string|null, message: string, sharedFiles: string[] }>}
|
|
152
|
+
*/
|
|
153
|
+
function _checkCoupling(targetHash, targetFiles) {
|
|
154
|
+
logger.debug('revert-feature - _checkCoupling', 'Checking coupling', {
|
|
155
|
+
targetHash,
|
|
156
|
+
targetFileCount: targetFiles.length
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
if (targetFiles.length === 0) return [];
|
|
160
|
+
|
|
161
|
+
const targetFileSet = new Set(targetFiles);
|
|
162
|
+
const rcCommits = _getRCCommits();
|
|
163
|
+
const coupled = [];
|
|
164
|
+
|
|
165
|
+
for (const commit of rcCommits) {
|
|
166
|
+
if (commit.hash === targetHash) continue;
|
|
167
|
+
// Skip revert commits — they intentionally touch the same files
|
|
168
|
+
if (commit.message.startsWith('Revert ')) continue;
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
const files = getCommitFiles(commit.hash);
|
|
172
|
+
const shared = files.filter((f) => targetFileSet.has(f));
|
|
173
|
+
if (shared.length > 0) {
|
|
174
|
+
coupled.push({
|
|
175
|
+
hash: commit.hash.substring(0, 7),
|
|
176
|
+
taskId: _extractTaskId(commit.message),
|
|
177
|
+
message: commit.message,
|
|
178
|
+
sharedFiles: shared
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
} catch {
|
|
182
|
+
logger.debug('revert-feature - _checkCoupling', 'Could not get files for commit', {
|
|
183
|
+
hash: commit.hash
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
logger.debug('revert-feature - _checkCoupling', 'Coupling check complete', {
|
|
189
|
+
coupledCount: coupled.length
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
return coupled;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ─── Revert log ──────────────────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Read .claude/revert-log.json (returns [] if missing or unreadable)
|
|
199
|
+
* @param {string} repoRoot
|
|
200
|
+
* @returns {Array}
|
|
201
|
+
*/
|
|
202
|
+
function _readRevertLog(repoRoot) {
|
|
203
|
+
const logPath = path.join(repoRoot, REVERT_LOG_RELATIVE_PATH);
|
|
204
|
+
if (!fs.existsSync(logPath)) return [];
|
|
205
|
+
try {
|
|
206
|
+
return JSON.parse(fs.readFileSync(logPath, 'utf8'));
|
|
207
|
+
} catch {
|
|
208
|
+
return [];
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Append a revert entry to .claude/revert-log.json
|
|
214
|
+
*
|
|
215
|
+
* @param {string} repoRoot
|
|
216
|
+
* @param {{ taskId: string, originalHash: string, revertHash: string, rcBranch: string, timestamp: string }} entry
|
|
217
|
+
*/
|
|
218
|
+
function _appendRevertLog(repoRoot, entry) {
|
|
219
|
+
const logPath = path.join(repoRoot, REVERT_LOG_RELATIVE_PATH);
|
|
220
|
+
const dir = path.join(repoRoot, '.claude');
|
|
221
|
+
|
|
222
|
+
if (!fs.existsSync(dir)) {
|
|
223
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const existing = _readRevertLog(repoRoot);
|
|
227
|
+
existing.push(entry);
|
|
228
|
+
fs.writeFileSync(logPath, JSON.stringify(existing, null, 2), 'utf8');
|
|
229
|
+
|
|
230
|
+
logger.debug('revert-feature - _appendRevertLog', 'Revert log updated', { logPath });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ─── Display helpers ─────────────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Print a formatted box with commit details
|
|
237
|
+
* @param {{ hash: string, message: string, author: string, date: string }} commit
|
|
238
|
+
* @param {string[]} files
|
|
239
|
+
*/
|
|
240
|
+
function _displayCommitDetails(commit, files) {
|
|
241
|
+
console.log('');
|
|
242
|
+
console.log('┌─────────────────────────────────────────────────────────────┐');
|
|
243
|
+
console.log(' Commit to revert');
|
|
244
|
+
console.log('├─────────────────────────────────────────────────────────────┤');
|
|
245
|
+
console.log(` Hash: ${commit.hash.substring(0, 7)}`);
|
|
246
|
+
console.log(` Message: ${commit.message}`);
|
|
247
|
+
console.log(` Author: ${commit.author}`);
|
|
248
|
+
console.log(` Date: ${commit.date.substring(0, 10)}`);
|
|
249
|
+
console.log(` Files: ${files.length} file(s) changed`);
|
|
250
|
+
|
|
251
|
+
const displayFiles = files.length <= 10 ? files : files.slice(0, 10);
|
|
252
|
+
displayFiles.forEach((f) => console.log(` - ${f}`));
|
|
253
|
+
if (files.length > 10) {
|
|
254
|
+
console.log(` ... and ${files.length - 10} more`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
console.log('└─────────────────────────────────────────────────────────────┘');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ─── Main command ─────────────────────────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Main entry point for `claude-hooks revert-feature <task-id>`
|
|
264
|
+
* @param {string[]} args - CLI args after 'revert-feature'
|
|
265
|
+
*/
|
|
266
|
+
export async function runRevertFeature(args) {
|
|
267
|
+
const { taskId, updateShadow, dryRun } = _parseArgs(args);
|
|
268
|
+
|
|
269
|
+
if (!taskId) {
|
|
270
|
+
console.error(
|
|
271
|
+
'❌ Usage: claude-hooks revert-feature <task-id> [--update-shadow] [--dry-run]'
|
|
272
|
+
);
|
|
273
|
+
console.error(' Example: claude-hooks revert-feature AUT-3179');
|
|
274
|
+
process.exit(1);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// 1. Validate: must be on a release-candidate/V* branch
|
|
278
|
+
const currentBranch = getCurrentBranch();
|
|
279
|
+
if (!currentBranch.startsWith('release-candidate/')) {
|
|
280
|
+
console.error('❌ Must be on a release-candidate/V* branch.');
|
|
281
|
+
console.error(` Current branch: ${currentBranch}`);
|
|
282
|
+
process.exit(1);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// 2. Validate: working directory must be clean
|
|
286
|
+
if (!isWorkingDirectoryClean()) {
|
|
287
|
+
console.error('❌ Working directory has uncommitted changes.');
|
|
288
|
+
console.error(' Please commit or stash your changes before reverting.');
|
|
289
|
+
process.exit(1);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// 3. Search for commits matching the task ID
|
|
293
|
+
console.log(`\n🔍 Searching for "${taskId}" in ${currentBranch}...`);
|
|
294
|
+
const commits = _searchCommits(taskId);
|
|
295
|
+
|
|
296
|
+
if (commits.length === 0) {
|
|
297
|
+
console.error(`❌ No commits found for "${taskId}" in this release-candidate.`);
|
|
298
|
+
console.error(` Searched: origin/main..HEAD on ${currentBranch}`);
|
|
299
|
+
process.exit(1);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// 4. Select target commit
|
|
303
|
+
let targetCommit;
|
|
304
|
+
if (commits.length === 1) {
|
|
305
|
+
targetCommit = commits[0];
|
|
306
|
+
} else {
|
|
307
|
+
console.log(`\n⚠️ Found ${commits.length} commits matching "${taskId}".`);
|
|
308
|
+
const options = commits.map((c, i) => ({
|
|
309
|
+
key: String(i + 1),
|
|
310
|
+
label: `${c.hash.substring(0, 7)} ${c.message} (${c.author}, ${c.date.substring(0, 10)})`
|
|
311
|
+
}));
|
|
312
|
+
const selectedKey = await promptMenu('Select the commit to revert:', options, '1');
|
|
313
|
+
const selectedIndex = parseInt(selectedKey, 10) - 1;
|
|
314
|
+
targetCommit = commits[Math.max(0, selectedIndex)];
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// 5. Get target commit files (for display and coupling)
|
|
318
|
+
let targetFiles = [];
|
|
319
|
+
try {
|
|
320
|
+
targetFiles = getCommitFiles(targetCommit.hash);
|
|
321
|
+
} catch {
|
|
322
|
+
logger.debug('revert-feature - runRevertFeature', 'Could not get commit files', {
|
|
323
|
+
hash: targetCommit.hash
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// 6. Coupling check
|
|
328
|
+
const coupledCommits = _checkCoupling(targetCommit.hash, targetFiles);
|
|
329
|
+
|
|
330
|
+
// 7. Dry-run: preview only
|
|
331
|
+
if (dryRun) {
|
|
332
|
+
console.log('\n📋 Dry-run preview (no changes will be made)\n');
|
|
333
|
+
_displayCommitDetails(targetCommit, targetFiles);
|
|
334
|
+
|
|
335
|
+
if (coupledCommits.length > 0) {
|
|
336
|
+
console.log('\n⚠️ Coupling warnings:');
|
|
337
|
+
coupledCommits.forEach((c) => {
|
|
338
|
+
const label = c.taskId ? `[${c.taskId}]` : c.hash;
|
|
339
|
+
console.log(` ${label} also modifies: ${c.sharedFiles.join(', ')}`);
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
console.log('\nActions that would be performed:');
|
|
344
|
+
console.log(` 1. git revert --no-edit ${targetCommit.hash.substring(0, 7)}`);
|
|
345
|
+
console.log(` 2. git push origin ${currentBranch}`);
|
|
346
|
+
console.log(' 3. Update .claude/revert-log.json');
|
|
347
|
+
if (updateShadow) {
|
|
348
|
+
console.log(` 4. claude-hooks shadow sync ${currentBranch}`);
|
|
349
|
+
}
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// 8. Display details + coupling warnings + single confirmation
|
|
354
|
+
_displayCommitDetails(targetCommit, targetFiles);
|
|
355
|
+
|
|
356
|
+
if (coupledCommits.length > 0) {
|
|
357
|
+
console.log('\n⚠️ Coupling warnings:');
|
|
358
|
+
coupledCommits.forEach((c) => {
|
|
359
|
+
const label = c.taskId ? `[${c.taskId}]` : c.hash;
|
|
360
|
+
console.log(
|
|
361
|
+
` ${label} also modifies: ${c.sharedFiles.join(', ')} — consider reverting both`
|
|
362
|
+
);
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const confirmed = await promptConfirmation('\nProceed with revert?', false);
|
|
367
|
+
if (!confirmed) {
|
|
368
|
+
console.log('Aborted.');
|
|
369
|
+
process.exit(0);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// 9. Revert
|
|
373
|
+
console.log('\n⏳ Reverting commit...');
|
|
374
|
+
try {
|
|
375
|
+
execSync(`git revert --no-edit ${targetCommit.hash}`, {
|
|
376
|
+
encoding: 'utf8',
|
|
377
|
+
stdio: 'inherit'
|
|
378
|
+
});
|
|
379
|
+
} catch (err) {
|
|
380
|
+
console.error(`❌ git revert failed: ${err.message}`);
|
|
381
|
+
console.error(' Resolve conflicts manually or run: git revert --abort');
|
|
382
|
+
process.exit(1);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const revertHash = _getHeadHash();
|
|
386
|
+
|
|
387
|
+
// 10. Push
|
|
388
|
+
console.log('⏳ Pushing to remote...');
|
|
389
|
+
const pushResult = pushBranch(currentBranch);
|
|
390
|
+
if (!pushResult.success) {
|
|
391
|
+
console.error(`❌ Push failed: ${pushResult.error}`);
|
|
392
|
+
console.error(' Revert commit created locally. Push manually:');
|
|
393
|
+
console.error(` git push origin ${currentBranch}`);
|
|
394
|
+
process.exit(1);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// 11. Write revert log
|
|
398
|
+
const repoRoot = getRepoRoot();
|
|
399
|
+
_appendRevertLog(repoRoot, {
|
|
400
|
+
taskId,
|
|
401
|
+
originalHash: targetCommit.hash,
|
|
402
|
+
revertHash,
|
|
403
|
+
rcBranch: currentBranch,
|
|
404
|
+
timestamp: new Date().toISOString()
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
// 12. Update shadow (optional)
|
|
408
|
+
if (updateShadow) {
|
|
409
|
+
console.log('\n⏳ Updating shadow branch...');
|
|
410
|
+
try {
|
|
411
|
+
const { runShadow } = await import('./shadow.js');
|
|
412
|
+
await runShadow(['sync', currentBranch]);
|
|
413
|
+
} catch (err) {
|
|
414
|
+
console.warn(`⚠️ Shadow sync failed: ${err.message}`);
|
|
415
|
+
console.warn(` Re-deploy manually: claude-hooks shadow sync ${currentBranch}`);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// 13. Summary + revert-the-revert reminder
|
|
420
|
+
console.log('\n✅ Feature reverted successfully!\n');
|
|
421
|
+
console.log('┌─────────────────────────────────────────────────────────────┐');
|
|
422
|
+
console.log(` Task ID: ${taskId}`);
|
|
423
|
+
console.log(
|
|
424
|
+
` Reverted: ${targetCommit.hash.substring(0, 7)} → revert ${revertHash.substring(0, 7)}`
|
|
425
|
+
);
|
|
426
|
+
console.log(` Branch: ${currentBranch}`);
|
|
427
|
+
if (updateShadow) {
|
|
428
|
+
console.log(' Shadow: synced ✅');
|
|
429
|
+
} else {
|
|
430
|
+
console.log(' Shadow: not updated (use --update-shadow to deploy)');
|
|
431
|
+
}
|
|
432
|
+
console.log('├─────────────────────────────────────────────────────────────┤');
|
|
433
|
+
console.log(' 💡 After the release, run in develop:');
|
|
434
|
+
console.log(` git revert ${revertHash.substring(0, 7)} # Restores ${taskId} for next sprint`);
|
|
435
|
+
console.log('└─────────────────────────────────────────────────────────────┘\n');
|
|
436
|
+
}
|