git-coco 0.31.1 → 0.32.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/dist/index.js CHANGED
@@ -18,6 +18,7 @@ var anthropic = require('@langchain/anthropic');
18
18
  var ollama = require('@langchain/ollama');
19
19
  var openai = require('@langchain/openai');
20
20
  var output_parsers = require('@langchain/core/output_parsers');
21
+ var minimatch = require('minimatch');
21
22
  var simpleGit = require('simple-git');
22
23
  var documents = require('@langchain/core/documents');
23
24
  var diff = require('diff');
@@ -34,7 +35,6 @@ require('@langchain/core/utils/json_schema');
34
35
  require('@langchain/core/utils/json_patch');
35
36
  require('@langchain/core/utils/env');
36
37
  require('@langchain/core/utils/async_caller');
37
- var minimatch = require('minimatch');
38
38
  var tiktoken = require('tiktoken');
39
39
  var child_process = require('child_process');
40
40
  var readline = require('readline');
@@ -68,7 +68,7 @@ var readline__namespace = /*#__PURE__*/_interopNamespaceDefault(readline);
68
68
  /**
69
69
  * Current build version from package.json
70
70
  */
71
- const BUILD_VERSION = "0.31.1";
71
+ const BUILD_VERSION = "0.32.0";
72
72
 
73
73
  const isInteractive = (config) => {
74
74
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -468,7 +468,7 @@ function getDefaultServiceConfigFromAlias(provider, model) {
468
468
  }
469
469
  }
470
470
 
