claude-git-hooks 2.9.1 → 2.11.0

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,103 @@
1
+ /**
2
+ * File: migrate-config.js
3
+ * Purpose: Migrate legacy config.json to v2.8.0 format
4
+ *
5
+ * DEPRECATED: Will be removed in v3.0.0 (most users will have migrated by then)
6
+ */
7
+
8
+ import fs from 'fs';
9
+ import path from 'path';
10
+ import {
11
+ error,
12
+ success,
13
+ info,
14
+ warning
15
+ } from './helpers.js';
16
+ import { extractLegacySettings } from './install.js';
17
+
18
+ /**
19
+ * Migrates legacy config.json to v2.8.0 format (Manual command)
20
+ * Why: Simplifies configuration, reduces redundancy
21
+ */
22
+ export async function runMigrateConfig() {
23
+ const claudeDir = '.claude';
24
+ const configPath = path.join(claudeDir, 'config.json');
25
+
26
+ if (!fs.existsSync(configPath)) {
27
+ info('ℹ️ No config file found. Nothing to migrate.');
28
+ console.log('\n💡 To create a new config:');
29
+ console.log(' 1. Run: claude-hooks install --force');
30
+ console.log(' 2. Or copy from: .claude/config_example/config.example.json');
31
+ return;
32
+ }
33
+
34
+ try {
35
+ const rawConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
36
+
37
+ // Check if already in v2.8.0 format
38
+ if (rawConfig.version === '2.8.0') {
39
+ success('✅ Config is already in v2.8.0 format.');
40
+ return;
41
+ }
42
+
43
+ info('📦 Starting config migration to v2.8.0...');
44
+
45
+ // Create backup in config_old/
46
+ const configOldDir = path.join(claudeDir, 'config_old');
47
+ if (!fs.existsSync(configOldDir)) {
48
+ fs.mkdirSync(configOldDir, { recursive: true });
49
+ }
50
+ const backupPath = path.join(configOldDir, `config.json.${Date.now()}`);
51
+ fs.copyFileSync(configPath, backupPath);
52
+ success(`Backup created: ${backupPath}`);
53
+
54
+ // Extract only allowed parameters
55
+ const allowedOverrides = extractLegacySettings(rawConfig);
56
+
57
+ // Check for advanced params
58
+ const hasAdvancedParams = allowedOverrides.analysis?.ignoreExtensions ||
59
+ allowedOverrides.commitMessage?.taskIdPattern ||
60
+ allowedOverrides.subagents?.model;
61
+
62
+ // Build new config
63
+ const newConfig = {
64
+ version: '2.8.0',
65
+ preset: rawConfig.preset || 'default'
66
+ };
67
+
68
+ // Only add overrides if there are any
69
+ if (Object.keys(allowedOverrides).length > 0) {
70
+ newConfig.overrides = allowedOverrides;
71
+ }
72
+
73
+ // Show diff
74
+ console.log('\n📝 Migration preview:');
75
+ console.log(` Old format: ${Object.keys(rawConfig).length} top-level keys`);
76
+ console.log(` New format: ${Object.keys(newConfig).length} top-level keys`);
77
+ if (Object.keys(allowedOverrides).length > 0) {
78
+ console.log(` Preserved: ${Object.keys(allowedOverrides).length} override sections`);
79
+ }
80
+
81
+ // Write new config
82
+ fs.writeFileSync(configPath, JSON.stringify(newConfig, null, 4));
83
+ success('✅ Config migrated to v2.8.0 successfully!');
84
+
85
+ if (hasAdvancedParams) {
86
+ warning('⚠️ Advanced parameters detected and preserved');
87
+ info('📖 See .claude/config.advanced.example.json for documentation');
88
+ }
89
+
90
+ console.log(`\n✨ New config:`);
91
+ console.log(JSON.stringify(newConfig, null, 2));
92
+ console.log(`\n💾 Old config backed up to: ${backupPath}`);
93
+ console.log('\n💡 Many parameters are now hardcoded with sensible defaults');
94
+ console.log(' See CHANGELOG.md for full list of changes');
95
+
96
+ } catch (err) {
97
+ error(`Failed to migrate config: ${err.message}`);
98
+ console.log('\n💡 Manual migration:');
99
+ console.log(' 1. Backup your current config');
100
+ console.log(' 2. See .claude/config.example.json for new format');
101
+ console.log(' 3. Copy minimal example and customize');
102
+ }
103
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * File: presets.js
3
+ * Purpose: Preset management commands (list, set, current)
4
+ */
5
+
6
+ import { listPresets } from '../utils/preset-loader.js';
7
+ import { getConfig } from '../config.js';
8
+ import {
9
+ colors,
10
+ error,
11
+ success,
12
+ info,
13
+ warning,
14
+ updateConfig
15
+ } from './helpers.js';
16
+
17
+ /**
18
+ * Shows all available presets
19
+ */
20
+ export async function runShowPresets() {
21
+ try {
22
+ const presets = await listPresets();
23
+
24
+ if (presets.length === 0) {
25
+ warning('No presets found');
26
+ return;
27
+ }
28
+
29
+ console.log('');
30
+ info('Available presets:');
31
+ console.log('');
32
+
33
+ presets.forEach(preset => {
34
+ console.log(` ${colors.green}${preset.name}${colors.reset}`);
35
+ console.log(` ${preset.displayName}`);
36
+ console.log(` ${colors.blue}${preset.description}${colors.reset}`);
37
+ console.log('');
38
+ });
39
+
40
+ info('To set a preset: claude-hooks --set-preset <name>');
41
+ info('To see current preset: claude-hooks preset current');
42
+ console.log('');
43
+ } catch (err) {
44
+ error(`Failed to list presets: ${err.message}`);
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Sets the active preset
50
+ * Why: Configures tech-stack specific analysis settings
51
+ * @param {string} presetName - Name of preset to activate
52
+ */
53
+ export async function runSetPreset(presetName) {
54
+ if (!presetName) {
55
+ error('Please specify a preset name: claude-hooks --set-preset <name>');
56
+ return;
57
+ }
58
+
59
+ await updateConfig('preset', presetName, {
60
+ validator: async (name) => {
61
+ const presets = await listPresets();
62
+ const preset = presets.find(p => p.name === name);
63
+ if (!preset) {
64
+ error(`Preset "${name}" not found`);
65
+ info('Available presets:');
66
+ presets.forEach(p => console.log(` - ${p.name}`));
67
+ throw new Error(`Invalid preset: ${name}`);
68
+ }
69
+ return preset;
70
+ },
71
+ successMessage: async (name) => {
72
+ const presets = await listPresets();
73
+ const preset = presets.find(p => p.name === name);
74
+ return `Preset '${preset.displayName}' activated`;
75
+ }
76
+ });
77
+ }
78
+
79
+ /**
80
+ * Shows the current active preset
81
+ */
82
+ export async function runCurrentPreset() {
83
+ try {
84
+ const config = await getConfig();
85
+ const presetName = config.preset || 'default';
86
+
87
+ const presets = await listPresets();
88
+ const preset = presets.find(p => p.name === presetName);
89
+
90
+ if (preset) {
91
+ console.log('');
92
+ success(`Current preset: ${preset.displayName} (${preset.name})`);
93
+ console.log(` ${colors.blue}${preset.description}${colors.reset}`);
94
+ console.log('');
95
+ } else {
96
+ warning(`Current preset "${presetName}" not found`);
97
+ }
98
+ } catch (err) {
99
+ error(`Failed to get current preset: ${err.message}`);
100
+ }
101
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * File: setup-github.js
3
+ * Purpose: Interactive GitHub authentication setup command
4
+ *
5
+ * Handles:
6
+ * - Checking existing token
7
+ * - Prompting user for new token
8
+ * - Validating token format and permissions
9
+ * - Saving token to .claude/settings.local.json
10
+ */
11
+
12
+ import { validateToken, saveGitHubToken, validateTokenFormat } from '../utils/github-api.js';
13
+ import { promptConfirmation, promptEditField, showSuccess, showError, showInfo, showWarning } from '../utils/interactive-ui.js';
14
+ import logger from '../utils/logger.js';
15
+
16
+ /**
17
+ * Run GitHub authentication setup
18
+ * Why: Interactive flow to configure GitHub token for PR creation
19
+ *
20
+ * @returns {Promise<void>}
21
+ */
22
+ export async function runSetupGitHub() {
23
+ showInfo('GitHub Authentication Setup');
24
+
25
+ // Check existing token
26
+ try {
27
+ const validation = await validateToken();
28
+ if (validation.valid) {
29
+ showSuccess(`Already authenticated as: ${validation.user}`);
30
+ logger.info(` Scopes: ${validation.scopes.join(', ')}`);
31
+
32
+ if (!validation.hasRepoScope) {
33
+ showWarning('Token lacks "repo" scope - PR creation may fail');
34
+ }
35
+
36
+ const reconfigure = await promptConfirmation('Configure a different token?', false);
37
+ if (!reconfigure) {
38
+ return;
39
+ }
40
+ }
41
+ } catch (e) {
42
+ logger.debug('setup-github', 'No existing token found or validation failed', { error: e.message });
43
+ }
44
+
45
+ // Show instructions
46
+ logger.info('Create a GitHub Personal Access Token:');
47
+ logger.info(' 1. Go to: https://github.com/settings/tokens/new');
48
+ logger.info(' 2. Select scopes: repo, read:org');
49
+ logger.info(' 3. Generate and copy the token');
50
+
51
+ // Prompt for token
52
+ const token = await promptEditField('Paste your token (ghp_...)', '');
53
+
54
+ if (!token || token.trim() === '') {
55
+ showWarning('No token provided. Setup cancelled.');
56
+ return;
57
+ }
58
+
59
+ const trimmedToken = token.trim();
60
+
61
+ // Validate format
62
+ const formatCheck = validateTokenFormat(trimmedToken);
63
+ if (formatCheck.warning) {
64
+ showWarning(formatCheck.warning);
65
+ const proceed = await promptConfirmation('Continue anyway?', false);
66
+ if (!proceed) {
67
+ return;
68
+ }
69
+ }
70
+
71
+ // Save token
72
+ const saveResult = saveGitHubToken(trimmedToken);
73
+ if (!saveResult.success) {
74
+ showError('Failed to save token: ' + saveResult.error);
75
+ return;
76
+ }
77
+ showSuccess(`Token saved to ${saveResult.path}`);
78
+
79
+ // Validate with GitHub API
80
+ showInfo('Validating token...');
81
+
82
+ const validation = await validateToken();
83
+ if (validation.valid) {
84
+ showSuccess(`Authenticated as: ${validation.user}`);
85
+ if (!validation.hasRepoScope) {
86
+ showWarning('Token lacks "repo" scope - regenerate with "repo" enabled');
87
+ }
88
+ } else {
89
+ showWarning('Token validation failed - may not work correctly');
90
+ }
91
+
92
+ showSuccess('Setup complete! You can now use: claude-hooks create-pr');
93
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * File: telemetry-cmd.js
3
+ * Purpose: Telemetry management commands (show, clear)
4
+ */
5
+
6
+ import { getConfig } from '../config.js';
7
+ import { displayStatistics as showTelemetryStats, clearTelemetry as clearTelemetryData } from '../utils/telemetry.js';
8
+ import { promptConfirmation } from '../utils/interactive-ui.js';
9
+ import logger from '../utils/logger.js';
10
+ import {
11
+ success,
12
+ info
13
+ } from './helpers.js';
14
+
15
+ /**
16
+ * Show telemetry statistics
17
+ * Why: Help users understand JSON parsing patterns and batch performance
18
+ */
19
+ export async function runShowTelemetry() {
20
+ await showTelemetryStats();
21
+ }
22
+
23
+ /**
24
+ * Clear telemetry data
25
+ * Why: Allow users to reset telemetry
26
+ */
27
+ export async function runClearTelemetry() {
28
+ const config = await getConfig();
29
+
30
+ if (!config.system?.telemetry && config.system?.telemetry !== undefined) {
31
+ logger.warning('⚠️ Telemetry is currently disabled.');
32
+ logger.info('To re-enable (default), remove or set to true in .claude/config.json:');
33
+ logger.info('{');
34
+ logger.info(' "system": {');
35
+ logger.info(' "telemetry": true');
36
+ logger.info(' }');
37
+ logger.info('}');
38
+ return;
39
+ }
40
+
41
+ const confirmed = await promptConfirmation('Are you sure you want to clear all telemetry data?');
42
+ if (confirmed) {
43
+ await clearTelemetryData();
44
+ success('Telemetry data cleared successfully');
45
+ } else {
46
+ info('Telemetry data was not cleared');
47
+ }
48
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * File: update.js
3
+ * Purpose: Update command - update to the latest version
4
+ */
5
+
6
+ import { execSync } from 'child_process';
7
+ import {
8
+ error,
9
+ success,
10
+ info,
11
+ warning,
12
+ getPackageJson,
13
+ getLatestVersion,
14
+ compareVersions
15
+ } from './helpers.js';
16
+ import { runInstall } from './install.js';
17
+
18
+ /**
19
+ * Update command - update to the latest version
20
+ */
21
+ export async function runUpdate() {
22
+ info('Checking latest available version...');
23
+
24
+ try {
25
+ const currentVersion = getPackageJson().version;
26
+ const latestVersion = await getLatestVersion('claude-git-hooks');
27
+
28
+ const comparison = compareVersions(currentVersion, latestVersion);
29
+
30
+ if (comparison === 0) {
31
+ success(`You already have the latest version installed (${currentVersion})`);
32
+ return;
33
+ } else if (comparison > 0) {
34
+ info(`You are using a development version (${currentVersion})`);
35
+ info(`Latest published version: ${latestVersion}`);
36
+ success(`You already have the latest version installed (${currentVersion})`);
37
+ return;
38
+ }
39
+
40
+ info(`Current version: ${currentVersion}`);
41
+ info(`Available version: ${latestVersion}`);
42
+
43
+ // Update the package
44
+ info('Updating claude-git-hooks...');
45
+ try {
46
+ execSync('npm install -g claude-git-hooks@latest', { stdio: 'inherit' });
47
+ success(`Successfully updated to version ${latestVersion}`);
48
+
49
+ // Reinstall hooks with the new version
50
+ info('Reinstalling hooks with the new version...');
51
+ await runInstall(['--force']);
52
+
53
+ } catch (updateError) {
54
+ error('Error updating. Try running: npm install -g claude-git-hooks@latest');
55
+ }
56
+ } catch (e) {
57
+ warning('Could not check the latest available version');
58
+ warning('Trying to update anyway...');
59
+ try {
60
+ execSync('npm install -g claude-git-hooks@latest', { stdio: 'inherit' });
61
+ success('Update completed');
62
+ await runInstall(['--force']);
63
+ } catch (updateError) {
64
+ error('Error updating: ' + updateError.message);
65
+ }
66
+ }
67
+ }
package/lib/config.js CHANGED
@@ -101,6 +101,11 @@ const defaults = {
101
101
  ai: ['ai', 'tooling'],
102
102
  default: []
103
103
  },
104
+ // Auto-push configuration (v2.11.0)
105
+ autoPush: true, // Auto-push unpublished branches
106
+ pushConfirm: true, // Prompt for confirmation before push
107
+ verifyRemote: true, // Verify remote exists before push
108
+ showCommits: true, // Show commit preview before push
104
109
  },
105
110
  },
106
111
 
@@ -328,6 +328,215 @@ const getStagedStats = () => {
328
328
  }
329
329
  };
330
330
 
331
+ /**
332
+ * Gets the configured remote name
333
+ * Why: Determines which remote to use for push operations (usually 'origin')
334
+ *
335
+ * @returns {string} Remote name (default: 'origin')
336
+ */
337
+ const getRemoteName = () => {
338
+ logger.debug('git-operations - getRemoteName', 'Getting remote name');
339
+
340
+ try {
341
+ // Get list of remotes
342
+ const remotes = execGitCommand('remote');
343
+ const remoteList = remotes.split(/\r?\n/).filter(r => r.length > 0);
344
+
345
+ if (remoteList.length === 0) {
346
+ logger.debug('git-operations - getRemoteName', 'No remotes configured');
347
+ return 'origin'; // Default even if not configured
348
+ }
349
+
350
+ // Prefer 'origin', otherwise use first remote
351
+ const remoteName = remoteList.includes('origin') ? 'origin' : remoteList[0];
352
+
353
+ logger.debug('git-operations - getRemoteName', 'Remote name determined', {
354
+ remoteName,
355
+ availableRemotes: remoteList
356
+ });
357
+
358
+ return remoteName;
359
+
360
+ } catch (error) {
361
+ logger.error('git-operations - getRemoteName', 'Failed to get remote name', error);
362
+ return 'origin'; // Fallback to default
363
+ }
364
+ };
365
+
366
+ /**
367
+ * Verifies that a remote exists and is configured
368
+ * Why: Prevents push failures by checking remote configuration first
369
+ *
370
+ * @param {string} remoteName - Name of remote to verify (e.g., 'origin')
371
+ * @returns {boolean} True if remote exists, false otherwise
372
+ */
373
+ const verifyRemoteExists = (remoteName) => {
374
+ logger.debug('git-operations - verifyRemoteExists', 'Verifying remote exists', { remoteName });
375
+
376
+ try {
377
+ const remotes = execGitCommand('remote');
378
+ const remoteList = remotes.split(/\r?\n/).filter(r => r.length > 0);
379
+ const exists = remoteList.includes(remoteName);
380
+
381
+ logger.debug('git-operations - verifyRemoteExists', 'Remote verification result', {
382
+ remoteName,
383
+ exists,
384
+ availableRemotes: remoteList
385
+ });
386
+
387
+ return exists;
388
+
389
+ } catch (error) {
390
+ logger.error('git-operations - verifyRemoteExists', 'Failed to verify remote', error);
391
+ return false;
392
+ }
393
+ };
394
+
395
+ /**
396
+ * Gets branch push status (remote tracking, unpushed commits, divergence)
397
+ * Why: Determines if and how to push before creating PR
398
+ *
399
+ * @param {string} branchName - Branch to check
400
+ * @returns {Object} Status object with:
401
+ * - hasRemote: boolean (branch exists on remote)
402
+ * - hasUnpushedCommits: boolean (local commits not pushed)
403
+ * - hasDiverged: boolean (local and remote histories differ)
404
+ * - upstreamBranch: string (e.g., 'origin/feature-branch')
405
+ * - unpushedCommits: Array<{sha, message}> (commits to push)
406
+ */
407
+ const getBranchPushStatus = (branchName) => {
408
+ logger.debug('git-operations - getBranchPushStatus', 'Checking branch push status', { branchName });
409
+
410
+ const status = {
411
+ hasRemote: false,
412
+ hasUnpushedCommits: false,
413
+ hasDiverged: false,
414
+ upstreamBranch: null,
415
+ unpushedCommits: []
416
+ };
417
+
418
+ try {
419
+ // Check if branch has upstream tracking
420
+ // Use execSync directly to avoid logging expected "no upstream" errors
421
+ const upstream = execSync(
422
+ `git rev-parse --abbrev-ref --symbolic-full-name ${branchName}@{u}`,
423
+ { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
424
+ ).trim();
425
+
426
+ status.upstreamBranch = upstream;
427
+ status.hasRemote = true;
428
+
429
+ logger.debug('git-operations - getBranchPushStatus', 'Branch has upstream', {
430
+ branchName,
431
+ upstream
432
+ });
433
+
434
+ // Check for unpushed commits (local ahead of remote)
435
+ const unpushedLog = execGitCommand(`rev-list ${upstream}..${branchName} --oneline`);
436
+ if (unpushedLog) {
437
+ status.hasUnpushedCommits = true;
438
+ status.unpushedCommits = unpushedLog.split(/\r?\n/)
439
+ .filter(line => line.length > 0)
440
+ .map(line => {
441
+ const [sha, ...messageParts] = line.split(' ');
442
+ return {
443
+ sha,
444
+ message: messageParts.join(' ')
445
+ };
446
+ });
447
+ }
448
+
449
+ // Check for divergence (remote has commits not in local)
450
+ const divergedLog = execGitCommand(`rev-list ${branchName}..${upstream} --oneline`);
451
+ if (divergedLog) {
452
+ status.hasDiverged = true;
453
+ logger.debug('git-operations - getBranchPushStatus', 'Branch has diverged', {
454
+ branchName,
455
+ remoteCommits: divergedLog.split(/\r?\n/).length
456
+ });
457
+ }
458
+
459
+ } catch (error) {
460
+ // No upstream means branch not published to remote (this is expected, not an error)
461
+ logger.debug('git-operations - getBranchPushStatus', 'Branch has no upstream (not published)', {
462
+ branchName
463
+ });
464
+
465
+ status.hasRemote = false;
466
+ status.hasUnpushedCommits = true; // All commits are unpushed
467
+
468
+ // Get all commits on this branch (not on any remote branch)
469
+ try {
470
+ const remoteName = getRemoteName();
471
+ const allCommits = execGitCommand(`rev-list ${branchName} --not --remotes=${remoteName} --oneline`);
472
+ if (allCommits) {
473
+ status.unpushedCommits = allCommits.split(/\r?\n/)
474
+ .filter(line => line.length > 0)
475
+ .map(line => {
476
+ const [sha, ...messageParts] = line.split(' ');
477
+ return {
478
+ sha,
479
+ message: messageParts.join(' ')
480
+ };
481
+ });
482
+ }
483
+ } catch (commitError) {
484
+ logger.debug('git-operations - getBranchPushStatus', 'Could not get unpushed commits', commitError);
485
+ }
486
+ }
487
+
488
+ logger.debug('git-operations - getBranchPushStatus', 'Push status determined', status);
489
+
490
+ return status;
491
+ };
492
+
493
+ /**
494
+ * Pushes branch to remote
495
+ * Why: Publishes local branch to remote before creating PR
496
+ *
497
+ * @param {string} branchName - Branch to push
498
+ * @param {Object} options - Push options
499
+ * @param {boolean} options.setUpstream - Use -u flag to set upstream tracking (default: false)
500
+ * @returns {Object} Result object with:
501
+ * - success: boolean
502
+ * - output: string (stdout)
503
+ * - error: string (stderr)
504
+ */
505
+ const pushBranch = (branchName, { setUpstream = false } = {}) => {
506
+ logger.debug('git-operations - pushBranch', 'Pushing branch', { branchName, setUpstream });
507
+
508
+ try {
509
+ const remoteName = getRemoteName();
510
+ const upstreamFlag = setUpstream ? '-u' : '';
511
+ const command = upstreamFlag
512
+ ? `push ${upstreamFlag} ${remoteName} ${branchName}`
513
+ : `push ${remoteName} ${branchName}`;
514
+
515
+ const output = execGitCommand(command);
516
+
517
+ logger.debug('git-operations - pushBranch', 'Push successful', {
518
+ branchName,
519
+ remoteName,
520
+ setUpstream
521
+ });
522
+
523
+ return {
524
+ success: true,
525
+ output,
526
+ error: ''
527
+ };
528
+
529
+ } catch (error) {
530
+ logger.error('git-operations - pushBranch', 'Push failed', error);
531
+
532
+ return {
533
+ success: false,
534
+ output: error.output || '',
535
+ error: error.cause?.message || error.message || 'Unknown push error'
536
+ };
537
+ }
538
+ };
539
+
331
540
  export {
332
541
  GitError,
333
542
  getStagedFiles,
@@ -337,5 +546,9 @@ export {
337
546
  getRepoRoot,
338
547
  getCurrentBranch,
339
548
  getRepoName,
340
- getStagedStats
549
+ getStagedStats,
550
+ getRemoteName,
551
+ verifyRemoteExists,
552
+ getBranchPushStatus,
553
+ pushBranch
341
554
  };