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.
- package/CHANGELOG.md +155 -5
- package/CLAUDE.md +495 -63
- package/README.md +53 -5
- package/bin/claude-hooks +90 -0
- package/lib/cli-metadata.js +89 -2
- package/lib/commands/analyze-pr.js +678 -0
- package/lib/commands/back-merge.js +740 -0
- package/lib/commands/check-coupling.js +209 -0
- package/lib/commands/close-release.js +485 -0
- package/lib/commands/create-pr.js +62 -3
- package/lib/commands/create-release.js +600 -0
- package/lib/commands/diff-batch-info.js +7 -13
- package/lib/commands/help.js +72 -42
- package/lib/commands/install.js +1 -5
- package/lib/commands/revert-feature.js +436 -0
- package/lib/commands/setup-linear.js +96 -0
- package/lib/commands/shadow.js +654 -0
- package/lib/config.js +16 -2
- package/lib/hooks/pre-commit.js +8 -6
- package/lib/utils/authorization.js +429 -0
- package/lib/utils/claude-client.js +16 -5
- package/lib/utils/coupling-detector.js +133 -0
- package/lib/utils/diff-analysis-orchestrator.js +7 -14
- package/lib/utils/git-operations.js +480 -1
- package/lib/utils/github-api.js +358 -112
- package/lib/utils/judge.js +67 -8
- package/lib/utils/linear-connector.js +284 -0
- package/lib/utils/package-info.js +0 -1
- package/lib/utils/pr-statistics.js +85 -0
- package/lib/utils/token-store.js +161 -0
- package/package.json +69 -69
- package/templates/ANALYZE_PR.md +79 -0
- package/templates/config.advanced.example.json +44 -3
- package/templates/settings.local.example.json +2 -1
|
@@ -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
|
};
|