471
- const DEFAULT_IGNORED_FILES = [
471
+ const DEFAULT_IGNORED_FILES$1 = [
472
472
  'package-lock.json',
473
473
  'yarn.lock',
474
474
  'pnpm-lock.yaml',
@@ -476,7 +476,7 @@ const DEFAULT_IGNORED_FILES = [
476
476
  'bun.lock',
477
477
  'node_modules',
478
478
  ];
479
- const DEFAULT_IGNORED_EXTENSIONS = ['.map', '.lock'];
479
+ const DEFAULT_IGNORED_EXTENSIONS$1 = ['.map', '.lock'];
480
480
  const COCO_CONFIG_START_COMMENT = '# -- start coco config --';
481
481
  const COCO_CONFIG_END_COMMENT = '# -- end coco config --';
482
482
  /**
@@ -490,8 +490,8 @@ const DEFAULT_CONFIG = {
490
490
  defaultBranch: 'main',
491
491
  service: getDefaultServiceConfigFromAlias('openai'),
492
492
  summarizePrompt: SUMMARIZE_PROMPT.template,
493
- ignoredFiles: DEFAULT_IGNORED_FILES,
494
- ignoredExtensions: DEFAULT_IGNORED_EXTENSIONS,
493
+ ignoredFiles: DEFAULT_IGNORED_FILES$1,
494
+ ignoredExtensions: DEFAULT_IGNORED_EXTENSIONS$1,
495
495
  };
496
496
  /**
497
497
  * Create a named export of all config keys for use in other modules.
@@ -7291,6 +7291,75 @@ function createSchemaParser(schema, llm, options = {}
7291
7291
  }
7292
7292
  }
7293
7293
 
7294
+ async function renderPrompt(prompt, variables) {
7295
+ if (typeof prompt.format === 'function') {
7296
+ return await prompt.format(variables);
7297
+ }
7298
+ if (typeof prompt.template === 'string') {
7299
+ return Object.entries(variables).reduce((result, [key, value]) => {
7300
+ return result
7301
+ .replaceAll(`{{${key}}}`, value)
7302
+ .replaceAll(`{${key}}`, value);
7303
+ }, prompt.template);
7304
+ }
7305
+ throw new Error('Prompt must provide either a format function or template string');
7306
+ }
7307
+ /**
7308
+ * Ensure the fully rendered LLM prompt fits the configured request budget.
7309
+ *
7310
+ * Diff condensation budgets only cover the diff summary itself. This guard accounts
7311
+ * for the rest of the rendered prompt, then trims the summary as a deterministic
7312
+ * fallback when additional context pushes the request over budget.
7313
+ */
7314
+ async function enforcePromptBudget({ prompt, variables, tokenizer, maxTokens, summaryKey = 'summary', responseTokenReserve = 512, }) {
7315
+ const renderedPrompt = await renderPrompt(prompt, variables);
7316
+ const promptTokenCount = tokenizer(renderedPrompt);
7317
+ if (promptTokenCount <= maxTokens) {
7318
+ return { variables, promptTokenCount, truncated: false };
7319
+ }
7320
+ const summary = variables[summaryKey] || '';
7321
+ const variablesWithoutSummary = { ...variables, [summaryKey]: '' };
7322
+ const overheadTokenCount = tokenizer(await renderPrompt(prompt, variablesWithoutSummary));
7323
+ const summaryBudget = Math.max(0, maxTokens - overheadTokenCount - responseTokenReserve);
7324
+ if (summaryBudget === 0) {
7325
+ const emptySummaryVariables = { ...variables, [summaryKey]: '' };
7326
+ const emptySummaryTokenCount = tokenizer(await renderPrompt(prompt, emptySummaryVariables));
7327
+ if (emptySummaryTokenCount > maxTokens) {
7328
+ throw new Error(`Rendered prompt exceeds token budget before adding ${summaryKey}: ` +
7329
+ `${emptySummaryTokenCount} > ${maxTokens}`);
7330
+ }
7331
+ return {
7332
+ variables: emptySummaryVariables,
7333
+ promptTokenCount: emptySummaryTokenCount,
7334
+ truncated: true,
7335
+ };
7336
+ }
7337
+ let low = 0;
7338
+ let high = summary.length;
7339
+ let bestSummary = '';
7340
+ let bestTokenCount = overheadTokenCount;
7341
+ while (low <= high) {
7342
+ const mid = Math.floor((low + high) / 2);
7343
+ const candidateSummary = summary.slice(0, mid);
7344
+ const candidateVariables = { ...variables, [summaryKey]: candidateSummary };
7345
+ const candidateTokenCount = tokenizer(await renderPrompt(prompt, candidateVariables));
7346
+ if (candidateTokenCount <= maxTokens - responseTokenReserve) {
7347
+ bestSummary = candidateSummary;
7348
+ bestTokenCount = candidateTokenCount;
7349
+ low = mid + 1;
7350
+ }
7351
+ else {
7352
+ high = mid - 1;
7353
+ }
7354
+ }
7355
+ const trimmedVariables = { ...variables, [summaryKey]: bestSummary.trimEnd() };
7356
+ return {
7357
+ variables: trimmedVariables,
7358
+ promptTokenCount: bestTokenCount,
7359
+ truncated: true,
7360
+ };
7361
+ }
7362
+
7294
7363
  /**
7295
7364
  * Extracts provider and endpoint info from LLM instance if available
7296
7365
  */
@@ -7580,6 +7649,148 @@ async function getCommitLogCurrentBranch({ git, logger, comparisonBranch = 'main
7580
7649
  return [];
7581
7650
  }
7582
7651
 
7652
+ /**
7653
+ * Determines the status of a file based on its changes in the Git repository.
7654
+ *
7655
+ * @param file - The file to check the status of.
7656
+ * @param location - The location to check the status in ('index' or 'working_dir'). Defaults to 'index'.
7657
+ * @returns The status of the file ('added', 'deleted', 'modified', 'renamed', 'untracked', or 'unknown').
7658
+ * @throws Error if the file type is invalid.
7659
+ */
7660
+ function getStatus(file, location = 'index') {
7661
+ if ('index' in file && 'working_dir' in file) {
7662
+ const statusCode = file[location];
7663
+ switch (statusCode) {
7664
+ case 'A':
7665
+ return 'added';
7666
+ case 'D':
7667
+ return 'deleted';
7668
+ case 'M':
7669
+ return 'modified';
7670
+ case 'R':
7671
+ return 'renamed';
7672
+ case '?':
7673
+ return 'untracked';
7674
+ default:
7675
+ return 'unknown';
7676
+ }
7677
+ }
7678
+ else if ('binary' in file && file.binary === true) {
7679
+ // DiffResultBinaryFile: has before/after, no changes/insertions/deletions
7680
+ if (file.file.includes('=>'))
7681
+ return 'renamed';
7682
+ if (file.before === 0 && file.after > 0)
7683
+ return 'added';
7684
+ if (file.after === 0 && file.before > 0)
7685
+ return 'deleted';
7686
+ if (file.before > 0 && file.after > 0)
7687
+ return 'modified';
7688
+ return 'untracked';
7689
+ }
7690
+ else if ('changes' in file && 'binary' in file) {
7691
+ // DiffResultTextFile: has changes/insertions/deletions
7692
+ if (file.changes === 0)
7693
+ return 'untracked';
7694
+ if (file.file.includes('=>'))
7695
+ return 'renamed';
7696
+ if (file.deletions === 0 && file.insertions > 0)
7697
+ return 'added';
7698
+ if (file.insertions === 0 && file.deletions > 0)
7699
+ return 'deleted';
7700
+ if ((file.insertions > 0 && file.deletions > 0) || file.changes > 0)
7701
+ return 'modified';
7702
+ return 'unknown';
7703
+ }
7704
+ else {
7705
+ throw new Error('Invalid file type');
7706
+ }
7707
+ }
7708
+
7709
+ /**
7710
+ * Returns the summary text for a file change.
7711
+ *
7712
+ * @param file - The file status or diff result.
7713
+ * @param change - The partial file change object.
7714
+ * @returns The summary text for the file change.
7715
+ * @throws Error if the file type is invalid.
7716
+ */
7717
+ function getSummaryText(file, change) {
7718
+ const status = change.status || getStatus(file);
7719
+ let filePath;
7720
+ if ('path' in file) {
7721
+ filePath = file.path;
7722
+ }
7723
+ else if ('file' in file) {
7724
+ filePath = change?.filePath || file.file;
7725
+ }
7726
+ else {
7727
+ throw new Error('Invalid file type');
7728
+ }
7729
+ if (change.oldFilePath) {
7730
+ return `${status}: ${change.oldFilePath} -> ${filePath}`;
7731
+ }
7732
+ return `${status}: ${filePath}`;
7733
+ }
7734
+
7735
+ /**
7736
+ * Parses a file string and returns the parsed file paths.
7737
+ * If the file string contains a separator, it splits the string into root path, file path, and old file path.
7738
+ * If the file string doesn't contain the separator, it assumes the file string itself is the file path and old file path is undefined.
7739
+ * @param file The file string to parse.
7740
+ * @returns The parsed file paths.
7741
+ */
7742
+ function parseFileString(file) {
7743
+ const separator = ' => ';
7744
+ if (file.includes(separator)) {
7745
+ const [oldFilePathWithRoot, filePath] = file.split(separator);
7746
+ const [rootPath, oldFilePath] = oldFilePathWithRoot.split('{');
7747
+ return {
7748
+ filePath: rootPath + filePath.trim().replace('{', '').replace('}', ''),
7749
+ oldFilePath: rootPath + oldFilePath.trim().replace('{', '').replace('}', ''),
7750
+ };
7751
+ }
7752
+ else {
7753
+ return {
7754
+ filePath: file.trim(),
7755
+ oldFilePath: undefined,
7756
+ };
7757
+ }
7758
+ }
7759
+
7760
+ const config = loadConfig();
7761
+ const DEFAULT_IGNORED_FILES = config?.ignoredFiles?.length ? config.ignoredFiles : [];
7762
+ const DEFAULT_IGNORED_EXTENSIONS = config?.ignoredExtensions?.length ? config.ignoredExtensions : [];
7763
+ /**
7764
+ * Retrieves the changes made in a commit.
7765
+ *
7766
+ * @deprecated use `getChanges` instead
7767
+ *
7768
+ * @param commit - The commit hash.
7769
+ * @param options - Optional parameters for customization.
7770
+ * @returns A promise that resolves to an array of FileChange objects representing the changes made in the commit.
7771
+ */
7772
+ async function getChangesByCommit({ commit, options: { git, ignoredFiles = DEFAULT_IGNORED_FILES, ignoredExtensions = DEFAULT_IGNORED_EXTENSIONS, }, }) {
7773
+ const changes = [];
7774
+ const diffSummary = await git.diffSummary([`${commit}^..${commit}`]);
7775
+ diffSummary.files.forEach((file) => {
7776
+ const { filePath, oldFilePath } = parseFileString(file.file);
7777
+ const fileChange = {
7778
+ filePath,
7779
+ oldFilePath,
7780
+ status: getStatus(file),
7781
+ };
7782
+ fileChange.summary = getSummaryText(file, fileChange);
7783
+ changes.push(fileChange);
7784
+ });
7785
+ const ignoredExtensionsSet = new Set(ignoredExtensions.map((extension) => extension.toLowerCase()));
7786
+ const filteredChanges = changes.filter((file) => {
7787
+ const extension = path.extname(file.filePath).toLowerCase();
7788
+ return (!ignoredExtensionsSet.has(extension) &&
7789
+ !ignoredFiles.some((ignoredPattern) => minimatch.minimatch(file.filePath, ignoredPattern)));
7790
+ });
7791
+ return filteredChanges;
7792
+ }
7793
+
7583
7794
  /**
7584
7795
  * Retrieves the SimpleGit instance for the repository.
7585
7796
  * @returns {SimpleGit} The SimpleGit instance.
@@ -7940,104 +8151,6 @@ async function handleResult({ result, mode, interactiveModeCallback }) {
7940
8151
  }
7941
8152
  }
7942
8153
 
7943
- /**
7944
- * Fetches the diff for the given commit ID.
7945
- *
7946
- * @param commitId The commit ID for which the diff is to be retrieved.
7947
- * @returns A promise that resolves to the diff of the commit.
7948
- */
7949
- async function getDiffForCommit(commitId, { git, }) {
7950
- try {
7951
- return await git.diff(['-p', `${commitId}^..${commitId}`]);
7952
- }
7953
- catch (error) {
7954
- throw new Error(`Error fetching diff for commit ${commitId}: ${error.message}`);
7955
- }
7956
- }
7957
-
7958
- /**
7959
- * Determines the status of a file based on its changes in the Git repository.
7960
- *
7961
- * @param file - The file to check the status of.
7962
- * @param location - The location to check the status in ('index' or 'working_dir'). Defaults to 'index'.
7963
- * @returns The status of the file ('added', 'deleted', 'modified', 'renamed', 'untracked', or 'unknown').
7964
- * @throws Error if the file type is invalid.
7965
- */
7966
- function getStatus(file, location = 'index') {
7967
- if ('index' in file && 'working_dir' in file) {
7968
- const statusCode = file[location];
7969
- switch (statusCode) {
7970
- case 'A':
7971
- return 'added';
7972
- case 'D':
7973
- return 'deleted';
7974
- case 'M':
7975
- return 'modified';
7976
- case 'R':
7977
- return 'renamed';
7978
- case '?':
7979
- return 'untracked';
7980
- default:
7981
- return 'unknown';
7982
- }
7983
- }
7984
- else if ('binary' in file && file.binary === true) {
7985
- // DiffResultBinaryFile: has before/after, no changes/insertions/deletions
7986
- if (file.file.includes('=>'))
7987
- return 'renamed';
7988
- if (file.before === 0 && file.after > 0)
7989
- return 'added';
7990
- if (file.after === 0 && file.before > 0)
7991
- return 'deleted';
7992
- if (file.before > 0 && file.after > 0)
7993
- return 'modified';
7994
- return 'untracked';
7995
- }
7996
- else if ('changes' in file && 'binary' in file) {
7997
- // DiffResultTextFile: has changes/insertions/deletions
7998
- if (file.changes === 0)
7999
- return 'untracked';
8000
- if (file.file.includes('=>'))
8001
- return 'renamed';
8002
- if (file.deletions === 0 && file.insertions > 0)
8003
- return 'added';
8004
- if (file.insertions === 0 && file.deletions > 0)
8005
- return 'deleted';
8006
- if ((file.insertions > 0 && file.deletions > 0) || file.changes > 0)
8007
- return 'modified';
8008
- return 'unknown';
8009
- }
8010
- else {
8011
- throw new Error('Invalid file type');
8012
- }
8013
- }
8014
-
8015
- /**
8016
- * Returns the summary text for a file change.
8017
- *
8018
- * @param file - The file status or diff result.
8019
- * @param change - The partial file change object.
8020
- * @returns The summary text for the file change.
8021
- * @throws Error if the file type is invalid.
8022
- */
8023
- function getSummaryText(file, change) {
8024
- const status = change.status || getStatus(file);
8025
- let filePath;
8026
- if ('path' in file) {
8027
- filePath = file.path;
8028
- }
8029
- else if ('file' in file) {
8030
- filePath = change?.filePath || file.file;
8031
- }
8032
- else {
8033
- throw new Error('Invalid file type');
8034
- }
8035
- if (change.oldFilePath) {
8036
- return `${status}: ${change.oldFilePath} -> ${filePath}`;
8037
- }
8038
- return `${status}: ${filePath}`;
8039
- }
8040
-
8041
8154
  /**
8042
8155
  * Retrieves the diff between the current branch and a specified target branch.
8043
8156
  *
@@ -8118,1050 +8231,571 @@ async function getDiffForBranch({ git, logger, baseBranch, headBranch, options,
8118
8231
  }
8119
8232
  }
8120
8233
 
8121
- const template$3 = `You are a highly skilled software engineer tasked with writing a git changelog. Your response should be informative, well-structured, and in the imperative.
8234
+ /**
8235
+ * Extract the path from a file path string.
8236
+ * @param {string} filePath - The full file path.
8237
+ * @returns {string} The path portion of the file path.
8238
+ */
8239
+ function getPathFromFilePath(filePath) {
8240
+ return filePath.split('/').slice(0, -1).join('/');
8241
+ }
8122
8242
 
8123
- ## Input
8124
- You will be provided with a summary of changes. This summary can be one of the following:
8125
- 1. A list of commits, each with its author, hash, message, and body.
8126
- 2. A list of commits, each with its details AND the full diff of the changes.
8127
- 3. A single, comprehensive diff for an entire branch.
8243
+ async function summarize(documents$1, { chain, textSplitter, options }) {
8244
+ const { returnIntermediateSteps = false } = options || {};
8245
+ const docs = await textSplitter.splitDocuments(documents$1.map((doc) => new documents.Document(doc)));
8246
+ const res = await chain.invoke({
8247
+ input_documents: docs,
8248
+ returnIntermediateSteps,
8249
+ });
8250
+ if (res.error)
8251
+ throw new Error(res.error);
8252
+ return res.text && res.text.trim();
8253
+ }
8128
8254
 
8129
- ## Rules
8130
- - Create a descriptive title for the changelog that gives a high-level overview of the changes.
8131
- - **BREAKING CHANGES**: Identify any commits that introduce breaking changes. These must be listed first under a "### 💥 BREAKING CHANGES" heading.
8132
- - **Grouping**: Logically group related changes under descriptive headings (e.g., ### Features, ### Fixes, ### Refactors).
8133
- - **Dependencies**: Group all dependency updates (e.g., changes to package.json, go.mod) under a "### Dependencies" section.
8134
- - **Summaries**: For each change, provide a concise summary.
8135
- - **Attribution**: {{author_instructions}}
8136
- - **Technical Details**: If provided with diffs, use them to understand the technical details and provide a more accurate and detailed description of the changes.
8137
- - **Clarity**: Avoid generalizations like "various bug fixes," "improvements," or "enhancements." Be specific.
8138
- - **Formatting**: Your entire response must be valid Markdown.
8139
-
8140
- ## Formatting Instructions
8141
- {{format_instructions}}
8142
-
8143
- {{additional_context}}
8144
-
8145
- """{{summary}}"""`;
8146
- const inputVariables$2 = [
8147
- 'format_instructions',
8148
- 'summary',
8149
- 'additional_context',
8150
- 'author_instructions',
8151
- ];
8152
- const CHANGELOG_PROMPT = new prompts$1.PromptTemplate({
8153
- template: template$3,
8154
- inputVariables: inputVariables$2,
8155
- });
8156
-
8157
- const handler$4 = async (argv, logger) => {
8158
- const config = loadConfig(argv);
8159
- const git = getRepo();
8160
- const key = getApiKeyForModel(config);
8161
- const { provider, model } = getModelAndProviderFromConfig(config);
8162
- const exclusiveOptions = [
8163
- argv.branch ? '--branch' : null,
8164
- argv.tag ? '--tag' : null,
8165
- config.sinceLastTag ? '--since-last-tag' : null,
8166
- ].filter(Boolean);
8167
- if (exclusiveOptions.length > 1) {
8168
- logger.log(`Options ${exclusiveOptions.join(', ')} cannot be used together.`, { color: 'red' });
8169
- process.exit(1);
8255
+ /**
8256
+ * Summarize a single file diff that exceeds the token threshold.
8257
+ */
8258
+ async function summarizeFileDiff(fileDiff, { chain, textSplitter, tokenizer }) {
8259
+ try {
8260
+ const fileSummary = await summarize([
8261
+ {
8262
+ pageContent: fileDiff.diff,
8263
+ metadata: {
8264
+ file: fileDiff.file,
8265
+ summary: fileDiff.summary,
8266
+ },
8267
+ },
8268
+ ], {
8269
+ chain,
8270
+ textSplitter,
8271
+ options: {
8272
+ returnIntermediateSteps: false,
8273
+ },
8274
+ });
8275
+ const newTokenCount = tokenizer(fileSummary);
8276
+ return {
8277
+ ...fileDiff,
8278
+ diff: fileSummary,
8279
+ tokenCount: newTokenCount,
8280
+ };
8170
8281
  }
8171
- if (config.service.authentication.type !== 'None' && !key) {
8172
- logger.log(`No API Key found. 🗝️🚪`, { color: 'red' });
8173
- process.exit(1);
8282
+ catch (error) {
8283
+ // On error, return original diff unchanged
8284
+ console.error(`Failed to summarize file ${fileDiff.file}:`, error);
8285
+ return fileDiff;
8174
8286
  }
8175
- const llm = getLlm(provider, model, config);
8176
- const INTERACTIVE = isInteractive(config);
8177
- if (INTERACTIVE) {
8178
- if (!config.hideCocoBanner) {
8179
- logger.log(LOGO);
8180
- }
8287
+ }
8288
+ /**
8289
+ * Process files in waves to respect concurrency limits.
8290
+ */
8291
+ async function processInWaves$1(items, processor, maxConcurrent) {
8292
+ const results = [];
8293
+ for (let i = 0; i < items.length; i += maxConcurrent) {
8294
+ const wave = items.slice(i, i + maxConcurrent);
8295
+ const waveResults = await Promise.all(wave.map(processor));
8296
+ results.push(...waveResults);
8181
8297
  }
8182
- async function factory() {
8183
- const branchName = await getCurrentBranchName({ git });
8184
- if (argv.onlyDiff) {
8185
- logger.verbose(`Generating changelog based on branch diff`, { color: 'yellow' });
8186
- const diff = await getDiffForBranch({ git, logger, baseBranch: argv.branch || 'main', headBranch: branchName });
8187
- return {
8188
- branch: branchName,
8189
- diff: JSON.stringify(diff.staged, null, 2),
8190
- };
8191
- }
8192
- let commits = [];
8193
- if (config.sinceLastTag) {
8194
- logger.verbose(`Generating commit log since the last tag`, { color: 'yellow' });
8195
- // This function returns string[], needs to be adapted or replaced
8196
- // For now, this path will have limited details.
8197
- const commitMessages = await getChangesSinceLastTag({ git});
8198
- commits = commitMessages.map(msg => ({ message: msg }));
8199
- }
8200
- else if (config.range && config.range.includes(':')) {
8201
- const [from, to] = config.range.split(':');
8202
- if (!from || !to) {
8203
- logger.log(`Invalid range provided. Expected format is <from>:<to>`, { color: 'red' });
8204
- process.exit(1);
8205
- }
8206
- commits = await getCommitLogRangeDetails(from, to, { git, noMerges: true });
8207
- }
8208
- else if (argv.branch) {
8209
- logger.verbose(`Generating commit log against branch: ${argv.branch}`, { color: 'yellow' });
8210
- commits = await getCommitLogAgainstBranch({ git, logger, targetBranch: argv.branch });
8211
- }
8212
- else if (argv.tag) {
8213
- logger.verbose(`Generating commit log against tag: ${argv.tag}`, { color: 'yellow' });
8214
- commits = await getCommitLogAgainstTag({ git, logger, targetTag: argv.tag });
8215
- }
8216
- else {
8217
- logger.verbose(`No range, branch, or tag option provided. Defaulting to current branch`, {
8218
- color: 'yellow',
8219
- });
8220
- commits = await getCommitLogCurrentBranch({ git, logger });
8221
- }
8222
- let commitsWithDiffText = commits;
8223
- if (argv.withDiff) {
8224
- commitsWithDiffText = await Promise.all(commits.map(async (commit) => ({
8225
- ...commit,
8226
- diffText: await getDiffForCommit(commit.hash, { git }),
8227
- })));
8298
+ return results;
8299
+ }
8300
+ /**
8301
+ * Pre-summarize individual files that exceed the maxFileTokens threshold.
8302
+ * This prevents large files from dominating the token budget and biasing
8303
+ * the final commit message toward a single file's changes.
8304
+ *
8305
+ * @param diffs - Array of file diffs to process
8306
+ * @param options - Configuration options for summarization
8307
+ * @returns Array of file diffs with large files summarized
8308
+ */
8309
+ async function summarizeLargeFiles(diffs, options) {
8310
+ const { maxFileTokens, minTokensForSummary, maxConcurrent, tokenizer, logger, chain, textSplitter } = options;
8311
+ // Identify files that need summarization
8312
+ const filesToSummarize = [];
8313
+ const results = [...diffs];
8314
+ diffs.forEach((diff, index) => {
8315
+ if (diff.tokenCount > maxFileTokens && diff.tokenCount >= minTokensForSummary) {
8316
+ filesToSummarize.push({ index, diff });
8228
8317
  }
8318
+ });
8319
+ if (filesToSummarize.length === 0) {
8320
+ return results;
8321
+ }
8322
+ logger.verbose(`Pre-summarizing ${filesToSummarize.length} large file(s)...`, { color: 'blue' });
8323
+ // Process large files in waves
8324
+ const summarizedFiles = await processInWaves$1(filesToSummarize, async ({ diff }) => summarizeFileDiff(diff, { chain, textSplitter, tokenizer }), maxConcurrent);
8325
+ // Update results with summarized files
8326
+ summarizedFiles.forEach((summarizedDiff, i) => {
8327
+ const originalIndex = filesToSummarize[i].index;
8328
+ const originalTokens = results[originalIndex].tokenCount;
8329
+ const newTokens = summarizedDiff.tokenCount;
8330
+ logger.verbose(` - ${summarizedDiff.file}: ${originalTokens} -> ${newTokens} tokens`, { color: 'magenta' });
8331
+ results[originalIndex] = summarizedDiff;
8332
+ });
8333
+ return results;
8334
+ }
8335
+ /**
8336
+ * Pre-process a DiffNode tree, summarizing large files at the leaf level.
8337
+ * Returns a new DiffNode with updated token counts.
8338
+ */
8339
+ async function preprocessLargeFiles(rootNode, options) {
8340
+ // Collect all diffs from the tree
8341
+ const allDiffs = [];
8342
+ function collectDiffs(node) {
8343
+ allDiffs.push(...node.diffs);
8344
+ node.children.forEach(collectDiffs);
8345
+ }
8346
+ collectDiffs(rootNode);
8347
+ // Summarize large files
8348
+ const processedDiffs = await summarizeLargeFiles(allDiffs, options);
8349
+ // Create a map for quick lookup
8350
+ const diffMap = new Map();
8351
+ processedDiffs.forEach((diff) => diffMap.set(diff.file, diff));
8352
+ // Rebuild tree with processed diffs
8353
+ function rebuildNode(node) {
8229
8354
  return {
8230
- branch: branchName,
8231
- commits: commitsWithDiffText,
8232
- withDiff: argv.withDiff,
8355
+ path: node.path,
8356
+ diffs: node.diffs.map((diff) => diffMap.get(diff.file) || diff),
8357
+ children: node.children.map(rebuildNode),
8233
8358
  };
8234
8359
  }
8235
- async function parser(data) {
8236
- if (data.diff) {
8237
- return `## Diff for ${data.branch}\n\n${data.diff}`;
8238
- }
8239
- if (!data.commits || data.commits.length === 0) {
8240
- return `## ${data.branch}\n\nNo commits found.`;
8241
- }
8242
- let result = `## ${data.branch}\n\n`;
8243
- result += data.commits.map(commit => {
8244
- let commitStr = `Author: ${commit.author_name}\nCommit: ${commit.hash}\nMessage: ${commit.message}\n${commit.body}`;
8245
- if (data.withDiff && commit.diffText) {
8246
- commitStr += `\nDiff:\n${commit.diffText}`;
8360
+ return rebuildNode(rootNode);
8361
+ }
8362
+
8363
+ /**
8364
+ * Create groups from a given node info.
8365
+ * @param {DiffNode} node - The node info to start grouping.
8366
+ * @returns {DirectoryDiff[]} The groups created.
8367
+ */
8368
+ function createDirectoryDiffs(node) {
8369
+ const groupByPath = {};
8370
+ function traverse(node) {
8371
+ node.diffs.forEach((diff) => {
8372
+ const path = getPathFromFilePath(diff.file);
8373
+ if (!groupByPath[path]) {
8374
+ groupByPath[path] = { diffs: [], path, tokenCount: 0 };
8247
8375
  }
8248
- return commitStr.trim();
8249
- }).join('\n\n---\n\n');
8250
- return result;
8376
+ groupByPath[path].diffs.push(diff);
8377
+ groupByPath[path].tokenCount += diff.tokenCount;
8378
+ });
8379
+ node.children.forEach(traverse);
8251
8380
  }
8252
- const changelogMsg = await generateAndReviewLoop({
8253
- label: 'changelog',
8254
- options: {
8255
- ...config,
8256
- prompt: config.prompt || CHANGELOG_PROMPT.template,
8257
- logger,
8258
- interactive: INTERACTIVE,
8259
- review: {
8260
- enableFullRetry: false,
8381
+ traverse(node);
8382
+ return Object.values(groupByPath);
8383
+ }
8384
+ /**
8385
+ * Summarize a directory diff asynchronously.
8386
+ */
8387
+ async function summarizeDirectoryDiff(directory, { chain, textSplitter, tokenizer }) {
8388
+ try {
8389
+ const directorySummary = await summarize(directory.diffs.map((diff) => ({
8390
+ pageContent: diff.diff,
8391
+ metadata: {
8392
+ file: diff.file,
8393
+ summary: diff.summary,
8394
+ },
8395
+ })), {
8396
+ chain,
8397
+ textSplitter,
8398
+ options: {
8399
+ returnIntermediateSteps: true,
8261
8400
  },
8262
- },
8263
- factory,
8264
- parser,
8265
- agent: async (context, options) => {
8266
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
8267
- const parser = createSchemaParser(ChangelogResponseSchema, llm);
8268
- const prompt = getPrompt({
8269
- template: options.prompt,
8270
- variables: CHANGELOG_PROMPT.inputVariables,
8271
- fallback: CHANGELOG_PROMPT,
8272
- });
8273
- const formatInstructions = "Only respond with a valid JSON object, containing two fields: 'title' an escaped string, no more than 65 characters, and 'content' also an escaped string.";
8274
- let additional_context = '';
8275
- if (argv.additional) {
8276
- additional_context = `## Additional Context\n${argv.additional}`;
8277
- }
8278
- const author_instructions = argv.author
8279
- ? 'At the end of each item, attribute the author and include a reference to the commit hash, like this: `by @author_name (f6dbe61)`. Use the first 7 characters of the hash.'
8280
- : 'At the end of each item, include a reference to the commit hash, like this: `(f6dbe61)`. Use the first 7 characters of the hash.';
8281
- const changelog = await executeChain({
8282
- llm,
8283
- prompt,
8284
- variables: {
8285
- summary: context,
8286
- format_instructions: formatInstructions,
8287
- additional_context: additional_context,
8288
- author_instructions: author_instructions,
8289
- },
8290
- parser,
8291
- });
8292
- const branchName = await getCurrentBranchName({ git });
8293
- const ticketId = extractTicketIdFromBranchName(branchName);
8294
- const footer = ticketId ? `\n\nPart of **${ticketId}**` : '';
8295
- return `${changelog.title}\n\n${changelog.content}${footer}`;
8296
- },
8297
- noResult: async () => {
8298
- if (config.range) {
8299
- logger.log(`No commits found in the provided range.`, { color: 'red' });
8300
- process.exit(0);
8301
- }
8302
- logger.log(`No commits found in the current branch.`, { color: 'red' });
8303
- process.exit(0);
8304
- },
8305
- });
8306
- const MODE = (INTERACTIVE && 'interactive') || (config.commit && 'interactive') || config?.mode || 'stdout';
8307
- handleResult({
8308
- result: changelogMsg,
8309
- interactiveModeCallback: async () => {
8310
- logSuccess();
8311
- },
8312
- mode: MODE,
8313
- });
8314
- };
8315
-
8316
- var changelog = {
8317
- command: command$4,
8318
- desc: 'Generate a changelog from current or target branch, provided commit range, or since the last tag.',
8319
- builder: builder$4,
8320
- handler: commandExecutor(handler$4),
8321
- options: options$4,
8322
- };
8323
-
8324
- const conventionalTypeRegex = /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?!?:/;
8325
- // Regular commit message schema with basic validation
8326
- const CommitMessageResponseSchema = objectType({
8327
- title: stringType().describe("Title of the commit message"),
8328
- body: stringType().describe("Body of the commit message"),
8329
- }).describe("Object with commit message 'title' and 'body'");
8330
- // Conventional commit message schema with strict formatting rules
8331
- const ConventionalCommitMessageResponseSchema = objectType({
8332
- title: stringType()
8333
- .max(50, "Title must be 50 characters or less")
8334
- .refine((title) => conventionalTypeRegex.test(title), "Title must follow Conventional Commits format (e.g., 'feat: add new feature' or 'fix(scope): fix bug')").describe("Title of the commit message"),
8335
- body: stringType().describe("Body of the commit message")
8336
- // .max(280, "Body must be 280 characters or less"),
8337
- }).describe("Object with Conventional Commit message 'title' and 'body' adhering to Conventional Commits specification");
8338
- const command$3 = 'commit';
8339
- /**
8340
- * Command line options via yargs
8341
- */
8342
- const options$3 = {
8343
- i: {
8344
- alias: 'interactive',
8345
- description: 'Toggle interactive mode',
8346
- type: 'boolean',
8347
- },
8348
- ignoredFiles: {
8349
- description: 'Ignored files',
8350
- type: 'array',
8351
- },
8352
- ignoredExtensions: {
8353
- description: 'Ignored extensions',
8354
- type: 'array',
8355
- },
8356
- append: {
8357
- description: 'Add content to the end of the generated commit message',
8358
- type: 'string',
8359
- },
8360
- appendTicket: {
8361
- description: 'Append ticket ID from branch name to the commit message',
8362
- type: 'boolean',
8363
- alias: 't',
8364
- },
8365
- additional: {
8366
- description: 'Add extra contextual information to the prompt',
8367
- type: 'string',
8368
- alias: 'a',
8369
- },
8370
- withPreviousCommits: {
8371
- description: 'Include previous commits as context (specify number of commits, 0 for none)',
8372
- type: 'number',
8373
- default: 0,
8374
- alias: 'p',
8375
- },
8376
- conventional: {
8377
- description: 'Generate commit message in Conventional Commits format',
8378
- type: 'boolean',
8379
- default: false,
8380
- alias: 'c',
8381
- },
8382
- includeBranchName: {
8383
- description: 'Include the current branch name in the commit prompt for context',
8384
- type: 'boolean',
8385
- default: true,
8386
- },
8387
- noDiff: {
8388
- description: 'Only pass basic "git status" result instead of providing entire diff',
8389
- type: 'boolean',
8390
- default: false,
8391
- },
8392
- };
8393
- const builder$3 = (yargs) => {
8394
- return yargs.options(options$3).usage(getCommandUsageHeader(command$3));
8395
- };
8396
-
8397
- /**
8398
- * High-level function that combines chain execution with schema-based parsing
8399
- * Includes automatic retry logic and graceful degradation
8400
- * @param schema - Zod schema for the expected output structure
8401
- * @param llm - LLM instance
8402
- * @param prompt - Prompt template
8403
- * @param variables - Variables for the prompt
8404
- * @param options - Configuration options
8405
- * @returns Parsed result matching the schema type
8406
- */
8407
- async function executeChainWithSchema(schema, llm, prompt, variables, options = {}) {
8408
- const { retryOptions = { maxAttempts: 3 }, fallbackParser, onFallback, ...parserOptions } = options;
8409
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
8410
- const parser = createSchemaParser(schema, llm, parserOptions);
8411
- const operation = async () => {
8412
- const result = await executeChain({
8413
- llm,
8414
- prompt,
8415
- variables,
8416
- parser,
8417
8401
  });
8418
- return result;
8419
- };
8420
- try {
8421
- return await withRetry(operation, retryOptions);
8402
+ const newTokenTotal = tokenizer(directorySummary);
8403
+ return {
8404
+ diffs: directory.diffs,
8405
+ path: directory.path,
8406
+ summary: directorySummary,
8407
+ tokenCount: newTokenTotal,
8408
+ };
8422
8409
  }
8423
8410
  catch (error) {
8424
- if (fallbackParser) {
8425
- if (onFallback) {
8426
- onFallback();
8427
- }
8428
- const fallbackResult = await executeChain({
8429
- llm,
8430
- prompt,
8431
- variables,
8432
- parser: new output_parsers.StringOutputParser(),
8433
- });
8434
- const fallbackText = typeof fallbackResult === 'string' ? fallbackResult : String(fallbackResult);
8435
- return fallbackParser(fallbackText);
8436
- }
8437
- // No fallback available, re-throw the error
8438
- throw error;
8411
+ console.error(error);
8412
+ return directory;
8439
8413
  }
8440
8414
  }
8441
-
8442
8415
  /**
8443
- * Utility to repair common JSON formatting issues that LLMs make
8444
- * Specifically handles cases where string values are not properly quoted
8416
+ * Default output formatter for directory diffs.
8417
+ *
8418
+ * TODO: Future improvements to consider:
8419
+ * - Hierarchical output showing file -> directory -> overall summary
8420
+ * - Configurable verbosity levels (compact, standard, detailed)
8421
+ * - Machine-readable format option (JSON) for programmatic use
8422
+ * - Semantic grouping by change type (added/modified/deleted) or feature area
8423
+ * - Visual diff indicators showing magnitude of changes
8445
8424
  */
8446
- function repairJson(jsonString) {
8447
- // Remove any markdown code block wrapping
8448
- let cleaned = jsonString.replace(/```(?:json)?\s*([\s\S]*?)\s*```/g, '$1').trim();
8449
- // Remove inline code block wrapping
8450
- cleaned = cleaned.replace(/^`(.*)`$/, '$1').trim();
8451
- // If it doesn't look like JSON, return as-is
8452
- if (!cleaned.startsWith('{') || !cleaned.endsWith('}')) {
8453
- return jsonString;
8425
+ const defaultOutputCallback = (group) => {
8426
+ let output = `
8427
+ -------\n* changes in "/${group.path}"\n\n`;
8428
+ if (group.summary) {
8429
+ output += `${group.diffs.map((diff) => ` • ${diff.summary}`).join('\n')}\n\nSummary:\n\n${group.summary}\n\n`;
8454
8430
  }
8455
- try {
8456
- // First try parsing as-is
8457
- JSON.parse(cleaned);
8458
- return cleaned;
8431
+ else {
8432
+ output += `${group.diffs.map((diff) => ` • ${diff.summary}\n\n${diff.diff}`).join('\n\n')}\n\n`;
8459
8433
  }
8460
- catch {
8461
- // Try to repair common issues
8462
- let repaired = cleaned;
8463
- // Fix unquoted string values in title and body fields
8464
- // Pattern: "title": unquoted_value, -> "title": "unquoted_value",
8465
- repaired = repaired.replace(/"(title|body)":\s*([^",\{\}\[\]]+?)(?=\s*[,\}])/g, (match, field, value) => {
8466
- // Clean up the value (remove leading/trailing whitespace)
8467
- const cleanValue = value.trim();
8468
- // If it's already quoted or looks like a number/boolean, leave it
8469
- if (cleanValue.startsWith('"') || /^(true|false|\d+)$/.test(cleanValue)) {
8470
- return match;
8434
+ return output;
8435
+ };
8436
+ /**
8437
+ * Process directory summarization in waves to respect concurrency limits
8438
+ * while maintaining predictable behavior.
8439
+ */
8440
+ async function summarizeInWaves(directories, options) {
8441
+ const { totalTokenCount: initialTotal, maxTokens, minTokensForSummary, maxConcurrent, logger, chain, textSplitter, tokenizer, } = options;
8442
+ let totalTokenCount = initialTotal;
8443
+ const results = [...directories];
8444
+ // Create sorted indices by token count (descending) for prioritized processing
8445
+ const sortedIndices = directories
8446
+ .map((d, i) => ({ index: i, tokens: d.tokenCount }))
8447
+ .sort((a, b) => b.tokens - a.tokens);
8448
+ let cursor = 0;
8449
+ while (totalTokenCount > maxTokens && cursor < sortedIndices.length) {
8450
+ // Select wave candidates: directories that exceed minTokensForSummary
8451
+ const wave = [];
8452
+ for (let i = cursor; i < sortedIndices.length && wave.length < maxConcurrent; i++) {
8453
+ const { index, tokens } = sortedIndices[i];
8454
+ // Skip directories below the minimum threshold
8455
+ if (tokens < minTokensForSummary) {
8456
+ cursor = i + 1;
8457
+ continue;
8471
8458
  }
8472
- // Quote the value
8473
- return `"${field}": "${cleanValue}"`;
8474
- });
8475
- // Fix missing quotes around field names (though this should be rare)
8476
- repaired = repaired.replace(/([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)\s*:/g, '$1"$2":');
8477
- // Remove trailing commas before closing braces
8478
- repaired = repaired.replace(/,(\s*[}\]])/g, '$1');
8479
- try {
8480
- // Test if the repair worked
8481
- JSON.parse(repaired);
8482
- return repaired;
8459
+ // Skip directories that have already been summarized
8460
+ if (results[index].summary) {
8461
+ cursor = i + 1;
8462
+ continue;
8463
+ }
8464
+ wave.push(index);
8465
+ cursor = i + 1;
8483
8466
  }
8484
- catch {
8485
- // If repair failed, return original
8486
- return jsonString;
8467
+ // No more eligible candidates
8468
+ if (wave.length === 0) {
8469
+ break;
8487
8470
  }
8488
- }
8489
- }
8490
-
8491
- /**
8492
- * Extract the first complete JSON object from a string by tracking balanced braces
8493
- */
8494
- function extractFirstJsonObject(text) {
8495
- const startIndex = text.indexOf('{');
8496
- if (startIndex === -1)
8497
- return null;
8498
- let braceCount = 0;
8499
- let inString = false;
8500
- let escapeNext = false;
8501
- for (let i = startIndex; i < text.length; i++) {
8502
- const char = text[i];
8503
- if (escapeNext) {
8504
- escapeNext = false;
8505
- continue;
8506
- }
8507
- if (char === '\\') {
8508
- escapeNext = true;
8509
- continue;
8510
- }
8511
- if (char === '"') {
8512
- inString = !inString;
8513
- continue;
8514
- }
8515
- if (inString)
8516
- continue;
8517
- if (char === '{') {
8518
- braceCount++;
8519
- }
8520
- else if (char === '}') {
8521
- braceCount--;
8522
- if (braceCount === 0) {
8523
- // Found the end of the first complete JSON object
8524
- return text.substring(startIndex, i + 1);
8525
- }
8471
+ logger.verbose(`\nProcessing wave of ${wave.length} directories...`, { color: 'blue' });
8472
+ // Process wave in parallel
8473
+ const waveResults = await Promise.all(wave.map((idx) => summarizeDirectoryDiff(results[idx], { chain, textSplitter, tokenizer })));
8474
+ // Update results and recalculate total
8475
+ waveResults.forEach((result, i) => {
8476
+ const idx = wave[i];
8477
+ const originalTokens = results[idx].tokenCount;
8478
+ const newTokens = result.tokenCount;
8479
+ const reduction = originalTokens - newTokens;
8480
+ totalTokenCount -= reduction;
8481
+ results[idx] = result;
8482
+ logger.verbose(` Summarized "/${result.path}": ${originalTokens} -> ${newTokens} tokens`, {
8483
+ color: 'magenta',
8484
+ });
8485
+ });
8486
+ logger.verbose(`Total token count: ${totalTokenCount}`, {
8487
+ color: totalTokenCount > maxTokens ? 'yellow' : 'green',
8488
+ });
8489
+ // Check if we're now under budget
8490
+ if (totalTokenCount <= maxTokens) {
8491
+ logger.verbose(`Under token budget, stopping summarization.`, { color: 'green' });
8492
+ break;
8526
8493
  }
8527
8494
  }
8528
- return null;
8495
+ return { directories: results, totalTokenCount };
8529
8496
  }
8530
8497
  /**
8531
- * Utility function to ensure commit messages are properly formatted as strings
8532
- * rather than JSON objects, whether they come as parsed objects or stringified JSON
8498
+ * Summarize diffs using a three-phase approach:
8499
+ *
8500
+ * Phase 1: Pre-process large files to prevent any single file from dominating
8501
+ * Phase 2: Group diffs by directory and assess total token count
8502
+ * Phase 3: Wave-based parallel summarization until under budget
8503
+ *
8504
+ * This approach ensures:
8505
+ * - Large files don't bias the summary
8506
+ * - Small changes preserve their detail (minTokensForSummary threshold)
8507
+ * - Efficient parallel processing with predictable behavior
8508
+ * - Early exit when under token budget
8533
8509
  */
8534
- function formatCommitMessage(result, options = {}) {
8535
- const { append, ticketId, appendTicket } = options;
8536
- // Helper function to construct the final message with appends
8537
- const constructMessage = (title, body) => {
8538
- const appendedText = append ? `\n\n${append}` : '';
8539
- const ticketFooter = appendTicket && ticketId ? `\n\nPart of **${ticketId}**` : '';
8540
- return `${title}\n\n${body}${appendedText}${ticketFooter}`;
8510
+ async function summarizeDiffs(rootDiffNode, { tokenizer, logger, maxTokens = 2048, minTokensForSummary = 400, maxFileTokens, maxConcurrent = 6, textSplitter, chain, handleOutput = defaultOutputCallback, }) {
8511
+ // Calculate maxFileTokens as 25% of maxTokens if not specified
8512
+ const effectiveMaxFileTokens = maxFileTokens ?? Math.floor(maxTokens * 0.25);
8513
+ // PHASE 1: Directory grouping & assessment
8514
+ logger.startTimer().startSpinner(`Organizing Diffs...`, { color: 'blue' });
8515
+ let directoryDiffs = createDirectoryDiffs(rootDiffNode);
8516
+ // Sort by token count descending for consistent output ordering
8517
+ directoryDiffs.sort((a, b) => b.tokenCount - a.tokenCount);
8518
+ let totalTokenCount = directoryDiffs.reduce((sum, group) => sum + group.tokenCount, 0);
8519
+ logger.stopSpinner('Diffs Organized').stopTimer();
8520
+ logger.verbose(`Total token count: ${totalTokenCount}, max allowed: ${maxTokens}`, {
8521
+ color: totalTokenCount > maxTokens ? 'yellow' : 'green',
8522
+ });
8523
+ // Early exit if already under budget
8524
+ if (totalTokenCount <= maxTokens) {
8525
+ logger.verbose(`Already under token budget, skipping summarization.`, { color: 'green' });
8526
+ return directoryDiffs.map(handleOutput).join('');
8527
+ }
8528
+ // PHASE 2: Pre-process large files only when the raw diff is over budget
8529
+ logger.startTimer().startSpinner(`Pre-processing large files...`, { color: 'blue' });
8530
+ const preprocessedNode = await preprocessLargeFiles(rootDiffNode, {
8531
+ maxFileTokens: effectiveMaxFileTokens,
8532
+ minTokensForSummary,
8533
+ maxConcurrent,
8534
+ tokenizer,
8535
+ logger,
8536
+ chain,
8537
+ textSplitter,
8538
+ });
8539
+ logger.stopSpinner('Files pre-processed').stopTimer();
8540
+ directoryDiffs = createDirectoryDiffs(preprocessedNode);
8541
+ directoryDiffs.sort((a, b) => b.tokenCount - a.tokenCount);
8542
+ totalTokenCount = directoryDiffs.reduce((sum, group) => sum + group.tokenCount, 0);
8543
+ logger.verbose(`Total token count after file pre-processing: ${totalTokenCount}`, {
8544
+ color: totalTokenCount > maxTokens ? 'yellow' : 'green',
8545
+ });
8546
+ if (totalTokenCount <= maxTokens) {
8547
+ logger.verbose(`Under token budget after file pre-processing.`, { color: 'green' });
8548
+ return directoryDiffs.map(handleOutput).join('');
8549
+ }
8550
+ // PHASE 3: Wave-based summarization
8551
+ logger.startTimer().startSpinner(`Consolidating Diffs...`, { color: 'blue' });
8552
+ const { directories: summarizedDiffs } = await summarizeInWaves(directoryDiffs, {
8553
+ totalTokenCount,
8554
+ maxTokens,
8555
+ minTokensForSummary,
8556
+ maxConcurrent,
8557
+ logger,
8558
+ chain,
8559
+ textSplitter,
8560
+ tokenizer,
8561
+ });
8562
+ logger.stopSpinner(`Diffs Consolidated`).stopTimer();
8563
+ return summarizedDiffs.map(handleOutput).join('');
8564
+ }
8565
+
8566
+ function createLimit(maxConcurrent) {
8567
+ const limit = Math.max(1, maxConcurrent);
8568
+ let active = 0;
8569
+ const queue = [];
8570
+ const runNext = () => {
8571
+ active--;
8572
+ const next = queue.shift();
8573
+ if (next)
8574
+ next();
8541
8575
  };
8542
- // If it's a string, check if it contains a JSON object (including markdown code blocks)
8543
- if (typeof result === 'string') {
8544
- // Early return if string clearly doesn't contain JSON-like content
8545
- if (!result.includes('{') && !result.includes('"title"')) {
8546
- return result;
8547
- }
8548
- // Handle multiple markdown code block formats and embedded JSON
8549
- const extractionPatterns = [
8550
- /```(?:json)?\s*(\{[\s\S]*?\})\s*```/, // Standard markdown blocks
8551
- /`(\{[\s\S]*?\})`/, // Inline code blocks
8552
- /^\s*(\{[\s\S]*\})\s*$/, // Raw JSON without blocks (entire string)
8553
- /(\{[\s\S]*?\})/ // JSON anywhere in text (fallback)
8554
- ];
8555
- let jsonString = result;
8556
- let foundMatch = false;
8557
- // Try each pattern to extract JSON
8558
- for (const pattern of extractionPatterns) {
8559
- const match = result.match(pattern);
8560
- if (match && match[1]) {
8561
- jsonString = match[1].trim();
8562
- foundMatch = true;
8563
- break;
8564
- }
8576
+ return async (operation) => {
8577
+ if (active >= limit) {
8578
+ await new Promise((resolve) => queue.push(resolve));
8565
8579
  }
8566
- // Only attempt JSON parsing if we found potential JSON content
8567
- if (foundMatch || jsonString.startsWith('{')) {
8568
- try {
8569
- // Try to parse as JSON to see if it's a stringified object
8570
- const parsed = JSON.parse(jsonString);
8571
- if (parsed &&
8572
- typeof parsed === 'object' &&
8573
- typeof parsed.title === 'string' &&
8574
- typeof parsed.body === 'string' &&
8575
- parsed.title.length > 0 &&
8576
- parsed.body.length > 0) {
8577
- // It's a valid stringified JSON object, format it properly
8578
- return constructMessage(parsed.title, parsed.body);
8579
- }
8580
- }
8581
- catch {
8582
- // Try to repair the JSON and parse again
8583
- try {
8584
- const repairedJson = repairJson(jsonString);
8585
- const parsed = JSON.parse(repairedJson);
8586
- if (parsed &&
8587
- typeof parsed === 'object' &&
8588
- typeof parsed.title === 'string' &&
8589
- typeof parsed.body === 'string' &&
8590
- parsed.title.length > 0 &&
8591
- parsed.body.length > 0) {
8592
- // Successfully repaired and parsed JSON
8593
- return constructMessage(parsed.title, parsed.body);
8594
- }
8595
- }
8596
- catch {
8597
- // Repair failed, try extracting just the first complete JSON object
8598
- const firstObject = extractFirstJsonObject(jsonString);
8599
- if (firstObject) {
8600
- try {
8601
- const parsed = JSON.parse(firstObject);
8602
- if (parsed &&
8603
- typeof parsed === 'object' &&
8604
- typeof parsed.title === 'string' &&
8605
- typeof parsed.body === 'string' &&
8606
- parsed.title.length > 0 &&
8607
- parsed.body.length > 0) {
8608
- return constructMessage(parsed.title, parsed.body);
8609
- }
8610
- }
8611
- catch {
8612
- // Even first object extraction failed, continue to fallback
8613
- }
8614
- }
8615
- }
8616
- }
8580
+ active++;
8581
+ try {
8582
+ return await operation();
8617
8583
  }
8618
- // If no JSON found and it's already formatted, return as-is
8619
- return result;
8620
- }
8621
- // If it's already an object with title and body, format it
8622
- if (typeof result === 'object' && result !== null &&
8623
- 'title' in result && 'body' in result) {
8624
- const commitMsgObj = result;
8625
- if (typeof commitMsgObj.title === 'string' && typeof commitMsgObj.body === 'string') {
8626
- return constructMessage(commitMsgObj.title, commitMsgObj.body);
8584
+ finally {
8585
+ runNext();
8627
8586
  }
8628
- }
8629
- // Fallback - convert to string and return as-is
8630
- return String(result);
8587
+ };
8631
8588
  }
8632
-
8633
8589
  /**
8634
- * Extract the path from a file path string.
8635
- * @param {string} filePath - The full file path.
8636
- * @returns {string} The path portion of the file path.
8590
+ * Asynchronously collect diffs for a given node and its children.
8637
8591
  */
8638
- function getPathFromFilePath(filePath) {
8639
- return filePath.split('/').slice(0, -1).join('/');
8592
+ async function collectDiffs(node, getFileDiff, tokenizer, logger, maxConcurrent = 6, limit = createLimit(maxConcurrent)) {
8593
+ // Collect diffs for the files of the current node
8594
+ const diffPromises = node.files.map((nodeFile) => limit(async () => {
8595
+ const diff = await getFileDiff(nodeFile);
8596
+ const tokenCount = tokenizer(diff);
8597
+ logger.verbose(`Collected diff for ${nodeFile.filePath} (${tokenCount} tokens)`, {
8598
+ color: 'magenta',
8599
+ });
8600
+ return {
8601
+ file: nodeFile.filePath,
8602
+ summary: nodeFile.summary,
8603
+ diff,
8604
+ tokenCount,
8605
+ };
8606
+ }));
8607
+ // Collect diffs for the children of the current node
8608
+ const childrenPromises = Array.from(node.children.values()).map(async (child) => collectDiffs(child, getFileDiff, tokenizer, logger, maxConcurrent, limit));
8609
+ const [diffs, children] = await Promise.all([
8610
+ Promise.all(diffPromises),
8611
+ Promise.all(childrenPromises),
8612
+ ]);
8613
+ return {
8614
+ path: node.getPath(),
8615
+ diffs,
8616
+ children,
8617
+ };
8640
8618
  }
8641
8619
 
8642
- async function summarize(documents$1, { chain, textSplitter, options }) {
8643
- const { returnIntermediateSteps = false } = options || {};
8644
- const docs = await textSplitter.splitDocuments(documents$1.map((doc) => new documents.Document(doc)));
8645
- const res = await chain.invoke({
8646
- input_documents: docs,
8647
- returnIntermediateSteps,
8648
- });
8649
- if (res.error)
8650
- throw new Error(res.error);
8651
- return res.text && res.text.trim();
8652
- }
8653
-
8654
- /**
8655
- * Summarize a single file diff that exceeds the token threshold.
8656
- */
8657
- async function summarizeFileDiff(fileDiff, { chain, textSplitter, tokenizer }) {
8658
- try {
8659
- const fileSummary = await summarize([
8660
- {
8661
- pageContent: fileDiff.diff,
8662
- metadata: {
8663
- file: fileDiff.file,
8664
- summary: fileDiff.summary,
8665
- },
8666
- },
8667
- ], {
8668
- chain,
8669
- textSplitter,
8670
- options: {
8671
- returnIntermediateSteps: false,
8672
- },
8673
- });
8674
- const newTokenCount = tokenizer(fileSummary);
8675
- return {
8676
- ...fileDiff,
8677
- diff: fileSummary,
8678
- tokenCount: newTokenCount,
8679
- };
8620
+ class DiffTreeNode {
8621
+ constructor(path) {
8622
+ this.path = [];
8623
+ this.files = [];
8624
+ this.children = new Map();
8625
+ if (path)
8626
+ this.path = path;
8680
8627
  }
8681
- catch (error) {
8682
- // On error, return original diff unchanged
8683
- console.error(`Failed to summarize file ${fileDiff.file}:`, error);
8684
- return fileDiff;
8628
+ addFile(file) {
8629
+ this.files.push(file);
8685
8630
  }
8686
- }
8687
- /**
8688
- * Process files in waves to respect concurrency limits.
8689
- */
8690
- async function processInWaves(items, processor, maxConcurrent) {
8691
- const results = [];
8692
- for (let i = 0; i < items.length; i += maxConcurrent) {
8693
- const wave = items.slice(i, i + maxConcurrent);
8694
- const waveResults = await Promise.all(wave.map(processor));
8695
- results.push(...waveResults);
8631
+ addChild(part, node) {
8632
+ this.children.set(part, node);
8696
8633
  }
8697
- return results;
8698
- }
8699
- /**
8700
- * Pre-summarize individual files that exceed the maxFileTokens threshold.
8701
- * This prevents large files from dominating the token budget and biasing
8702
- * the final commit message toward a single file's changes.
8703
- *
8704
- * @param diffs - Array of file diffs to process
8705
- * @param options - Configuration options for summarization
8706
- * @returns Array of file diffs with large files summarized
8707
- */
8708
- async function summarizeLargeFiles(diffs, options) {
8709
- const { maxFileTokens, minTokensForSummary, maxConcurrent, tokenizer, logger, chain, textSplitter } = options;
8710
- // Identify files that need summarization
8711
- const filesToSummarize = [];
8712
- const results = [...diffs];
8713
- diffs.forEach((diff, index) => {
8714
- if (diff.tokenCount > maxFileTokens && diff.tokenCount >= minTokensForSummary) {
8715
- filesToSummarize.push({ index, diff });
8716
- }
8717
- });
8718
- if (filesToSummarize.length === 0) {
8719
- return results;
8634
+ getChild(part) {
8635
+ return this.children.get(part);
8720
8636
  }
8721
- logger.verbose(`Pre-summarizing ${filesToSummarize.length} large file(s)...`, { color: 'blue' });
8722
- // Process large files in waves
8723
- const summarizedFiles = await processInWaves(filesToSummarize, async ({ diff }) => summarizeFileDiff(diff, { chain, textSplitter, tokenizer }), maxConcurrent);
8724
- // Update results with summarized files
8725
- summarizedFiles.forEach((summarizedDiff, i) => {
8726
- const originalIndex = filesToSummarize[i].index;
8727
- const originalTokens = results[originalIndex].tokenCount;
8728
- const newTokens = summarizedDiff.tokenCount;
8729
- logger.verbose(` - ${summarizedDiff.file}: ${originalTokens} -> ${newTokens} tokens`, { color: 'magenta' });
8730
- results[originalIndex] = summarizedDiff;
8731
- });
8732
- return results;
8733
- }
8734
- /**
8735
- * Pre-process a DiffNode tree, summarizing large files at the leaf level.
8736
- * Returns a new DiffNode with updated token counts.
8737
- */
8738
- async function preprocessLargeFiles(rootNode, options) {
8739
- // Collect all diffs from the tree
8740
- const allDiffs = [];
8741
- function collectDiffs(node) {
8742
- allDiffs.push(...node.diffs);
8743
- node.children.forEach(collectDiffs);
8637
+ getPath() {
8638
+ return this.path.join('/');
8744
8639
  }
8745
- collectDiffs(rootNode);
8746
- // Summarize large files
8747
- const processedDiffs = await summarizeLargeFiles(allDiffs, options);
8748
- // Create a map for quick lookup
8749
- const diffMap = new Map();
8750
- processedDiffs.forEach((diff) => diffMap.set(diff.file, diff));
8751
- // Rebuild tree with processed diffs
8752
- function rebuildNode(node) {
8753
- return {
8754
- path: node.path,
8755
- diffs: node.diffs.map((diff) => diffMap.get(diff.file) || diff),
8756
- children: node.children.map(rebuildNode),
8757
- };
8640
+ print(indentation = 0) {
8641
+ const indent = ' '.repeat(indentation);
8642
+ let output = `${indent}- Path: ${this.getPath()}\n`;
8643
+ if (this.files.length > 0) {
8644
+ output += `${indent} Files:\n`;
8645
+ for (const file of this.files) {
8646
+ output += `${indent} - ${file.summary}\n`;
8647
+ }
8648
+ }
8649
+ if (this.children.size > 0) {
8650
+ output += `${indent} Children:\n`;
8651
+ for (const [, child] of this.children) {
8652
+ output += child.print(indentation + 4);
8653
+ }
8654
+ }
8655
+ return output;
8758
8656
  }
8759
- return rebuildNode(rootNode);
8760
8657
  }
8761
-
8762
- /**
8763
- * Create groups from a given node info.
8764
- * @param {DiffNode} node - The node info to start grouping.
8765
- * @returns {DirectoryDiff[]} The groups created.
8766
- */
8767
- function createDirectoryDiffs(node) {
8768
- const groupByPath = {};
8769
- function traverse(node) {
8770
- node.diffs.forEach((diff) => {
8771
- const path = getPathFromFilePath(diff.file);
8772
- if (!groupByPath[path]) {
8773
- groupByPath[path] = { diffs: [], path, tokenCount: 0 };
8658
+ const createDiffTree = (changes) => {
8659
+ const root = new DiffTreeNode();
8660
+ for (const change of changes) {
8661
+ let currentParent = root;
8662
+ const parts = change.filePath.split('/');
8663
+ parts.pop();
8664
+ for (const part of parts) {
8665
+ let childNode = currentParent.getChild(part);
8666
+ if (!childNode) {
8667
+ childNode = new DiffTreeNode([...currentParent.path, part]);
8668
+ currentParent.addChild(part, childNode);
8774
8669
  }
8775
- groupByPath[path].diffs.push(diff);
8776
- groupByPath[path].tokenCount += diff.tokenCount;
8670
+ currentParent = childNode;
8671
+ }
8672
+ // Create a NodeFile object and add it to the parent
8673
+ currentParent.addFile({
8674
+ filePath: change.filePath,
8675
+ oldFilePath: change.oldFilePath,
8676
+ summary: change.summary,
8677
+ status: change.status,
8777
8678
  });
8778
- node.children.forEach(traverse);
8779
8679
  }
8780
- traverse(node);
8781
- return Object.values(groupByPath);
8782
- }
8680
+ return root;
8681
+ };
8682
+
8783
8683
  /**
8784
- * Summarize a directory diff asynchronously.
8684
+ * Parses the default file diff for a given nodeFile.
8685
+ *
8686
+ * @param nodeFile - The file change object.
8687
+ * @param commit - The commit to diff against. Defaults to '--staged'.
8688
+ * @param git - The SimpleGit instance.
8689
+ * @returns A Promise that resolves to the file diff as a string.
8785
8690
  */
8786
- async function summarizeDirectoryDiff(directory, { chain, textSplitter, tokenizer }) {
8691
+ async function parseDefaultFileDiff(nodeFile, commit = '--staged', git) {
8692
+ if (commit === '--staged') {
8693
+ return await git.diff(['--staged', nodeFile.filePath]);
8694
+ }
8695
+ else if (commit === '--unstaged') {
8696
+ return await git.diff([nodeFile.filePath]);
8697
+ }
8698
+ else if (commit === '--untracked') {
8699
+ // For untracked files, read the file content directly from the filesystem
8700
+ try {
8701
+ const fileContent = await fs.promises.readFile(nodeFile.filePath, 'utf-8');
8702
+ return fileContent;
8703
+ }
8704
+ catch (error) {
8705
+ throw new Error(`Error reading untracked file: ${error?.message || 'Unknown error'}`);
8706
+ }
8707
+ }
8708
+ // For branch comparisons, handle files that may not exist in the base branch
8787
8709
  try {
8788
- const directorySummary = await summarize(directory.diffs.map((diff) => ({
8789
- pageContent: diff.diff,
8790
- metadata: {
8791
- file: diff.file,
8792
- summary: diff.summary,
8793
- },
8794
- })), {
8795
- chain,
8796
- textSplitter,
8797
- options: {
8798
- returnIntermediateSteps: true,
8799
- },
8800
- });
8801
- const newTokenTotal = tokenizer(directorySummary);
8802
- return {
8803
- diffs: directory.diffs,
8804
- path: directory.path,
8805
- summary: directorySummary,
8806
- tokenCount: newTokenTotal,
8807
- };
8710
+ return await git.diff([commit, nodeFile.filePath]);
8808
8711
  }
8809
8712
  catch (error) {
8810
- console.error(error);
8811
- return directory;
8713
+ const errorMessage = error instanceof Error ? error.message : String(error);
8714
+ // If the error indicates the file doesn't exist in the base branch, handle it gracefully
8715
+ if (errorMessage.includes('unknown revision or path not in the working tree') ||
8716
+ errorMessage.includes('ambiguous argument')) {
8717
+ // This is likely a newly added file - show the entire file content as an addition
8718
+ if (nodeFile.status === 'added') {
8719
+ try {
8720
+ const fileContent = await fs.promises.readFile(nodeFile.filePath, 'utf-8');
8721
+ return `+++ ${nodeFile.filePath}\n${fileContent.split('\n').map(line => `+${line}`).join('\n')}`;
8722
+ }
8723
+ catch (fsError) {
8724
+ return `Error reading added file ${nodeFile.filePath}: ${fsError instanceof Error ? fsError.message : String(fsError)}`;
8725
+ }
8726
+ }
8727
+ // For other cases, try to get the file content from the current HEAD
8728
+ try {
8729
+ const fileContent = await git.show([`HEAD:${nodeFile.filePath}`]);
8730
+ return `File content from current version:\n${fileContent}`;
8731
+ }
8732
+ catch (showError) {
8733
+ // If all else fails, provide a meaningful error message
8734
+ return `Unable to retrieve diff for ${nodeFile.filePath}. File may be newly added or renamed.`;
8735
+ }
8736
+ }
8737
+ // Re-throw other types of errors
8738
+ throw error;
8812
8739
  }
8813
8740
  }
8814
8741
  /**
8815
- * Default output formatter for directory diffs.
8742
+ * Parses the diff for a renamed file.
8816
8743
  *
8817
- * TODO: Future improvements to consider:
8818
- * - Hierarchical output showing file -> directory -> overall summary
8819
- * - Configurable verbosity levels (compact, standard, detailed)
8820
- * - Machine-readable format option (JSON) for programmatic use
8821
- * - Semantic grouping by change type (added/modified/deleted) or feature area
8822
- * - Visual diff indicators showing magnitude of changes
8823
- */
8824
- const defaultOutputCallback = (group) => {
8825
- let output = `
8826
- -------\n* changes in "/${group.path}"\n\n`;
8827
- if (group.summary) {
8828
- output += `${group.diffs.map((diff) => ` • ${diff.summary}`).join('\n')}\n\nSummary:\n\n${group.summary}\n\n`;
8829
- }
8830
- else {
8831
- output += `${group.diffs.map((diff) => ` • ${diff.summary}\n\n${diff.diff}`).join('\n\n')}\n\n`;
8832
- }
8833
- return output;
8834
- };
8835
- /**
8836
- * Process directory summarization in waves to respect concurrency limits
8837
- * while maintaining predictable behavior.
8744
+ * @param nodeFile - The file change object.
8745
+ * @param commit - The commit hash or '--staged'.
8746
+ * @param git - The SimpleGit instance.
8747
+ * @param logger - The logger instance.
8748
+ * @returns A Promise that resolves to the diff string.
8838
8749
  */
8839
- async function summarizeInWaves(directories, options) {
8840
- const { totalTokenCount: initialTotal, maxTokens, minTokensForSummary, maxConcurrent, logger, chain, textSplitter, tokenizer, } = options;
8841
- let totalTokenCount = initialTotal;
8842
- const results = [...directories];
8843
- // Create sorted indices by token count (descending) for prioritized processing
8844
- const sortedIndices = directories
8845
- .map((d, i) => ({ index: i, tokens: d.tokenCount }))
8846
- .sort((a, b) => b.tokens - a.tokens);
8847
- let cursor = 0;
8848
- while (totalTokenCount > maxTokens && cursor < sortedIndices.length) {
8849
- // Select wave candidates: directories that exceed minTokensForSummary
8850
- const wave = [];
8851
- for (let i = cursor; i < sortedIndices.length && wave.length < maxConcurrent; i++) {
8852
- const { index, tokens } = sortedIndices[i];
8853
- // Skip directories below the minimum threshold
8854
- if (tokens < minTokensForSummary) {
8855
- cursor = i + 1;
8856
- continue;
8857
- }
8858
- // Skip directories that have already been summarized
8859
- if (results[index].summary) {
8860
- cursor = i + 1;
8861
- continue;
8862
- }
8863
- wave.push(index);
8864
- cursor = i + 1;
8865
- }
8866
- // No more eligible candidates
8867
- if (wave.length === 0) {
8868
- break;
8750
+ async function parseRenamedFileDiff(nodeFile, commit, git, logger) {
8751
+ let result = '';
8752
+ const oldFilePath = nodeFile?.oldFilePath || nodeFile.filePath;
8753
+ let previousCommitHash = 'HEAD';
8754
+ let newCommitHash = '';
8755
+ if (commit !== '--staged') {
8756
+ try {
8757
+ previousCommitHash = await git.revparse([`${commit}~1`]);
8869
8758
  }
8870
- logger.verbose(`\nProcessing wave of ${wave.length} directories...`, { color: 'blue' });
8871
- // Process wave in parallel
8872
- const waveResults = await Promise.all(wave.map((idx) => summarizeDirectoryDiff(results[idx], { chain, textSplitter, tokenizer })));
8873
- // Update results and recalculate total
8874
- waveResults.forEach((result, i) => {
8875
- const idx = wave[i];
8876
- const originalTokens = results[idx].tokenCount;
8877
- const newTokens = result.tokenCount;
8878
- const reduction = originalTokens - newTokens;
8879
- totalTokenCount -= reduction;
8880
- results[idx] = result;
8881
- logger.verbose(` • Summarized "/${result.path}": ${originalTokens} -> ${newTokens} tokens`, {
8882
- color: 'magenta',
8759
+ catch (err) {
8760
+ logger.verbose(`Error getting previous commit hash for ${nodeFile.filePath}`, {
8761
+ color: 'red',
8883
8762
  });
8884
- });
8885
- logger.verbose(`Total token count: ${totalTokenCount}`, {
8886
- color: totalTokenCount > maxTokens ? 'yellow' : 'green',
8887
- });
8888
- // Check if we're now under budget
8889
- if (totalTokenCount <= maxTokens) {
8890
- logger.verbose(`Under token budget, stopping summarization.`, { color: 'green' });
8891
- break;
8892
8763
  }
8764
+ newCommitHash = commit;
8893
8765
  }
8894
- return { directories: results, totalTokenCount };
8895
- }
8896
- /**
8897
- * Summarize diffs using a three-phase approach:
8898
- *
8899
- * Phase 1: Pre-process large files to prevent any single file from dominating
8900
- * Phase 2: Group diffs by directory and assess total token count
8901
- * Phase 3: Wave-based parallel summarization until under budget
8902
- *
8903
- * This approach ensures:
8904
- * - Large files don't bias the summary
8905
- * - Small changes preserve their detail (minTokensForSummary threshold)
8906
- * - Efficient parallel processing with predictable behavior
8907
- * - Early exit when under token budget
8908
- */
8909
- async function summarizeDiffs(rootDiffNode, { tokenizer, logger, maxTokens = 2048, minTokensForSummary = 400, maxFileTokens, maxConcurrent = 6, textSplitter, chain, handleOutput = defaultOutputCallback, }) {
8910
- // Calculate maxFileTokens as 25% of maxTokens if not specified
8911
- const effectiveMaxFileTokens = maxFileTokens ?? Math.floor(maxTokens * 0.25);
8912
- // PHASE 1: Pre-process large files
8913
- logger.startTimer().startSpinner(`Pre-processing large files...`, { color: 'blue' });
8914
- const preprocessedNode = await preprocessLargeFiles(rootDiffNode, {
8915
- maxFileTokens: effectiveMaxFileTokens,
8916
- minTokensForSummary,
8917
- maxConcurrent,
8918
- tokenizer,
8919
- logger,
8920
- chain,
8921
- textSplitter,
8922
- });
8923
- logger.stopSpinner('Files pre-processed').stopTimer();
8924
- // PHASE 2: Directory grouping & assessment
8925
- logger.startTimer().startSpinner(`Organizing Diffs...`, { color: 'blue' });
8926
- const directoryDiffs = createDirectoryDiffs(preprocessedNode);
8927
- // Sort by token count descending for consistent output ordering
8928
- directoryDiffs.sort((a, b) => b.tokenCount - a.tokenCount);
8929
- const totalTokenCount = directoryDiffs.reduce((sum, group) => sum + group.tokenCount, 0);
8930
- logger.stopSpinner('Diffs Organized').stopTimer();
8931
- logger.verbose(`Total token count: ${totalTokenCount}, max allowed: ${maxTokens}`, {
8932
- color: totalTokenCount > maxTokens ? 'yellow' : 'green',
8933
- });
8934
- // Early exit if already under budget
8935
- if (totalTokenCount <= maxTokens) {
8936
- logger.verbose(`Already under token budget, skipping summarization.`, { color: 'green' });
8937
- return directoryDiffs.map(handleOutput).join('');
8938
- }
8939
- // PHASE 3: Wave-based summarization
8940
- logger.startTimer().startSpinner(`Consolidating Diffs...`, { color: 'blue' });
8941
- const { directories: summarizedDiffs } = await summarizeInWaves(directoryDiffs, {
8942
- totalTokenCount,
8943
- maxTokens,
8944
- minTokensForSummary,
8945
- maxConcurrent,
8946
- logger,
8947
- chain,
8948
- textSplitter,
8949
- tokenizer,
8950
- });
8951
- logger.stopSpinner(`Diffs Consolidated`).stopTimer();
8952
- return summarizedDiffs.map(handleOutput).join('');
8953
- }
8954
-
8955
- /**
8956
- * Asynchronously collect diffs for a given node and its children.
8957
- */
8958
- async function collectDiffs(node, getFileDiff, tokenizer, logger) {
8959
- // Collect diffs for the files of the current node
8960
- const diffPromises = node.files.map(async (nodeFile) => {
8961
- const diff = await getFileDiff(nodeFile);
8962
- const tokenCount = tokenizer(diff);
8963
- logger.verbose(`Collected diff for ${nodeFile.filePath} (${tokenCount} tokens)`, {
8964
- color: 'magenta',
8965
- });
8966
- return {
8967
- file: nodeFile.filePath,
8968
- summary: nodeFile.summary,
8969
- diff,
8970
- tokenCount,
8971
- };
8972
- });
8973
- // Collect diffs for the children of the current node
8974
- const childrenPromises = Array.from(node.children.values()).map(async (child) => collectDiffs(child, getFileDiff, tokenizer, logger));
8975
- const [diffs, children] = await Promise.all([
8976
- Promise.all(diffPromises),
8977
- Promise.all(childrenPromises),
8978
- ]);
8979
- return {
8980
- path: node.getPath(),
8981
- diffs,
8982
- children,
8983
- };
8984
- }
8985
-
8986
- class DiffTreeNode {
8987
- constructor(path) {
8988
- this.path = [];
8989
- this.files = [];
8990
- this.children = new Map();
8991
- if (path)
8992
- this.path = path;
8993
- }
8994
- addFile(file) {
8995
- this.files.push(file);
8996
- }
8997
- addChild(part, node) {
8998
- this.children.set(part, node);
8999
- }
9000
- getChild(part) {
9001
- return this.children.get(part);
9002
- }
9003
- getPath() {
9004
- return this.path.join('/');
9005
- }
9006
- print(indentation = 0) {
9007
- const indent = ' '.repeat(indentation);
9008
- let output = `${indent}- Path: ${this.getPath()}\n`;
9009
- if (this.files.length > 0) {
9010
- output += `${indent} Files:\n`;
9011
- for (const file of this.files) {
9012
- output += `${indent} - ${file.summary}\n`;
9013
- }
8766
+ try {
8767
+ const [previousContent, newContent] = await Promise.all([
8768
+ git.show([`${previousCommitHash}:${oldFilePath}`]),
8769
+ git.show([`${newCommitHash}:${nodeFile.filePath}`]),
8770
+ ]);
8771
+ if (previousContent !== newContent) {
8772
+ result = diff.createTwoFilesPatch(oldFilePath, nodeFile.filePath, previousContent, newContent, '', '', {
8773
+ context: 3,
8774
+ });
8775
+ // remove the first 4 lines of the patch (they contain the old and new file names)
8776
+ result = result.split('\n').slice(4).join('\n');
9014
8777
  }
9015
- if (this.children.size > 0) {
9016
- output += `${indent} Children:\n`;
9017
- for (const [, child] of this.children) {
9018
- output += child.print(indentation + 4);
9019
- }
8778
+ else {
8779
+ result = 'File contents are unchanged.';
9020
8780
  }
9021
- return output;
9022
8781
  }
9023
- }
9024
- const createDiffTree = (changes) => {
9025
- const root = new DiffTreeNode();
9026
- for (const change of changes) {
9027
- let currentParent = root;
9028
- const parts = change.filePath.split('/');
9029
- parts.pop();
9030
- for (const part of parts) {
9031
- let childNode = currentParent.getChild(part);
9032
- if (!childNode) {
9033
- childNode = new DiffTreeNode([...currentParent.path, part]);
9034
- currentParent.addChild(part, childNode);
9035
- }
9036
- currentParent = childNode;
9037
- }
9038
- // Create a NodeFile object and add it to the parent
9039
- currentParent.addFile({
9040
- filePath: change.filePath,
9041
- oldFilePath: change.oldFilePath,
9042
- summary: change.summary,
9043
- status: change.status,
9044
- });
8782
+ catch (err) {
8783
+ logger.verbose(`Error comparing file contents for ${nodeFile.filePath}`, { color: 'red' });
8784
+ result = 'Error comparing file contents.';
9045
8785
  }
9046
- return root;
9047
- };
9048
-
8786
+ return result;
8787
+ }
9049
8788
  /**
9050
- * Parses the default file diff for a given nodeFile.
8789
+ * Retrieves the diff for a given file change in a specific commit.
8790
+ * If the file is deleted, it returns a message indicating that the file has been deleted.
8791
+ * If the file is renamed, it parses the renamed file diff and returns it.
8792
+ * Otherwise, it retrieves the default diff from the index and returns it.
9051
8793
  *
9052
8794
  * @param nodeFile - The file change object.
9053
- * @param commit - The commit to diff against. Defaults to '--staged'.
8795
+ * @param commit - The commit hash.
9054
8796
  * @param git - The SimpleGit instance.
9055
- * @returns A Promise that resolves to the file diff as a string.
9056
- */
9057
- async function parseDefaultFileDiff(nodeFile, commit = '--staged', git) {
9058
- if (commit === '--staged') {
9059
- return await git.diff(['--staged', nodeFile.filePath]);
9060
- }
9061
- else if (commit === '--unstaged') {
9062
- return await git.diff([nodeFile.filePath]);
9063
- }
9064
- else if (commit === '--untracked') {
9065
- // For untracked files, read the file content directly from the filesystem
9066
- try {
9067
- const fileContent = await fs.promises.readFile(nodeFile.filePath, 'utf-8');
9068
- return fileContent;
9069
- }
9070
- catch (error) {
9071
- throw new Error(`Error reading untracked file: ${error?.message || 'Unknown error'}`);
9072
- }
9073
- }
9074
- // For branch comparisons, handle files that may not exist in the base branch
9075
- try {
9076
- return await git.diff([commit, nodeFile.filePath]);
9077
- }
9078
- catch (error) {
9079
- const errorMessage = error instanceof Error ? error.message : String(error);
9080
- // If the error indicates the file doesn't exist in the base branch, handle it gracefully
9081
- if (errorMessage.includes('unknown revision or path not in the working tree') ||
9082
- errorMessage.includes('ambiguous argument')) {
9083
- // This is likely a newly added file - show the entire file content as an addition
9084
- if (nodeFile.status === 'added') {
9085
- try {
9086
- const fileContent = await fs.promises.readFile(nodeFile.filePath, 'utf-8');
9087
- return `+++ ${nodeFile.filePath}\n${fileContent.split('\n').map(line => `+${line}`).join('\n')}`;
9088
- }
9089
- catch (fsError) {
9090
- return `Error reading added file ${nodeFile.filePath}: ${fsError instanceof Error ? fsError.message : String(fsError)}`;
9091
- }
9092
- }
9093
- // For other cases, try to get the file content from the current HEAD
9094
- try {
9095
- const fileContent = await git.show([`HEAD:${nodeFile.filePath}`]);
9096
- return `File content from current version:\n${fileContent}`;
9097
- }
9098
- catch (showError) {
9099
- // If all else fails, provide a meaningful error message
9100
- return `Unable to retrieve diff for ${nodeFile.filePath}. File may be newly added or renamed.`;
9101
- }
9102
- }
9103
- // Re-throw other types of errors
9104
- throw error;
9105
- }
9106
- }
9107
- /**
9108
- * Parses the diff for a renamed file.
9109
- *
9110
- * @param nodeFile - The file change object.
9111
- * @param commit - The commit hash or '--staged'.
9112
- * @param git - The SimpleGit instance.
9113
- * @param logger - The logger instance.
9114
- * @returns A Promise that resolves to the diff string.
9115
- */
9116
- async function parseRenamedFileDiff(nodeFile, commit, git, logger) {
9117
- let result = '';
9118
- const oldFilePath = nodeFile?.oldFilePath || nodeFile.filePath;
9119
- let previousCommitHash = 'HEAD';
9120
- let newCommitHash = '';
9121
- if (commit !== '--staged') {
9122
- try {
9123
- previousCommitHash = await git.revparse([`${commit}~1`]);
9124
- }
9125
- catch (err) {
9126
- logger.verbose(`Error getting previous commit hash for ${nodeFile.filePath}`, {
9127
- color: 'red',
9128
- });
9129
- }
9130
- newCommitHash = commit;
9131
- }
9132
- try {
9133
- const [previousContent, newContent] = await Promise.all([
9134
- git.show([`${previousCommitHash}:${oldFilePath}`]),
9135
- git.show([`${newCommitHash}:${nodeFile.filePath}`]),
9136
- ]);
9137
- if (previousContent !== newContent) {
9138
- result = diff.createTwoFilesPatch(oldFilePath, nodeFile.filePath, previousContent, newContent, '', '', {
9139
- context: 3,
9140
- });
9141
- // remove the first 4 lines of the patch (they contain the old and new file names)
9142
- result = result.split('\n').slice(4).join('\n');
9143
- }
9144
- else {
9145
- result = 'File contents are unchanged.';
9146
- }
9147
- }
9148
- catch (err) {
9149
- logger.verbose(`Error comparing file contents for ${nodeFile.filePath}`, { color: 'red' });
9150
- result = 'Error comparing file contents.';
9151
- }
9152
- return result;
9153
- }
9154
- /**
9155
- * Retrieves the diff for a given file change in a specific commit.
9156
- * If the file is deleted, it returns a message indicating that the file has been deleted.
9157
- * If the file is renamed, it parses the renamed file diff and returns it.
9158
- * Otherwise, it retrieves the default diff from the index and returns it.
9159
- *
9160
- * @param nodeFile - The file change object.
9161
- * @param commit - The commit hash.
9162
- * @param git - The SimpleGit instance.
9163
- * @param logger - The logger instance.
9164
- * @returns A promise that resolves to the diff as a string.
8797
+ * @param logger - The logger instance.
8798
+ * @returns A promise that resolves to the diff as a string.
9165
8799
  */
9166
8800
  async function getDiff(nodeFile, commit, { git, logger, }) {
9167
8801
  if (nodeFile.status === 'deleted') {
@@ -10767,7 +10401,7 @@ var RecursiveCharacterTextSplitter = class RecursiveCharacterTextSplitter extend
10767
10401
  };
10768
10402
 
10769
10403
  //#region src/chains/summarization/stuff_prompts.ts
10770
- const template$2 = `Write a concise summary of the following:
10404
+ const template$3 = `Write a concise summary of the following:
10771
10405
 
10772
10406
 
10773
10407
  "{text}"
@@ -10775,7 +10409,7 @@ const template$2 = `Write a concise summary of the following:
10775
10409
 
10776
10410
  CONCISE SUMMARY:`;
10777
10411
  const DEFAULT_PROMPT = /* @__PURE__ */ new prompts$1.PromptTemplate({
10778
- template: template$2,
10412
+ template: template$3,
10779
10413
  inputVariables: ["text"]
10780
10414
  });
10781
10415
 
@@ -11871,46 +11505,658 @@ function simpleEscapeSequence(c) {
11871
11505
  (c === 0x50/* P */) ? '\u2029' : '';
11872
11506
  }
11873
11507
 
11874
- var simpleEscapeCheck = new Array(256); // integer, for fast access
11875
- var simpleEscapeMap = new Array(256);
11876
- for (var i = 0; i < 256; i++) {
11877
- simpleEscapeCheck[i] = simpleEscapeSequence(i) ? 1 : 0;
11878
- simpleEscapeMap[i] = simpleEscapeSequence(i);
11508
+ var simpleEscapeCheck = new Array(256); // integer, for fast access
11509
+ var simpleEscapeMap = new Array(256);
11510
+ for (var i = 0; i < 256; i++) {
11511
+ simpleEscapeCheck[i] = simpleEscapeSequence(i) ? 1 : 0;
11512
+ simpleEscapeMap[i] = simpleEscapeSequence(i);
11513
+ }
11514
+
11515
+ async function fileChangeParser({ changes, commit, options: { tokenizer, git, llm: model, logger, maxTokens, minTokensForSummary, maxFileTokens, maxConcurrent, }, }) {
11516
+ const textSplitter = new RecursiveCharacterTextSplitter({ chunkSize: 10000, chunkOverlap: 250 });
11517
+ const summarizationChain = loadSummarizationChain(model, {
11518
+ type: 'map_reduce',
11519
+ combineMapPrompt: SUMMARIZE_PROMPT,
11520
+ combinePrompt: SUMMARIZE_PROMPT,
11521
+ });
11522
+ logger.startTimer();
11523
+ const rootTreeNode = createDiffTree(changes);
11524
+ logger.stopTimer('Created file hierarchy');
11525
+ // Collect diffs
11526
+ logger.startTimer().startSpinner(`Collecting Diffs...\n`, { color: 'blue' });
11527
+ const diffs = await collectDiffs(rootTreeNode, (path) => getDiff(path, commit, { git, logger }), tokenizer, logger, maxConcurrent);
11528
+ logger.stopSpinner('Diffs Collected').stopTimer();
11529
+ // Summarize diffs using three-phase approach:
11530
+ // 1. Pre-process large files to prevent bias
11531
+ // 2. Group by directory and assess token count
11532
+ // 3. Wave-based parallel summarization until under budget
11533
+ logger.startTimer();
11534
+ const summary = await summarizeDiffs(diffs, {
11535
+ tokenizer,
11536
+ maxTokens: maxTokens || 2048,
11537
+ minTokensForSummary,
11538
+ maxFileTokens,
11539
+ maxConcurrent,
11540
+ textSplitter,
11541
+ chain: summarizationChain,
11542
+ logger,
11543
+ });
11544
+ logger.stopTimer(`\nSummary generated for ${changes.length} staged files`, { color: 'green' });
11545
+ return summary;
11546
+ }
11547
+
11548
+ /**
11549
+ * Retrieves a TikToken for the specified model.
11550
+ *
11551
+ * @param {TiktokenModel} modelName - The name of the TiktokenModel.
11552
+ * @returns A Promise that resolves to the TikToken.
11553
+ */
11554
+ const getTikToken = async (modelName) => {
11555
+ return await tiktoken.encoding_for_model(modelName);
11556
+ };
11557
+ /**
11558
+ * Retrieves the token counter for a given model name.
11559
+ *
11560
+ * @param {TikTokenModel} modelName - The name of the Tiktoken model.
11561
+ * @returns A promise that resolves to a function that calculates the number of tokens in a given text.
11562
+ */
11563
+ const getTokenCounter = async (modelName) => {
11564
+ return getTikToken(modelName).then((tokenizer) => (text) => {
11565
+ const tokens = tokenizer.encode(text);
11566
+ return tokens.length;
11567
+ });
11568
+ };
11569
+
11570
+ const template$2 = `You are a highly skilled software engineer tasked with writing a git changelog. Your response should be informative, well-structured, and in the imperative.
11571
+
11572
+ ## Input
11573
+ You will be provided with a summary of changes. This summary can be one of the following:
11574
+ 1. A list of commits, each with its author, hash, message, and body.
11575
+ 2. A list of commits, each with its details AND the full diff of the changes.
11576
+ 3. A single, comprehensive diff for an entire branch.
11577
+
11578
+ ## Rules
11579
+ - Create a descriptive title for the changelog that gives a high-level overview of the changes.
11580
+ - **BREAKING CHANGES**: Identify any commits that introduce breaking changes. These must be listed first under a "### 💥 BREAKING CHANGES" heading.
11581
+ - **Grouping**: Logically group related changes under descriptive headings (e.g., ### Features, ### Fixes, ### Refactors).
11582
+ - **Dependencies**: Group all dependency updates (e.g., changes to package.json, go.mod) under a "### Dependencies" section.
11583
+ - **Summaries**: For each change, provide a concise summary.
11584
+ - **Attribution**: {{author_instructions}}
11585
+ - **Technical Details**: If provided with diffs, use them to understand the technical details and provide a more accurate and detailed description of the changes.
11586
+ - **Clarity**: Avoid generalizations like "various bug fixes," "improvements," or "enhancements." Be specific.
11587
+ - **Formatting**: Your entire response must be valid Markdown.
11588
+
11589
+ ## Formatting Instructions
11590
+ {{format_instructions}}
11591
+
11592
+ {{additional_context}}
11593
+
11594
+ """{{summary}}"""`;
11595
+ const inputVariables$2 = [
11596
+ 'format_instructions',
11597
+ 'summary',
11598
+ 'additional_context',
11599
+ 'author_instructions',
11600
+ ];
11601
+ const CHANGELOG_PROMPT = new prompts$1.PromptTemplate({
11602
+ template: template$2,
11603
+ inputVariables: inputVariables$2,
11604
+ });
11605
+
11606
+ async function processInWaves(items, processor, maxConcurrent = 6) {
11607
+ const results = [];
11608
+ const limit = Math.max(1, maxConcurrent);
11609
+ for (let i = 0; i < items.length; i += limit) {
11610
+ const waveResults = await Promise.all(items.slice(i, i + limit).map(processor));
11611
+ results.push(...waveResults);
11612
+ }
11613
+ return results;
11614
+ }
11615
+ const handler$4 = async (argv, logger) => {
11616
+ const config = loadConfig(argv);
11617
+ const git = getRepo();
11618
+ const key = getApiKeyForModel(config);
11619
+ const { provider, model } = getModelAndProviderFromConfig(config);
11620
+ const exclusiveOptions = [
11621
+ argv.branch ? '--branch' : null,
11622
+ argv.tag ? '--tag' : null,
11623
+ config.sinceLastTag ? '--since-last-tag' : null,
11624
+ ].filter(Boolean);
11625
+ if (exclusiveOptions.length > 1) {
11626
+ logger.log(`Options ${exclusiveOptions.join(', ')} cannot be used together.`, { color: 'red' });
11627
+ process.exit(1);
11628
+ }
11629
+ if (config.service.authentication.type !== 'None' && !key) {
11630
+ logger.log(`No API Key found. 🗝️🚪`, { color: 'red' });
11631
+ process.exit(1);
11632
+ }
11633
+ const llm = getLlm(provider, model, config);
11634
+ const tokenizer = await getTokenCounter(provider === 'openai' ? model : 'gpt-4o');
11635
+ const INTERACTIVE = isInteractive(config);
11636
+ if (INTERACTIVE) {
11637
+ if (!config.hideCocoBanner) {
11638
+ logger.log(LOGO);
11639
+ }
11640
+ }
11641
+ async function factory() {
11642
+ const branchName = await getCurrentBranchName({ git });
11643
+ if (argv.onlyDiff) {
11644
+ const baseBranch = argv.branch || config.defaultBranch || 'main';
11645
+ logger.verbose(`Generating changelog based on branch diff`, { color: 'yellow' });
11646
+ const diff = await getDiffForBranch({ git, logger, baseBranch, headBranch: branchName });
11647
+ return {
11648
+ branch: branchName,
11649
+ diffChanges: diff.staged,
11650
+ diffCommit: `${baseBranch}..${branchName}`,
11651
+ };
11652
+ }
11653
+ let commits = [];
11654
+ if (config.sinceLastTag) {
11655
+ logger.verbose(`Generating commit log since the last tag`, { color: 'yellow' });
11656
+ // This function returns string[], needs to be adapted or replaced
11657
+ // For now, this path will have limited details.
11658
+ const commitMessages = await getChangesSinceLastTag({ git});
11659
+ commits = commitMessages.map(msg => ({ message: msg }));
11660
+ }
11661
+ else if (config.range && config.range.includes(':')) {
11662
+ const [from, to] = config.range.split(':');
11663
+ if (!from || !to) {
11664
+ logger.log(`Invalid range provided. Expected format is <from>:<to>`, { color: 'red' });
11665
+ process.exit(1);
11666
+ }
11667
+ commits = await getCommitLogRangeDetails(from, to, { git, noMerges: true });
11668
+ }
11669
+ else if (argv.branch) {
11670
+ logger.verbose(`Generating commit log against branch: ${argv.branch}`, { color: 'yellow' });
11671
+ commits = await getCommitLogAgainstBranch({ git, logger, targetBranch: argv.branch });
11672
+ }
11673
+ else if (argv.tag) {
11674
+ logger.verbose(`Generating commit log against tag: ${argv.tag}`, { color: 'yellow' });
11675
+ commits = await getCommitLogAgainstTag({ git, logger, targetTag: argv.tag });
11676
+ }
11677
+ else {
11678
+ logger.verbose(`No range, branch, or tag option provided. Defaulting to current branch`, {
11679
+ color: 'yellow',
11680
+ });
11681
+ commits = await getCommitLogCurrentBranch({ git, logger });
11682
+ }
11683
+ let commitsWithDiffText = commits;
11684
+ if (argv.withDiff) {
11685
+ commitsWithDiffText = await processInWaves(commits, async (commit) => {
11686
+ const changes = await getChangesByCommit({
11687
+ commit: commit.hash,
11688
+ options: {
11689
+ git,
11690
+ ignoredFiles: config.ignoredFiles || undefined,
11691
+ ignoredExtensions: config.ignoredExtensions || undefined,
11692
+ },
11693
+ });
11694
+ return {
11695
+ ...commit,
11696
+ diffText: changes.length > 0
11697
+ ? await fileChangeParser({
11698
+ changes,
11699
+ commit: `${commit.hash}^..${commit.hash}`,
11700
+ options: {
11701
+ tokenizer,
11702
+ git,
11703
+ llm,
11704
+ logger,
11705
+ maxTokens: config.service.tokenLimit,
11706
+ minTokensForSummary: config.service.minTokensForSummary,
11707
+ maxFileTokens: config.service.maxFileTokens,
11708
+ maxConcurrent: config.service.maxConcurrent,
11709
+ },
11710
+ })
11711
+ : undefined,
11712
+ };
11713
+ }, config.service.maxConcurrent);
11714
+ }
11715
+ return {
11716
+ branch: branchName,
11717
+ commits: commitsWithDiffText,
11718
+ withDiff: argv.withDiff,
11719
+ };
11720
+ }
11721
+ async function parser(data) {
11722
+ if (data.diffChanges && data.diffCommit) {
11723
+ const diffSummary = await fileChangeParser({
11724
+ changes: data.diffChanges,
11725
+ commit: data.diffCommit,
11726
+ options: {
11727
+ tokenizer,
11728
+ git,
11729
+ llm,
11730
+ logger,
11731
+ maxTokens: config.service.tokenLimit,
11732
+ minTokensForSummary: config.service.minTokensForSummary,
11733
+ maxFileTokens: config.service.maxFileTokens,
11734
+ maxConcurrent: config.service.maxConcurrent,
11735
+ },
11736
+ });
11737
+ return `## Diff for ${data.branch}\n\n${diffSummary}`;
11738
+ }
11739
+ if (!data.commits || data.commits.length === 0) {
11740
+ return `## ${data.branch}\n\nNo commits found.`;
11741
+ }
11742
+ let result = `## ${data.branch}\n\n`;
11743
+ result += data.commits.map(commit => {
11744
+ let commitStr = `Author: ${commit.author_name}\nCommit: ${commit.hash}\nMessage: ${commit.message}\n${commit.body}`;
11745
+ if (data.withDiff && commit.diffText) {
11746
+ commitStr += `\nDiff:\n${commit.diffText}`;
11747
+ }
11748
+ return commitStr.trim();
11749
+ }).join('\n\n---\n\n');
11750
+ return result;
11751
+ }
11752
+ const changelogMsg = await generateAndReviewLoop({
11753
+ label: 'changelog',
11754
+ options: {
11755
+ ...config,
11756
+ prompt: config.prompt || CHANGELOG_PROMPT.template,
11757
+ logger,
11758
+ interactive: INTERACTIVE,
11759
+ review: {
11760
+ enableFullRetry: false,
11761
+ },
11762
+ },
11763
+ factory,
11764
+ parser,
11765
+ agent: async (context, options) => {
11766
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
11767
+ const parser = createSchemaParser(ChangelogResponseSchema, llm);
11768
+ const prompt = getPrompt({
11769
+ template: options.prompt,
11770
+ variables: CHANGELOG_PROMPT.inputVariables,
11771
+ fallback: CHANGELOG_PROMPT,
11772
+ });
11773
+ const formatInstructions = "Only respond with a valid JSON object, containing two fields: 'title' an escaped string, no more than 65 characters, and 'content' also an escaped string.";
11774
+ let additional_context = '';
11775
+ if (argv.additional) {
11776
+ additional_context = `## Additional Context\n${argv.additional}`;
11777
+ }
11778
+ const author_instructions = argv.author
11779
+ ? 'At the end of each item, attribute the author and include a reference to the commit hash, like this: `by @author_name (f6dbe61)`. Use the first 7 characters of the hash.'
11780
+ : 'At the end of each item, include a reference to the commit hash, like this: `(f6dbe61)`. Use the first 7 characters of the hash.';
11781
+ const variables = {
11782
+ summary: context,
11783
+ format_instructions: formatInstructions,
11784
+ additional_context: additional_context,
11785
+ author_instructions: author_instructions,
11786
+ };
11787
+ const budgetedPrompt = await enforcePromptBudget({
11788
+ prompt,
11789
+ variables,
11790
+ tokenizer,
11791
+ maxTokens: config.service.tokenLimit || 2048,
11792
+ });
11793
+ if (budgetedPrompt.truncated) {
11794
+ logger.verbose(`Rendered prompt exceeded token budget; trimmed summary to ${budgetedPrompt.promptTokenCount} tokens.`, { color: 'yellow' });
11795
+ }
11796
+ const changelog = await executeChain({
11797
+ llm,
11798
+ prompt,
11799
+ variables: budgetedPrompt.variables,
11800
+ parser,
11801
+ });
11802
+ const branchName = await getCurrentBranchName({ git });
11803
+ const ticketId = extractTicketIdFromBranchName(branchName);
11804
+ const footer = ticketId ? `\n\nPart of **${ticketId}**` : '';
11805
+ return `${changelog.title}\n\n${changelog.content}${footer}`;
11806
+ },
11807
+ noResult: async () => {
11808
+ if (config.range) {
11809
+ logger.log(`No commits found in the provided range.`, { color: 'red' });
11810
+ process.exit(0);
11811
+ }
11812
+ logger.log(`No commits found in the current branch.`, { color: 'red' });
11813
+ process.exit(0);
11814
+ },
11815
+ });
11816
+ const MODE = (INTERACTIVE && 'interactive') || (config.commit && 'interactive') || config?.mode || 'stdout';
11817
+ handleResult({
11818
+ result: changelogMsg,
11819
+ interactiveModeCallback: async () => {
11820
+ logSuccess();
11821
+ },
11822
+ mode: MODE,
11823
+ });
11824
+ };
11825
+
11826
+ var changelog = {
11827
+ command: command$4,
11828
+ desc: 'Generate a changelog from current or target branch, provided commit range, or since the last tag.',
11829
+ builder: builder$4,
11830
+ handler: commandExecutor(handler$4),
11831
+ options: options$4,
11832
+ };
11833
+
11834
+ const conventionalTypeRegex = /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?!?:/;
11835
+ // Regular commit message schema with basic validation
11836
+ const CommitMessageResponseSchema = objectType({
11837
+ title: stringType().describe("Title of the commit message"),
11838
+ body: stringType().describe("Body of the commit message"),
11839
+ }).describe("Object with commit message 'title' and 'body'");
11840
+ // Conventional commit message schema with strict formatting rules
11841
+ const ConventionalCommitMessageResponseSchema = objectType({
11842
+ title: stringType()
11843
+ .max(50, "Title must be 50 characters or less")
11844
+ .refine((title) => conventionalTypeRegex.test(title), "Title must follow Conventional Commits format (e.g., 'feat: add new feature' or 'fix(scope): fix bug')").describe("Title of the commit message"),
11845
+ body: stringType().describe("Body of the commit message")
11846
+ // .max(280, "Body must be 280 characters or less"),
11847
+ }).describe("Object with Conventional Commit message 'title' and 'body' adhering to Conventional Commits specification");
11848
+ const command$3 = 'commit';
11849
+ /**
11850
+ * Command line options via yargs
11851
+ */
11852
+ const options$3 = {
11853
+ i: {
11854
+ alias: 'interactive',
11855
+ description: 'Toggle interactive mode',
11856
+ type: 'boolean',
11857
+ },
11858
+ ignoredFiles: {
11859
+ description: 'Ignored files',
11860
+ type: 'array',
11861
+ },
11862
+ ignoredExtensions: {
11863
+ description: 'Ignored extensions',
11864
+ type: 'array',
11865
+ },
11866
+ append: {
11867
+ description: 'Add content to the end of the generated commit message',
11868
+ type: 'string',
11869
+ },
11870
+ appendTicket: {
11871
+ description: 'Append ticket ID from branch name to the commit message',
11872
+ type: 'boolean',
11873
+ alias: 't',
11874
+ },
11875
+ additional: {
11876
+ description: 'Add extra contextual information to the prompt',
11877
+ type: 'string',
11878
+ alias: 'a',
11879
+ },
11880
+ withPreviousCommits: {
11881
+ description: 'Include previous commits as context (specify number of commits, 0 for none)',
11882
+ type: 'number',
11883
+ default: 0,
11884
+ alias: 'p',
11885
+ },
11886
+ conventional: {
11887
+ description: 'Generate commit message in Conventional Commits format',
11888
+ type: 'boolean',
11889
+ default: false,
11890
+ alias: 'c',
11891
+ },
11892
+ includeBranchName: {
11893
+ description: 'Include the current branch name in the commit prompt for context',
11894
+ type: 'boolean',
11895
+ default: true,
11896
+ },
11897
+ noDiff: {
11898
+ description: 'Only pass basic "git status" result instead of providing entire diff',
11899
+ type: 'boolean',
11900
+ default: false,
11901
+ },
11902
+ noVerify: {
11903
+ description: 'Skip pre-commit and commit-msg hooks (passes --no-verify to git commit)',
11904
+ type: 'boolean',
11905
+ default: false,
11906
+ alias: 'n',
11907
+ },
11908
+ };
11909
+ const builder$3 = (yargs) => {
11910
+ return yargs.options(options$3).usage(getCommandUsageHeader(command$3));
11911
+ };
11912
+
11913
+ /**
11914
+ * High-level function that combines chain execution with schema-based parsing
11915
+ * Includes automatic retry logic and graceful degradation
11916
+ * @param schema - Zod schema for the expected output structure
11917
+ * @param llm - LLM instance
11918
+ * @param prompt - Prompt template
11919
+ * @param variables - Variables for the prompt
11920
+ * @param options - Configuration options
11921
+ * @returns Parsed result matching the schema type
11922
+ */
11923
+ async function executeChainWithSchema(schema, llm, prompt, variables, options = {}) {
11924
+ const { retryOptions = { maxAttempts: 3 }, fallbackParser, onFallback, ...parserOptions } = options;
11925
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
11926
+ const parser = createSchemaParser(schema, llm, parserOptions);
11927
+ const operation = async () => {
11928
+ const result = await executeChain({
11929
+ llm,
11930
+ prompt,
11931
+ variables,
11932
+ parser,
11933
+ });
11934
+ return result;
11935
+ };
11936
+ try {
11937
+ return await withRetry(operation, retryOptions);
11938
+ }
11939
+ catch (error) {
11940
+ if (fallbackParser) {
11941
+ if (onFallback) {
11942
+ onFallback();
11943
+ }
11944
+ const fallbackResult = await executeChain({
11945
+ llm,
11946
+ prompt,
11947
+ variables,
11948
+ parser: new output_parsers.StringOutputParser(),
11949
+ });
11950
+ const fallbackText = typeof fallbackResult === 'string' ? fallbackResult : String(fallbackResult);
11951
+ return fallbackParser(fallbackText);
11952
+ }
11953
+ // No fallback available, re-throw the error
11954
+ throw error;
11955
+ }
11956
+ }
11957
+
11958
+ /**
11959
+ * Utility to repair common JSON formatting issues that LLMs make
11960
+ * Specifically handles cases where string values are not properly quoted
11961
+ */
11962
+ function repairJson(jsonString) {
11963
+ // Remove any markdown code block wrapping
11964
+ let cleaned = jsonString.replace(/```(?:json)?\s*([\s\S]*?)\s*```/g, '$1').trim();
11965
+ // Remove inline code block wrapping
11966
+ cleaned = cleaned.replace(/^`(.*)`$/, '$1').trim();
11967
+ // If it doesn't look like JSON, return as-is
11968
+ if (!cleaned.startsWith('{') || !cleaned.endsWith('}')) {
11969
+ return jsonString;
11970
+ }
11971
+ try {
11972
+ // First try parsing as-is
11973
+ JSON.parse(cleaned);
11974
+ return cleaned;
11975
+ }
11976
+ catch {
11977
+ // Try to repair common issues
11978
+ let repaired = cleaned;
11979
+ // Fix unquoted string values in title and body fields
11980
+ // Pattern: "title": unquoted_value, -> "title": "unquoted_value",
11981
+ repaired = repaired.replace(/"(title|body)":\s*([^",\{\}\[\]]+?)(?=\s*[,\}])/g, (match, field, value) => {
11982
+ // Clean up the value (remove leading/trailing whitespace)
11983
+ const cleanValue = value.trim();
11984
+ // If it's already quoted or looks like a number/boolean, leave it
11985
+ if (cleanValue.startsWith('"') || /^(true|false|\d+)$/.test(cleanValue)) {
11986
+ return match;
11987
+ }
11988
+ // Quote the value
11989
+ return `"${field}": "${cleanValue}"`;
11990
+ });
11991
+ // Fix missing quotes around field names (though this should be rare)
11992
+ repaired = repaired.replace(/([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)\s*:/g, '$1"$2":');
11993
+ // Remove trailing commas before closing braces
11994
+ repaired = repaired.replace(/,(\s*[}\]])/g, '$1');
11995
+ try {
11996
+ // Test if the repair worked
11997
+ JSON.parse(repaired);
11998
+ return repaired;
11999
+ }
12000
+ catch {
12001
+ // If repair failed, return original
12002
+ return jsonString;
12003
+ }
12004
+ }
12005
+ }
12006
+
12007
+ /**
12008
+ * Extract the first complete JSON object from a string by tracking balanced braces
12009
+ */
12010
+ function extractFirstJsonObject(text) {
12011
+ const startIndex = text.indexOf('{');
12012
+ if (startIndex === -1)
12013
+ return null;
12014
+ let braceCount = 0;
12015
+ let inString = false;
12016
+ let escapeNext = false;
12017
+ for (let i = startIndex; i < text.length; i++) {
12018
+ const char = text[i];
12019
+ if (escapeNext) {
12020
+ escapeNext = false;
12021
+ continue;
12022
+ }
12023
+ if (char === '\\') {
12024
+ escapeNext = true;
12025
+ continue;
12026
+ }
12027
+ if (char === '"') {
12028
+ inString = !inString;
12029
+ continue;
12030
+ }
12031
+ if (inString)
12032
+ continue;
12033
+ if (char === '{') {
12034
+ braceCount++;
12035
+ }
12036
+ else if (char === '}') {
12037
+ braceCount--;
12038
+ if (braceCount === 0) {
12039
+ // Found the end of the first complete JSON object
12040
+ return text.substring(startIndex, i + 1);
12041
+ }
12042
+ }
12043
+ }
12044
+ return null;
11879
12045
  }
11880
-
11881
- async function fileChangeParser({ changes, commit, options: { tokenizer, git, llm: model, logger, maxTokens, minTokensForSummary, maxFileTokens, maxConcurrent, }, }) {
11882
- const textSplitter = new RecursiveCharacterTextSplitter({ chunkSize: 10000, chunkOverlap: 250 });
11883
- const summarizationChain = loadSummarizationChain(model, {
11884
- type: 'map_reduce',
11885
- combineMapPrompt: SUMMARIZE_PROMPT,
11886
- combinePrompt: SUMMARIZE_PROMPT,
11887
- });
11888
- logger.startTimer();
11889
- const rootTreeNode = createDiffTree(changes);
11890
- logger.stopTimer('Created file hierarchy');
11891
- // Collect diffs
11892
- logger.startTimer().startSpinner(`Collecting Diffs...\n`, { color: 'blue' });
11893
- const diffs = await collectDiffs(rootTreeNode, (path) => getDiff(path, commit, { git, logger }), tokenizer, logger);
11894
- logger.stopSpinner('Diffs Collected').stopTimer();
11895
- // Summarize diffs using three-phase approach:
11896
- // 1. Pre-process large files to prevent bias
11897
- // 2. Group by directory and assess token count
11898
- // 3. Wave-based parallel summarization until under budget
11899
- logger.startTimer();
11900
- const summary = await summarizeDiffs(diffs, {
11901
- tokenizer,
11902
- maxTokens: maxTokens || 2048,
11903
- minTokensForSummary,
11904
- maxFileTokens,
11905
- maxConcurrent,
11906
- textSplitter,
11907
- chain: summarizationChain,
11908
- logger,
11909
- });
11910
- logger.stopTimer(`\nSummary generated for ${changes.length} staged files`, { color: 'green' });
11911
- return summary;
12046
+ /**
12047
+ * Utility function to ensure commit messages are properly formatted as strings
12048
+ * rather than JSON objects, whether they come as parsed objects or stringified JSON
12049
+ */
12050
+ function formatCommitMessage(result, options = {}) {
12051
+ const { append, ticketId, appendTicket } = options;
12052
+ // Helper function to construct the final message with appends
12053
+ const constructMessage = (title, body) => {
12054
+ const appendedText = append ? `\n\n${append}` : '';
12055
+ const ticketFooter = appendTicket && ticketId ? `\n\nPart of **${ticketId}**` : '';
12056
+ return `${title}\n\n${body}${appendedText}${ticketFooter}`;
12057
+ };
12058
+ // If it's a string, check if it contains a JSON object (including markdown code blocks)
12059
+ if (typeof result === 'string') {
12060
+ // Early return if string clearly doesn't contain JSON-like content
12061
+ if (!result.includes('{') && !result.includes('"title"')) {
12062
+ return result;
12063
+ }
12064
+ // Handle multiple markdown code block formats and embedded JSON
12065
+ const extractionPatterns = [
12066
+ /```(?:json)?\s*(\{[\s\S]*?\})\s*```/, // Standard markdown blocks
12067
+ /`(\{[\s\S]*?\})`/, // Inline code blocks
12068
+ /^\s*(\{[\s\S]*\})\s*$/, // Raw JSON without blocks (entire string)
12069
+ /(\{[\s\S]*?\})/ // JSON anywhere in text (fallback)
12070
+ ];
12071
+ let jsonString = result;
12072
+ let foundMatch = false;
12073
+ // Try each pattern to extract JSON
12074
+ for (const pattern of extractionPatterns) {
12075
+ const match = result.match(pattern);
12076
+ if (match && match[1]) {
12077
+ jsonString = match[1].trim();
12078
+ foundMatch = true;
12079
+ break;
12080
+ }
12081
+ }
12082
+ // Only attempt JSON parsing if we found potential JSON content
12083
+ if (foundMatch || jsonString.startsWith('{')) {
12084
+ try {
12085
+ // Try to parse as JSON to see if it's a stringified object
12086
+ const parsed = JSON.parse(jsonString);
12087
+ if (parsed &&
12088
+ typeof parsed === 'object' &&
12089
+ typeof parsed.title === 'string' &&
12090
+ typeof parsed.body === 'string' &&
12091
+ parsed.title.length > 0 &&
12092
+ parsed.body.length > 0) {
12093
+ // It's a valid stringified JSON object, format it properly
12094
+ return constructMessage(parsed.title, parsed.body);
12095
+ }
12096
+ }
12097
+ catch {
12098
+ // Try to repair the JSON and parse again
12099
+ try {
12100
+ const repairedJson = repairJson(jsonString);
12101
+ const parsed = JSON.parse(repairedJson);
12102
+ if (parsed &&
12103
+ typeof parsed === 'object' &&
12104
+ typeof parsed.title === 'string' &&
12105
+ typeof parsed.body === 'string' &&
12106
+ parsed.title.length > 0 &&
12107
+ parsed.body.length > 0) {
12108
+ // Successfully repaired and parsed JSON
12109
+ return constructMessage(parsed.title, parsed.body);
12110
+ }
12111
+ }
12112
+ catch {
12113
+ // Repair failed, try extracting just the first complete JSON object
12114
+ const firstObject = extractFirstJsonObject(jsonString);
12115
+ if (firstObject) {
12116
+ try {
12117
+ const parsed = JSON.parse(firstObject);
12118
+ if (parsed &&
12119
+ typeof parsed === 'object' &&
12120
+ typeof parsed.title === 'string' &&
12121
+ typeof parsed.body === 'string' &&
12122
+ parsed.title.length > 0 &&
12123
+ parsed.body.length > 0) {
12124
+ return constructMessage(parsed.title, parsed.body);
12125
+ }
12126
+ }
12127
+ catch {
12128
+ // Even first object extraction failed, continue to fallback
12129
+ }
12130
+ }
12131
+ }
12132
+ }
12133
+ }
12134
+ // If no JSON found and it's already formatted, return as-is
12135
+ return result;
12136
+ }
12137
+ // If it's already an object with title and body, format it
12138
+ if (typeof result === 'object' && result !== null &&
12139
+ 'title' in result && 'body' in result) {
12140
+ const commitMsgObj = result;
12141
+ if (typeof commitMsgObj.title === 'string' && typeof commitMsgObj.body === 'string') {
12142
+ return constructMessage(commitMsgObj.title, commitMsgObj.body);
12143
+ }
12144
+ }
12145
+ // Fallback - convert to string and return as-is
12146
+ return String(result);
11912
12147
  }
11913
12148
 
12149
+ /**
12150
+ * Error thrown when a pre-commit hook blocks a commit (e.g. a linter exits non-zero).
12151
+ * Contains the raw hook output so callers can present it cleanly to the user.
12152
+ */
12153
+ class PreCommitHookError extends Error {
12154
+ constructor(hookOutput) {
12155
+ super('Pre-commit hook failed');
12156
+ this.name = 'PreCommitHookError';
12157
+ this.hookOutput = hookOutput;
12158
+ }
12159
+ }
11914
12160
  /**
11915
12161
  * Detects whether a GitError was caused by a pre-commit hook that modified files.
11916
12162
  * These hooks (e.g. black, prettier) reformat files and exit non-zero on the first run,
@@ -11930,15 +12176,19 @@ function isPreCommitHookModifiedFilesError(error) {
11930
12176
  * Creates a commit with the specified commit message.
11931
12177
  * Handles the case where pre-commit hooks modify files (e.g. black, prettier):
11932
12178
  * when detected, stages the hook-modified files and retries the commit once.
12179
+ * Any other GitError (e.g. hook lint failure) is wrapped in a PreCommitHookError
12180
+ * so callers can present it cleanly instead of showing a raw stack trace.
11933
12181
  *
11934
12182
  * @param message The commit message.
11935
12183
  * @param git The SimpleGit instance.
11936
12184
  * @param onHookModifiedFiles Optional callback invoked before the auto-retry so callers can notify the user.
12185
+ * @param options Optional commit options (e.g. noVerify).
11937
12186
  * @returns A Promise that resolves to the CommitResult.
11938
12187
  */
11939
- async function createCommit(message, git, onHookModifiedFiles) {
12188
+ async function createCommit(message, git, onHookModifiedFiles, options) {
12189
+ const flags = options?.noVerify ? ['--no-verify'] : [];
11940
12190
  try {
11941
- return await git.commit(message);
12191
+ return await git.commit(message, flags);
11942
12192
  }
11943
12193
  catch (error) {
11944
12194
  if (isPreCommitHookModifiedFilesError(error)) {
@@ -11948,7 +12198,12 @@ async function createCommit(message, git, onHookModifiedFiles) {
11948
12198
  }
11949
12199
  // Stage all hook-modified files and retry the commit once
11950
12200
  await git.add('.');
11951
- return await git.commit(message);
12201
+ return await git.commit(message, flags);
12202
+ }
12203
+ // Wrap any other GitError so callers can present it cleanly rather than
12204
+ // showing a raw Node.js stack trace originating from simple-git internals.
12205
+ if (error instanceof simpleGit.GitError) {
12206
+ throw new PreCommitHookError(error.message);
11952
12207
  }
11953
12208
  throw error;
11954
12209
  }
@@ -11961,7 +12216,7 @@ async function createCommit(message, git, onHookModifiedFiles) {
11961
12216
  * @returns {Promise<GetChangesResult>} A promise that resolves to the changes in the Git repository.
11962
12217
  */
11963
12218
  async function getChanges({ git, options }) {
11964
- const { ignoredFiles = DEFAULT_IGNORED_FILES, ignoredExtensions = DEFAULT_IGNORED_EXTENSIONS } = options || {};
12219
+ const { ignoredFiles = DEFAULT_IGNORED_FILES$1, ignoredExtensions = DEFAULT_IGNORED_EXTENSIONS$1 } = options || {};
11965
12220
  const staged = [];
11966
12221
  const unstaged = [];
11967
12222
  const untracked = [];
@@ -12055,28 +12310,6 @@ async function getPreviousCommits(options) {
12055
12310
  }
12056
12311
  }
12057
12312
 
12058
- /**
12059
- * Retrieves a TikToken for the specified model.
12060
- *
12061
- * @param {TiktokenModel} modelName - The name of the TiktokenModel.
12062
- * @returns A Promise that resolves to the TikToken.
12063
- */
12064
- const getTikToken = async (modelName) => {
12065
- return await tiktoken.encoding_for_model(modelName);
12066
- };
12067
- /**
12068
- * Retrieves the token counter for a given model name.
12069
- *
12070
- * @param {TikTokenModel} modelName - The name of the Tiktoken model.
12071
- * @returns A promise that resolves to a function that calculates the number of tokens in a given text.
12072
- */
12073
- const getTokenCounter = async (modelName) => {
12074
- return getTikToken(modelName).then((tokenizer) => (text) => {
12075
- const tokens = tokenizer.encode(text);
12076
- return tokens.length;
12077
- });
12078
- };
12079
-
12080
12313
  const COMMITLINT_CONFIG_FILES = [
12081
12314
  '.commitlintrc',
12082
12315
  '.commitlintrc.json',
@@ -12385,7 +12618,16 @@ IMPORTANT RULES:
12385
12618
  ? `${variables.additional_context}\n\n## Validation Errors from Previous Attempt\nPlease fix the following issues:\n${validationErrors}`
12386
12619
  : variables.additional_context,
12387
12620
  };
12388
- const commitMsg = await executeChainWithSchema(schema, llm, prompt, currentVariables, {
12621
+ const budgetedPrompt = await enforcePromptBudget({
12622
+ prompt,
12623
+ variables: currentVariables,
12624
+ tokenizer,
12625
+ maxTokens: config.service.tokenLimit || 2048,
12626
+ });
12627
+ if (budgetedPrompt.truncated) {
12628
+ logger.verbose(`Rendered prompt exceeded token budget; trimmed summary to ${budgetedPrompt.promptTokenCount} tokens.`, { color: 'yellow' });
12629
+ }
12630
+ const commitMsg = await executeChainWithSchema(schema, llm, prompt, budgetedPrompt.variables, {
12389
12631
  retryOptions: {
12390
12632
  maxAttempts,
12391
12633
  onRetry: (attempt, error) => {
@@ -12530,10 +12772,67 @@ IMPORTANT RULES:
12530
12772
  handleResult({
12531
12773
  result: commitMsg,
12532
12774
  interactiveModeCallback: async (result) => {
12533
- await createCommit(result, git, () => {
12534
- logger.log('⚠️ Pre-commit hook modified files. Staging changes and retrying commit...', { color: 'yellow' });
12535
- });
12536
- logSuccess();
12775
+ const noVerify = argv.noVerify || config.noVerify || false;
12776
+ const attemptCommit = async (skipHooks) => {
12777
+ try {
12778
+ await createCommit(result, git, () => {
12779
+ logger.log('⚠️ Pre-commit hook modified files. Staging changes and retrying commit...', { color: 'yellow' });
12780
+ }, { noVerify: skipHooks });
12781
+ logSuccess();
12782
+ }
12783
+ catch (error) {
12784
+ if (error instanceof PreCommitHookError) {
12785
+ // Display friendly hook failure output
12786
+ logger.log('\n✖ Commit blocked by pre-commit hook', { color: 'red' });
12787
+ logger.log('\nHook output:', { color: 'yellow' });
12788
+ logger.log(SEPERATOR);
12789
+ logger.log(error.hookOutput);
12790
+ logger.log(SEPERATOR);
12791
+ if (INTERACTIVE) {
12792
+ const { select } = await import('@inquirer/prompts');
12793
+ const choice = await select({
12794
+ message: 'How would you like to proceed?',
12795
+ choices: [
12796
+ {
12797
+ name: '🔄 Retry',
12798
+ value: 'retry',
12799
+ description: 'Fix the issues above and retry the commit',
12800
+ },
12801
+ {
12802
+ name: '⚠️ Skip hooks',
12803
+ value: 'skip',
12804
+ description: 'Retry with --no-verify to bypass pre-commit hooks (use with care)',
12805
+ },
12806
+ {
12807
+ name: '💣 Abort',
12808
+ value: 'abort',
12809
+ description: 'Abort the commit',
12810
+ },
12811
+ ],
12812
+ });
12813
+ if (choice === 'retry') {
12814
+ await attemptCommit(false);
12815
+ }
12816
+ else if (choice === 'skip') {
12817
+ logger.log('⚠️ Skipping hooks with --no-verify...', { color: 'yellow' });
12818
+ await attemptCommit(true);
12819
+ }
12820
+ else {
12821
+ logger.log('\nCommit aborted.', { color: 'red' });
12822
+ process.exit(1);
12823
+ }
12824
+ }
12825
+ else {
12826
+ logger.log('\nFix the issues above and try again, or use --no-verify to skip hooks.', { color: 'yellow' });
12827
+ process.exit(1);
12828
+ }
12829
+ }
12830
+ else {
12831
+ throw error;
12832
+ }
12833
+ }
12834
+ };
12835
+ await attemptCommit(noVerify);
12537
12836
  },
12538
12837
  mode: MODE,
12539
12838
  });