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