claude-git-hooks 2.10.0 → 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 CHANGED
@@ -5,6 +5,39 @@ Todos los cambios notables en este proyecto se documentarán en este archivo.
5
5
  El formato está basado en [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  y este proyecto adhiere a [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [2.11.0] - 2026-02-03
9
+
10
+ ### ✨ Added
11
+
12
+ - **Auto-push functionality in `create-pr` command** - Automatically detects and pushes unpublished branches before creating PR (#59, #34)
13
+ - Handles 3 scenarios: unpublished branch, unpushed commits, up-to-date branch
14
+ - Interactive confirmation prompt before pushing (configurable)
15
+ - Commit preview showing what will be pushed
16
+ - Diverged branch detection with helpful error message and resolution steps
17
+ - Never uses `git push --force` for security
18
+
19
+ - **New configuration options** under `github.pr`:
20
+ - `autoPush: true` - Enable/disable auto-push (enabled by default)
21
+ - `pushConfirm: true` - Prompt for confirmation before push
22
+ - `showCommits: true` - Show commit preview before push
23
+ - `verifyRemote: true` - Verify remote exists before push
24
+
25
+ - **New git-operations functions**:
26
+ - `getRemoteName()` - Get configured remote name (usually 'origin')
27
+ - `verifyRemoteExists()` - Check if remote is configured
28
+ - `getBranchPushStatus()` - Detect branch publish status and unpushed commits
29
+ - `pushBranch()` - Execute git push with upstream tracking
30
+
31
+ ### 🔧 Fixed
32
+
33
+ - Issue #59: `create-pr` no longer fails when branch is not published to remote
34
+ - Issue #34: Git push now integrated into create-pr workflow with proper error handling
35
+
36
+ ### 📚 Documentation
37
+
38
+ - Updated CLAUDE.md with new git-operations functions and configuration options
39
+ - Added comprehensive JSDoc comments for all new functions
40
+
8
41
  ## [2.10.0] - 2026-02-02
9
42
 
10
43
  ### 🏗️ Architecture - Modular CLI Refactoring
package/README.md CHANGED
@@ -31,9 +31,16 @@ git commit -m "auto"
31
31
 
32
32
  ```bash
33
33
  claude-hooks create-pr develop
34
+ # Automatically pushes branch if unpublished (with confirmation)
34
35
  # Analyzes diff, generates title/description, creates PR on GitHub
35
36
  ```
36
37
 
38
+ **Auto-push feature (v2.11.0):**
39
+ - Detects unpublished branches or unpushed commits
40
+ - Shows commit preview before pushing
41
+ - Prompts for confirmation (configurable)
42
+ - Handles diverged branches gracefully
43
+
37
44
  ### GitHub Token Setup
38
45
 
39
46
  ```bash
@@ -221,7 +228,16 @@ Preset always wins - tech-stack specific has priority over user preferences.
221
228
  "version": "2.8.0",
222
229
  "preset": "backend",
223
230
  "overrides": {
224
- "github": { "pr": { "defaultBase": "develop", "reviewers": ["user"] } },
231
+ "github": {
232
+ "pr": {
233
+ "defaultBase": "develop",
234
+ "reviewers": ["user"],
235
+ "autoPush": true, // Auto-push unpublished branches (v2.11.0)
236
+ "pushConfirm": true, // Prompt before push (v2.11.0)
237
+ "showCommits": true, // Show commit preview (v2.11.0)
238
+ "verifyRemote": true // Verify remote exists (v2.11.0)
239
+ }
240
+ },
225
241
  "subagents": { "batchSize": 2 }
226
242
  }
227
243
  }
@@ -11,7 +11,8 @@ import { loadPrompt } from '../utils/prompt-builder.js';
11
11
  import { getConfig } from '../config.js';
12
12
  import { getOrPromptTaskId, formatWithTaskId } from '../utils/task-id.js';
13
13
  import { getReviewersForFiles } from '../utils/github-client.js';
14
- import { showPRPreview, promptMenu, showSuccess, showError, showInfo, showWarning } from '../utils/interactive-ui.js';
14
+ import { showPRPreview, promptMenu, showSuccess, showError, showInfo, showWarning, promptConfirmation } from '../utils/interactive-ui.js';
15
+ import { getBranchPushStatus, pushBranch } from '../utils/git-operations.js';
15
16
  import logger from '../utils/logger.js';
16
17
  import {
17
18
  error,
@@ -136,6 +137,101 @@ export async function runCreatePr(args) {
136
137
 
137
138
  logger.debug('create-pr', 'No existing PR found, continuing');
138
139
 
140
+ // Step 5.5: Check branch status and auto-push if needed (v2.11.0)
141
+ logger.debug('create-pr', 'Step 5.5: Checking branch push status');
142
+ const pushStatus = getBranchPushStatus(currentBranch);
143
+ const pushConfig = config.github?.pr;
144
+
145
+ logger.debug('create-pr', 'Branch push status', {
146
+ hasRemote: pushStatus.hasRemote,
147
+ hasUnpushedCommits: pushStatus.hasUnpushedCommits,
148
+ hasDiverged: pushStatus.hasDiverged,
149
+ unpushedCount: pushStatus.unpushedCommits.length
150
+ });
151
+
152
+ if (!pushStatus.hasRemote || pushStatus.hasUnpushedCommits) {
153
+ // Branch needs to be pushed
154
+
155
+ if (pushStatus.hasDiverged) {
156
+ // ABORT: Cannot push diverged branch
157
+ logger.error('create-pr', 'Branch has diverged from remote', {
158
+ branch: currentBranch,
159
+ upstream: pushStatus.upstreamBranch
160
+ });
161
+ showError('Branch has diverged from remote');
162
+ console.log('');
163
+ console.log('Please resolve conflicts manually:');
164
+ console.log(` git pull origin ${currentBranch}`);
165
+ console.log('');
166
+ process.exit(1);
167
+ }
168
+
169
+ if (!pushConfig?.autoPush) {
170
+ // Auto-push disabled - show error
171
+ logger.debug('create-pr', 'Auto-push disabled, aborting', { autoPush: pushConfig?.autoPush });
172
+ showError('Branch not pushed to remote');
173
+ console.log('');
174
+ console.log('Please push your branch first:');
175
+ console.log(` git push -u origin ${currentBranch}`);
176
+ console.log('');
177
+ process.exit(1);
178
+ }
179
+
180
+ // Show push preview
181
+ if (pushConfig?.showCommits && pushStatus.unpushedCommits.length > 0) {
182
+ console.log('');
183
+ showInfo('Commits to push:');
184
+ pushStatus.unpushedCommits.forEach(commit => {
185
+ console.log(` ${commit.sha.substring(0, 7)} ${commit.message}`);
186
+ });
187
+ }
188
+
189
+ // Confirm with user
190
+ if (pushConfig?.pushConfirm) {
191
+ console.log('');
192
+ const shouldPush = await promptConfirmation(
193
+ `Push branch '${currentBranch}' to remote?`,
194
+ true // default yes
195
+ );
196
+
197
+ if (!shouldPush) {
198
+ logger.debug('create-pr', 'User declined push, aborting');
199
+ showInfo('Push cancelled - cannot create PR without pushing');
200
+ process.exit(0);
201
+ }
202
+ }
203
+
204
+ // Execute push
205
+ console.log('');
206
+ showInfo('Pushing branch to remote...');
207
+ logger.debug('create-pr', 'Executing push', {
208
+ branch: currentBranch,
209
+ setUpstream: !pushStatus.hasRemote
210
+ });
211
+
212
+ const pushResult = pushBranch(currentBranch, {
213
+ setUpstream: !pushStatus.hasRemote
214
+ });
215
+
216
+ if (!pushResult.success) {
217
+ logger.error('create-pr', 'Push failed', {
218
+ branch: currentBranch,
219
+ error: pushResult.error
220
+ });
221
+ showError('Failed to push branch');
222
+ console.error('');
223
+ console.error(` ${pushResult.error}`);
224
+ console.error('');
225
+ process.exit(1);
226
+ }
227
+
228
+ logger.debug('create-pr', 'Push completed successfully');
229
+ showSuccess('Branch pushed successfully');
230
+ console.log('');
231
+ } else {
232
+ logger.debug('create-pr', 'Branch already up-to-date with remote, skipping push');
233
+ }
234
+
139
235
  // Step 6: Update remote and check for differences
140
236
  logger.debug('create-pr', 'Step 6: Fetching latest changes from remote');
141
237
  execSync('git fetch', { stdio: 'ignore' });
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
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-git-hooks",
3
- "version": "2.10.0",
3
+ "version": "2.11.0",
4
4
  "description": "Git hooks with Claude CLI for code analysis and automatic commit messages",
5
5
  "type": "module",
6
6
  "bin": {