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.
@@ -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
- '\n╔════════════════════════════════════════════════════════════════════╗'
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(' Orchestrator threshold: 3 files (commits with ≥3 files use intelligent grouping)');
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
 
@@ -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: 30000 });
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: 30000 });
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: 30000 });
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: 30000 });
423
+ const composeResponse = await executeClaudeWithRetry(composePrompt, { timeout: 60000 });
426
424
 
427
425
  let issueData;
428
426
  try {
@@ -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
+ }