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.
- package/CHANGELOG.md +140 -0
- package/README.md +224 -754
- package/bin/claude-hooks +97 -2310
- package/lib/commands/analyze-diff.js +262 -0
- package/lib/commands/create-pr.js +470 -0
- package/lib/commands/debug.js +52 -0
- package/lib/commands/help.js +147 -0
- package/lib/commands/helpers.js +389 -0
- package/lib/commands/hooks.js +150 -0
- package/lib/commands/install.js +688 -0
- package/lib/commands/migrate-config.js +103 -0
- package/lib/commands/presets.js +101 -0
- package/lib/commands/setup-github.js +93 -0
- package/lib/commands/telemetry-cmd.js +48 -0
- package/lib/commands/update.js +67 -0
- package/lib/config.js +5 -0
- package/lib/utils/git-operations.js +214 -1
- package/lib/utils/github-api.js +87 -17
- package/lib/utils/github-client.js +9 -550
- package/package.json +1 -1
- package/lib/utils/mcp-setup.js +0 -342
|
@@ -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
|
};
|