claude-git-hooks 2.20.0 → 2.30.1

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.
@@ -1078,6 +1078,474 @@ const getCommitsBetweenRefs = (baseRef, headRef = 'HEAD', { format = 'oneline' }
1078
1078
  }
1079
1079
  };
1080
1080
 
1081
+ /**
1082
+ * Checks out a branch, optionally creating it
1083
+ * Why: Supports workflow commands that need to switch or create branches programmatically
1084
+ *
1085
+ * @param {string} branchName - Branch to checkout (or create)
1086
+ * @param {Object} options
1087
+ * @param {boolean} options.create - Create the branch with -b flag (default: false)
1088
+ * @param {string} options.startPoint - Start point for new branch (e.g., 'origin/main')
1089
+ * @param {string} options.repoPath - Path to repository root (default: cwd)
1090
+ */
1091
+ const checkoutBranch = (branchName, { create = false, startPoint, repoPath } = {}) => {
1092
+ logger.debug('git-operations - checkoutBranch', 'Checking out branch', {
1093
+ branchName,
1094
+ create,
1095
+ startPoint
1096
+ });
1097
+
1098
+ const sanitized = sanitizeBranchName(branchName);
1099
+ if (!sanitized) {
1100
+ throw new GitError('Invalid branch name', {
1101
+ command: 'checkoutBranch',
1102
+ output: branchName
1103
+ });
1104
+ }
1105
+
1106
+ const opts = repoPath ? { cwd: repoPath } : {};
1107
+ let command = create ? `checkout -b ${sanitized}` : `checkout ${sanitized}`;
1108
+
1109
+ if (startPoint) {
1110
+ const sanitizedStart = sanitizeBranchName(startPoint);
1111
+ if (!sanitizedStart) {
1112
+ throw new GitError('Invalid start point', {
1113
+ command: 'checkoutBranch',
1114
+ output: startPoint
1115
+ });
1116
+ }
1117
+ command += ` ${sanitizedStart}`;
1118
+ }
1119
+
1120
+ try {
1121
+ execGitCommand(command, opts);
1122
+ logger.debug('git-operations - checkoutBranch', 'Checkout successful', { branchName });
1123
+ } catch (error) {
1124
+ logger.error('git-operations - checkoutBranch', 'Checkout failed', error);
1125
+ throw new GitError(`Failed to checkout branch: ${sanitized}`, {
1126
+ command: `git ${command}`,
1127
+ cause: error
1128
+ });
1129
+ }
1130
+ };
1131
+
1132
+ /**
1133
+ * Merges a branch into the current branch
1134
+ * Why: Supports workflow commands that need to merge branches programmatically
1135
+ *
1136
+ * @param {string} sourceBranch - Branch to merge
1137
+ * @param {Object} options
1138
+ * @param {string} options.message - Custom merge commit message
1139
+ * @param {boolean} options.noFF - Use --no-ff to always create a merge commit (default: false)
1140
+ * @param {boolean} options.noEdit - Use --no-edit to skip editor for merge message (default: false)
1141
+ * @param {string} options.repoPath - Path to repository root (default: cwd)
1142
+ */
1143
+ const mergeBranch = (sourceBranch, { message, noFF = false, noEdit = false, repoPath } = {}) => {
1144
+ logger.debug('git-operations - mergeBranch', 'Merging branch', { sourceBranch, noFF, noEdit });
1145
+
1146
+ const sanitized = sanitizeBranchName(sourceBranch);
1147
+ if (!sanitized) {
1148
+ throw new GitError('Invalid branch name', { command: 'mergeBranch', output: sourceBranch });
1149
+ }
1150
+
1151
+ const opts = repoPath ? { cwd: repoPath } : {};
1152
+ let command = 'merge';
1153
+ if (noFF) command += ' --no-ff';
1154
+ if (noEdit) command += ' --no-edit';
1155
+ if (message) {
1156
+ const escapedMessage = message.replace(/"/g, '\\"');
1157
+ command += ` -m "${escapedMessage}"`;
1158
+ }
1159
+ command += ` ${sanitized}`;
1160
+
1161
+ try {
1162
+ execGitCommand(command, opts);
1163
+ logger.debug('git-operations - mergeBranch', 'Merge successful', { sourceBranch });
1164
+ } catch (error) {
1165
+ logger.error('git-operations - mergeBranch', 'Merge failed', error);
1166
+ throw new GitError(`Failed to merge branch: ${sanitized}`, {
1167
+ command: `git ${command}`,
1168
+ cause: error
1169
+ });
1170
+ }
1171
+ };
1172
+
1173
+ /**
1174
+ * Resets current branch to a target ref
1175
+ * Why: Supports workflow commands that need to undo commits (e.g., revert-feature, close-release)
1176
+ * ⚠️ Destructive — always logs a warning before execution
1177
+ *
1178
+ * @param {string} target - Ref to reset to (e.g., 'origin/main', commit SHA)
1179
+ * @param {Object} options
1180
+ * @param {'soft'|'hard'} options.mode - Reset mode (default: 'soft')
1181
+ * @param {string} options.repoPath - Path to repository root (default: cwd)
1182
+ * @param {boolean} options.dryRun - Log intent without executing (default: false)
1183
+ */
1184
+ const resetBranch = (target, { mode = 'soft', repoPath, dryRun = false } = {}) => {
1185
+ const sanitized = sanitizeBranchName(target);
1186
+ if (!sanitized) {
1187
+ throw new GitError('Invalid target', { command: 'resetBranch', output: target });
1188
+ }
1189
+
1190
+ if (mode !== 'soft' && mode !== 'hard') {
1191
+ throw new GitError('Invalid reset mode — must be "soft" or "hard"', {
1192
+ command: 'resetBranch',
1193
+ output: mode
1194
+ });
1195
+ }
1196
+
1197
+ if (dryRun) {
1198
+ logger.debug(
1199
+ 'git-operations - resetBranch',
1200
+ `[DRY RUN] Would execute: git reset --${mode} ${sanitized}`
1201
+ );
1202
+ return;
1203
+ }
1204
+
1205
+ logger.warning('git-operations - resetBranch', `Resetting branch --${mode} to ${sanitized}`);
1206
+
1207
+ const opts = repoPath ? { cwd: repoPath } : {};
1208
+ try {
1209
+ execGitCommand(`reset --${mode} ${sanitized}`, opts);
1210
+ logger.debug('git-operations - resetBranch', 'Reset successful', {
1211
+ target: sanitized,
1212
+ mode
1213
+ });
1214
+ } catch (error) {
1215
+ logger.error('git-operations - resetBranch', 'Reset failed', error);
1216
+ throw new GitError(`Failed to reset branch: --${mode} ${sanitized}`, {
1217
+ command: `git reset --${mode} ${sanitized}`,
1218
+ cause: error
1219
+ });
1220
+ }
1221
+ };
1222
+
1223
+ /**
1224
+ * Force pushes a branch to remote
1225
+ * Why: Required after rebase or reset to update remote branch history
1226
+ * ⚠️ Destructive — always logs a warning before execution
1227
+ *
1228
+ * @param {string} branchName - Branch to force push
1229
+ * @param {Object} options
1230
+ * @param {boolean} options.lease - Use --force-with-lease for safety (default: true)
1231
+ * @param {string} options.repoPath - Path to repository root (default: cwd)
1232
+ * @param {boolean} options.dryRun - Log intent without executing (default: false)
1233
+ * @returns {{ success: boolean, output: string, error: string }}
1234
+ */
1235
+ const forcePush = (branchName, { lease = true, repoPath, dryRun = false } = {}) => {
1236
+ const sanitized = sanitizeBranchName(branchName);
1237
+ if (!sanitized) {
1238
+ throw new GitError('Invalid branch name', { command: 'forcePush', output: branchName });
1239
+ }
1240
+
1241
+ const forceFlag = lease ? '--force-with-lease' : '--force';
1242
+ const remoteName = getRemoteName();
1243
+
1244
+ if (dryRun) {
1245
+ logger.debug(
1246
+ 'git-operations - forcePush',
1247
+ `[DRY RUN] Would execute: git push ${forceFlag} ${remoteName} ${sanitized}`
1248
+ );
1249
+ return { success: true, output: '', error: '' };
1250
+ }
1251
+
1252
+ logger.warning(
1253
+ 'git-operations - forcePush',
1254
+ `Force pushing ${sanitized} to ${remoteName} with ${forceFlag}`
1255
+ );
1256
+
1257
+ const opts = repoPath ? { cwd: repoPath } : {};
1258
+ try {
1259
+ const output = execGitCommand(`push ${forceFlag} ${remoteName} ${sanitized}`, opts);
1260
+ logger.debug('git-operations - forcePush', 'Force push successful', {
1261
+ branchName: sanitized,
1262
+ remoteName,
1263
+ forceFlag
1264
+ });
1265
+ return { success: true, output, error: '' };
1266
+ } catch (error) {
1267
+ logger.error('git-operations - forcePush', 'Force push failed', error);
1268
+ return {
1269
+ success: false,
1270
+ output: '',
1271
+ error:
1272
+ error.output || error.cause?.message || error.message || 'Unknown force push error'
1273
+ };
1274
+ }
1275
+ };
1276
+
1277
+ /**
1278
+ * Deletes a branch from the remote
1279
+ * Why: Used by workflow commands to clean up merged feature/release branches
1280
+ * ⚠️ Destructive — logs a warning before execution; server enforces branch protection rules
1281
+ *
1282
+ * @param {string} branchName - Remote branch to delete
1283
+ * @param {Object} options
1284
+ * @param {string} options.remote - Remote name (default: detected remote)
1285
+ * @param {string} options.repoPath - Path to repository root (default: cwd)
1286
+ * @param {boolean} options.dryRun - Log intent without executing (default: false)
1287
+ */
1288
+ const deleteRemoteBranch = (branchName, { remote, repoPath, dryRun = false } = {}) => {
1289
+ const sanitized = sanitizeBranchName(branchName);
1290
+ if (!sanitized) {
1291
+ throw new GitError('Invalid branch name', {
1292
+ command: 'deleteRemoteBranch',
1293
+ output: branchName
1294
+ });
1295
+ }
1296
+
1297
+ const remoteName = remote || getRemoteName();
1298
+
1299
+ if (dryRun) {
1300
+ logger.debug(
1301
+ 'git-operations - deleteRemoteBranch',
1302
+ `[DRY RUN] Would execute: git push ${remoteName} --delete ${sanitized}`
1303
+ );
1304
+ return;
1305
+ }
1306
+
1307
+ logger.warning(
1308
+ 'git-operations - deleteRemoteBranch',
1309
+ `Deleting remote branch ${remoteName}/${sanitized}`
1310
+ );
1311
+
1312
+ const opts = repoPath ? { cwd: repoPath } : {};
1313
+ try {
1314
+ execGitCommand(`push ${remoteName} --delete ${sanitized}`, opts);
1315
+ logger.debug('git-operations - deleteRemoteBranch', 'Remote branch deleted', {
1316
+ branchName: sanitized,
1317
+ remoteName
1318
+ });
1319
+ } catch (error) {
1320
+ logger.error(
1321
+ 'git-operations - deleteRemoteBranch',
1322
+ 'Failed to delete remote branch',
1323
+ error
1324
+ );
1325
+ throw new GitError(`Failed to delete remote branch: ${remoteName}/${sanitized}`, {
1326
+ command: `git push ${remoteName} --delete ${sanitized}`,
1327
+ cause: error
1328
+ });
1329
+ }
1330
+ };
1331
+
1332
+ /**
1333
+ * Gets the commit divergence between two refs
1334
+ * Why: Determines how far ahead/behind a branch is for automated workflow decisions
1335
+ *
1336
+ * @param {string} refA - First ref (e.g., local branch)
1337
+ * @param {string} refB - Second ref (e.g., 'origin/main')
1338
+ * @param {Object} options
1339
+ * @param {string} options.repoPath - Path to repository root (default: cwd)
1340
+ * @returns {{ ahead: number, behind: number }} Commits in refA not in refB (ahead) and vice versa (behind)
1341
+ */
1342
+ const getDivergence = (refA, refB, { repoPath } = {}) => {
1343
+ logger.debug('git-operations - getDivergence', 'Getting divergence', { refA, refB });
1344
+
1345
+ const sanitizedA = sanitizeBranchName(refA);
1346
+ const sanitizedB = sanitizeBranchName(refB);
1347
+ if (!sanitizedA || !sanitizedB) {
1348
+ throw new GitError('Invalid ref', {
1349
+ command: 'getDivergence',
1350
+ output: `refA: ${refA}, refB: ${refB}`
1351
+ });
1352
+ }
1353
+
1354
+ const opts = repoPath ? { cwd: repoPath } : {};
1355
+ try {
1356
+ const aheadOutput = execGitCommand(`rev-list --count ${sanitizedB}..${sanitizedA}`, opts);
1357
+ const behindOutput = execGitCommand(`rev-list --count ${sanitizedA}..${sanitizedB}`, opts);
1358
+ const result = {
1359
+ ahead: parseInt(aheadOutput, 10) || 0,
1360
+ behind: parseInt(behindOutput, 10) || 0
1361
+ };
1362
+ logger.debug('git-operations - getDivergence', 'Divergence calculated', { ...result });
1363
+ return result;
1364
+ } catch (error) {
1365
+ logger.error('git-operations - getDivergence', 'Failed to get divergence', error);
1366
+ throw new GitError('Failed to get divergence', {
1367
+ command: `git rev-list --count ${sanitizedA}..${sanitizedB}`,
1368
+ cause: error
1369
+ });
1370
+ }
1371
+ };
1372
+
1373
+ /**
1374
+ * Reads a file from a specific ref without checking out
1375
+ * Why: Allows reading file content from another branch or commit without switching branches
1376
+ *
1377
+ * @param {string} ref - Git ref (branch name, tag, or commit SHA)
1378
+ * @param {string} filePath - Path to file within the repository
1379
+ * @param {Object} options
1380
+ * @param {string} options.repoPath - Path to repository root (default: cwd)
1381
+ * @returns {string} File content at the specified ref
1382
+ */
1383
+ const readFileFromRef = (ref, filePath, { repoPath } = {}) => {
1384
+ logger.debug('git-operations - readFileFromRef', 'Reading file from ref', { ref, filePath });
1385
+
1386
+ const sanitizedRef = sanitizeBranchName(ref);
1387
+ if (!sanitizedRef) {
1388
+ throw new GitError('Invalid ref', { command: 'readFileFromRef', output: ref });
1389
+ }
1390
+
1391
+ if (!filePath || typeof filePath !== 'string' || filePath.trim().length === 0) {
1392
+ throw new GitError('Invalid file path', {
1393
+ command: 'readFileFromRef',
1394
+ output: String(filePath)
1395
+ });
1396
+ }
1397
+
1398
+ const opts = repoPath ? { cwd: repoPath } : {};
1399
+ return execGitCommand(`show "${sanitizedRef}:${filePath}"`, opts);
1400
+ };
1401
+
1402
+ /**
1403
+ * Gets the latest tag matching a pattern
1404
+ * Why: Used by workflow commands to find the current release version
1405
+ *
1406
+ * @param {string} [pattern='v*'] - Glob pattern to match tags (e.g., 'v*', 'release-*')
1407
+ * @param {Object} options
1408
+ * @param {string} options.repoPath - Path to repository root (default: cwd)
1409
+ * @returns {string|null} Latest tag name, or null if no matching tags found
1410
+ */
1411
+ const getLatestTag = (pattern = 'v*', { repoPath } = {}) => {
1412
+ logger.debug('git-operations - getLatestTag', 'Getting latest tag', { pattern });
1413
+
1414
+ const opts = repoPath ? { cwd: repoPath } : {};
1415
+ try {
1416
+ const matchFlag = pattern ? `--match "${pattern}"` : '';
1417
+ const tag = execGitCommand(`describe --tags --abbrev=0 ${matchFlag}`.trim(), opts);
1418
+ logger.debug('git-operations - getLatestTag', 'Latest tag found', { tag });
1419
+ return tag;
1420
+ } catch (error) {
1421
+ logger.debug('git-operations - getLatestTag', 'No matching tag found', { pattern });
1422
+ return null;
1423
+ }
1424
+ };
1425
+
1426
+ /**
1427
+ * Checks if the working directory has no uncommitted changes
1428
+ * Why: Workflow commands need a clean working directory before proceeding with destructive operations
1429
+ *
1430
+ * @param {Object} options
1431
+ * @param {string} options.repoPath - Path to repository root (default: cwd)
1432
+ * @returns {boolean} True if working directory is clean (no uncommitted changes)
1433
+ */
1434
+ const isWorkingDirectoryClean = ({ repoPath } = {}) => {
1435
+ logger.debug('git-operations - isWorkingDirectoryClean', 'Checking working directory status');
1436
+
1437
+ const opts = repoPath ? { cwd: repoPath } : {};
1438
+ try {
1439
+ const output = execGitCommand('status --porcelain', opts);
1440
+ const isClean = output.length === 0;
1441
+ logger.debug('git-operations - isWorkingDirectoryClean', 'Working directory status', {
1442
+ isClean
1443
+ });
1444
+ return isClean;
1445
+ } catch (error) {
1446
+ logger.error(
1447
+ 'git-operations - isWorkingDirectoryClean',
1448
+ 'Failed to check working directory status',
1449
+ error
1450
+ );
1451
+ return false;
1452
+ }
1453
+ };
1454
+
1455
+ /**
1456
+ * Finds the most recently updated remote branch matching a prefix
1457
+ * Why: Used to locate the active release or feature branch for a given workflow stage
1458
+ *
1459
+ * @param {string} prefix - Branch prefix to match (e.g., 'release', 'feature/IX-123')
1460
+ * @param {Object} options
1461
+ * @param {string} options.repoPath - Path to repository root (default: cwd)
1462
+ * @returns {string|null} Most recently updated matching branch name (without remote prefix), or null
1463
+ */
1464
+ const getActiveBranch = (prefix, { repoPath } = {}) => {
1465
+ logger.debug('git-operations - getActiveBranch', 'Finding active branch', { prefix });
1466
+
1467
+ const sanitizedPrefix = sanitizeBranchName(prefix);
1468
+ if (!sanitizedPrefix) {
1469
+ throw new GitError('Invalid branch prefix', { command: 'getActiveBranch', output: prefix });
1470
+ }
1471
+
1472
+ const remoteName = getRemoteName();
1473
+ const opts = repoPath ? { cwd: repoPath } : {};
1474
+
1475
+ try {
1476
+ const output = execGitCommand(
1477
+ `branch -r --list "${remoteName}/${sanitizedPrefix}/*" --sort=-committerdate`,
1478
+ opts
1479
+ );
1480
+
1481
+ if (!output) {
1482
+ logger.debug('git-operations - getActiveBranch', 'No matching branches found', {
1483
+ prefix: sanitizedPrefix
1484
+ });
1485
+ return null;
1486
+ }
1487
+
1488
+ const branches = output
1489
+ .split(/\r?\n/)
1490
+ .map((line) => line.trim())
1491
+ .filter((line) => line && !line.includes('->'))
1492
+ .map((line) => line.replace(`${remoteName}/`, ''));
1493
+
1494
+ const result = branches.length > 0 ? branches[0] : null;
1495
+ logger.debug('git-operations - getActiveBranch', 'Active branch resolved', { result });
1496
+ return result;
1497
+ } catch (error) {
1498
+ logger.error('git-operations - getActiveBranch', 'Failed to get active branch', error);
1499
+ return null;
1500
+ }
1501
+ };
1502
+
1503
+ /**
1504
+ * Gets the list of files changed in a specific commit
1505
+ * Why: Used by revert-feature to identify files touched by a commit for coupling detection
1506
+ *
1507
+ * @param {string} hash - Commit hash (short or full SHA)
1508
+ * @param {Object} options
1509
+ * @param {string} options.repoPath - Path to repository root (default: cwd)
1510
+ * @returns {string[]} Array of file paths changed in the commit
1511
+ */
1512
+ const getCommitFiles = (hash, { repoPath } = {}) => {
1513
+ logger.debug('git-operations - getCommitFiles', 'Getting files for commit', { hash });
1514
+
1515
+ const sanitizedHash = sanitizeBranchName(hash);
1516
+ if (!sanitizedHash) {
1517
+ throw new GitError('Invalid commit hash', { command: 'getCommitFiles', output: hash });
1518
+ }
1519
+
1520
+ const opts = repoPath ? { cwd: repoPath } : {};
1521
+ try {
1522
+ const output = execGitCommand(
1523
+ `diff-tree --no-commit-id -r --name-only ${sanitizedHash}`,
1524
+ opts
1525
+ );
1526
+
1527
+ if (!output) {
1528
+ logger.debug('git-operations - getCommitFiles', 'No files found for commit', { hash });
1529
+ return [];
1530
+ }
1531
+
1532
+ const files = output.split(/\r?\n/).filter((f) => f.length > 0);
1533
+
1534
+ logger.debug('git-operations - getCommitFiles', 'Files retrieved', {
1535
+ hash,
1536
+ fileCount: files.length
1537
+ });
1538
+
1539
+ return files;
1540
+ } catch (error) {
1541
+ logger.error('git-operations - getCommitFiles', 'Failed to get commit files', error);
1542
+ throw new GitError(`Failed to get files for commit: ${sanitizedHash}`, {
1543
+ command: `git diff-tree --no-commit-id -r --name-only ${sanitizedHash}`,
1544
+ cause: error
1545
+ });
1546
+ }
1547
+ };
1548
+
1081
1549
  export {
1082
1550
  GitError,
1083
1551
  getStagedFiles,
@@ -1102,5 +1570,16 @@ export {
1102
1570
  resolveBaseBranch,
1103
1571
  getChangedFilesBetweenRefs,
1104
1572
  getDiffBetweenRefs,
1105
- getCommitsBetweenRefs
1573
+ getCommitsBetweenRefs,
1574
+ checkoutBranch,
1575
+ mergeBranch,
1576
+ resetBranch,
1577
+ forcePush,
1578
+ deleteRemoteBranch,
1579
+ getDivergence,
1580
+ readFileFromRef,
1581
+ getLatestTag,
1582
+ isWorkingDirectoryClean,
1583
+ getActiveBranch,
1584
+ getCommitFiles
1106
1585
  };