git-coco 0.1.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/LICENSE +21 -0
- package/README.md +120 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.esm.mjs +1074 -0
- package/dist/index.esm.mjs.map +1 -0
- package/dist/index.js +1098 -0
- package/dist/lib/config/default.d.ts +7 -0
- package/dist/lib/config/index.d.ts +21 -0
- package/dist/lib/config/services/env.d.ts +8 -0
- package/dist/lib/config/services/git.d.ts +8 -0
- package/dist/lib/config/services/ignore.d.ts +15 -0
- package/dist/lib/config/services/project.d.ts +8 -0
- package/dist/lib/config/services/xdg.d.ts +8 -0
- package/dist/lib/config/services/yargs.d.ts +25 -0
- package/dist/lib/config/types.d.ts +63 -0
- package/dist/lib/langchain/chains/llm.d.ts +6 -0
- package/dist/lib/langchain/chains/summarize.d.ts +11 -0
- package/dist/lib/langchain/prompts/commitDefault.d.ts +3 -0
- package/dist/lib/langchain/prompts/summarize.d.ts +3 -0
- package/dist/lib/langchain/utils.d.ts +20 -0
- package/dist/lib/parsers/default/fileChangeParser.d.ts +2 -0
- package/dist/lib/parsers/default/utils/collectDiffs.d.ts +8 -0
- package/dist/lib/parsers/default/utils/createDiffTree.d.ts +12 -0
- package/dist/lib/parsers/default/utils/parseFileDiff.d.ts +4 -0
- package/dist/lib/parsers/default/utils/summarizeDiffs.d.ts +24 -0
- package/dist/lib/parsers/noResult.d.ts +8 -0
- package/dist/lib/types.d.ts +34 -0
- package/dist/lib/ui.d.ts +2 -0
- package/dist/lib/utils/getPathFromFilePath.d.ts +6 -0
- package/dist/lib/utils/getTokenizer.d.ts +9 -0
- package/dist/lib/utils/getTruncatedFilePath.d.ts +1 -0
- package/dist/lib/utils/git/constants.d.ts +1 -0
- package/dist/lib/utils/git/createCommit.d.ts +2 -0
- package/dist/lib/utils/git/getChanges.d.ts +43 -0
- package/dist/lib/utils/git/getStatus.d.ts +3 -0
- package/dist/lib/utils/git/getSummaryText.d.ts +2 -0
- package/dist/lib/utils/git/parsePatches.d.ts +18 -0
- package/dist/lib/utils/logger.d.ts +23 -0
- package/dist/lib/utils/readFile.d.ts +3 -0
- package/dist/lib/utils/removeUndefined.d.ts +9 -0
- package/dist/stats.html +5305 -0
- package/dist/types.d.ts +2 -0
- package/package.json +92 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1098 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
var nodegit = require('nodegit');
|
|
5
|
+
var prompts$1 = require('@inquirer/prompts');
|
|
6
|
+
var fs = require('fs');
|
|
7
|
+
var os = require('os');
|
|
8
|
+
var path = require('path');
|
|
9
|
+
var ini = require('ini');
|
|
10
|
+
var yargs = require('yargs');
|
|
11
|
+
var helpers = require('yargs/helpers');
|
|
12
|
+
var prompts = require('langchain/prompts');
|
|
13
|
+
var PQueue = require('p-queue');
|
|
14
|
+
var chalk = require('chalk');
|
|
15
|
+
var ora = require('ora');
|
|
16
|
+
var now = require('performance-now');
|
|
17
|
+
var prettyMilliseconds = require('pretty-ms');
|
|
18
|
+
var document = require('langchain/document');
|
|
19
|
+
var diff = require('diff');
|
|
20
|
+
var util = require('util');
|
|
21
|
+
var chains = require('langchain/chains');
|
|
22
|
+
var openai = require('langchain/llms/openai');
|
|
23
|
+
var text_splitter = require('langchain/text_splitter');
|
|
24
|
+
var GPT3NodeTokenizer = require('gpt3-tokenizer');
|
|
25
|
+
var minimatch = require('minimatch');
|
|
26
|
+
|
|
27
|
+
function _interopNamespaceDefault(e) {
|
|
28
|
+
var n = Object.create(null);
|
|
29
|
+
if (e) {
|
|
30
|
+
Object.keys(e).forEach(function (k) {
|
|
31
|
+
if (k !== 'default') {
|
|
32
|
+
var d = Object.getOwnPropertyDescriptor(e, k);
|
|
33
|
+
Object.defineProperty(n, k, d.get ? d : {
|
|
34
|
+
enumerable: true,
|
|
35
|
+
get: function () { return e[k]; }
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
n.default = e;
|
|
41
|
+
return Object.freeze(n);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
var nodegit__namespace = /*#__PURE__*/_interopNamespaceDefault(nodegit);
|
|
45
|
+
var fs__namespace = /*#__PURE__*/_interopNamespaceDefault(fs);
|
|
46
|
+
var os__namespace = /*#__PURE__*/_interopNamespaceDefault(os);
|
|
47
|
+
var path__namespace = /*#__PURE__*/_interopNamespaceDefault(path);
|
|
48
|
+
var ini__namespace = /*#__PURE__*/_interopNamespaceDefault(ini);
|
|
49
|
+
var util__namespace = /*#__PURE__*/_interopNamespaceDefault(util);
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Returns a new object with all undefined keys removed
|
|
53
|
+
*
|
|
54
|
+
* @param obj Object to remove undefined keys from
|
|
55
|
+
* @returns
|
|
56
|
+
*/
|
|
57
|
+
function removeUndefined(obj) {
|
|
58
|
+
return Object.fromEntries(Object.entries(obj).filter(([, value]) => value !== undefined));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Load environment variables
|
|
63
|
+
*
|
|
64
|
+
* @param {Config} config
|
|
65
|
+
* @returns {Config} Updated config
|
|
66
|
+
**/
|
|
67
|
+
function loadEnvConfig(config) {
|
|
68
|
+
const envConfig = {
|
|
69
|
+
openAIApiKey: process.env.OPENAI_API_KEY || undefined,
|
|
70
|
+
tokenLimit: process.env.COCO_TOKEN_LIMIT
|
|
71
|
+
? parseInt(process.env.COCO_TOKEN_LIMIT)
|
|
72
|
+
: undefined,
|
|
73
|
+
prompt: process.env.COCO_PROMPT,
|
|
74
|
+
mode: process.env.COCO_MODE,
|
|
75
|
+
summarizePrompt: process.env.COCO_SUMMARIZE_PROMPT,
|
|
76
|
+
ignoredFiles: process.env.COCO_IGNORED_FILES
|
|
77
|
+
? process.env.COCO_IGNORED_FILES.split(',')
|
|
78
|
+
: undefined,
|
|
79
|
+
ignoredExtensions: process.env.COCO_IGNORED_EXTENSIONS
|
|
80
|
+
? process.env.COCO_IGNORED_EXTENSIONS.split(',')
|
|
81
|
+
: undefined,
|
|
82
|
+
};
|
|
83
|
+
config = { ...config, ...removeUndefined(envConfig) };
|
|
84
|
+
return config;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Load git profile config (from ~/.gitconfig)
|
|
89
|
+
*
|
|
90
|
+
* @param {Config} config
|
|
91
|
+
* @returns {Config} Updated config
|
|
92
|
+
**/
|
|
93
|
+
function loadGitConfig(config) {
|
|
94
|
+
const gitConfigPath = path__namespace.join(os__namespace.homedir(), '.gitconfig');
|
|
95
|
+
if (fs__namespace.existsSync(gitConfigPath)) {
|
|
96
|
+
const gitConfigRaw = fs__namespace.readFileSync(gitConfigPath, 'utf-8');
|
|
97
|
+
const gitConfigParsed = ini__namespace.parse(gitConfigRaw);
|
|
98
|
+
config = {
|
|
99
|
+
...config,
|
|
100
|
+
openAIApiKey: gitConfigParsed.coco?.openAIApiKey || config.openAIApiKey,
|
|
101
|
+
tokenLimit: parseInt(gitConfigParsed.coco?.tokenLimit) || config.tokenLimit,
|
|
102
|
+
prompt: gitConfigParsed.coco?.prompt || config.prompt,
|
|
103
|
+
mode: gitConfigParsed.coco?.mode || config.mode,
|
|
104
|
+
temperature: gitConfigParsed.coco?.temperature || config.temperature,
|
|
105
|
+
summarizePrompt: gitConfigParsed.coco?.summarizePrompt || config.summarizePrompt,
|
|
106
|
+
ignoredFiles: gitConfigParsed.coco?.ignoredFiles || config.ignoredFiles,
|
|
107
|
+
ignoredExtensions: gitConfigParsed.coco?.ignoredExtensions || config.ignoredExtensions,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
return config;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Load .gitignore in project root
|
|
115
|
+
*
|
|
116
|
+
* @param {Config} config
|
|
117
|
+
* @returns
|
|
118
|
+
*/
|
|
119
|
+
function loadGitignore(config) {
|
|
120
|
+
if (fs__namespace.existsSync('.gitignore')) {
|
|
121
|
+
const gitignoreContent = fs__namespace.readFileSync('.gitignore', 'utf-8');
|
|
122
|
+
config.ignoredFiles = [
|
|
123
|
+
...(config?.ignoredFiles || []),
|
|
124
|
+
...gitignoreContent.split('\n').filter((line) => line.trim() !== '' && !line.startsWith('#')),
|
|
125
|
+
];
|
|
126
|
+
}
|
|
127
|
+
return config;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Load .ignore in project root
|
|
131
|
+
*
|
|
132
|
+
* @param {Config} config
|
|
133
|
+
* @returns
|
|
134
|
+
*/
|
|
135
|
+
function loadIgnore(config) {
|
|
136
|
+
if (fs__namespace.existsSync('.ignore')) {
|
|
137
|
+
const ignoreContent = fs__namespace.readFileSync('.ignore', 'utf-8');
|
|
138
|
+
config.ignoredFiles = [
|
|
139
|
+
...(config?.ignoredFiles || []),
|
|
140
|
+
...ignoreContent.split('\n').filter((line) => line.trim() !== '' && !line.startsWith('#')),
|
|
141
|
+
];
|
|
142
|
+
}
|
|
143
|
+
return config;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Load project config
|
|
148
|
+
*
|
|
149
|
+
* @param {Config} config
|
|
150
|
+
* @returns {Config} Updated config
|
|
151
|
+
**/
|
|
152
|
+
function loadProjectConfig(config) {
|
|
153
|
+
if (fs__namespace.existsSync('.coco.config.json')) {
|
|
154
|
+
const projectConfig = JSON.parse(fs__namespace.readFileSync('.coco.config.json', 'utf-8'));
|
|
155
|
+
config = { ...config, ...projectConfig };
|
|
156
|
+
}
|
|
157
|
+
return config;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Load XDG config
|
|
162
|
+
*
|
|
163
|
+
* @param {Config} config
|
|
164
|
+
* @returns {Config} Updated config
|
|
165
|
+
*/
|
|
166
|
+
function loadXDGConfig(config) {
|
|
167
|
+
const xdgConfigHome = process.env.XDG_CONFIG_HOME || path__namespace.join(os__namespace.homedir(), '.config');
|
|
168
|
+
const xdgConfigPath = path__namespace.join(xdgConfigHome, 'coco', 'config.json');
|
|
169
|
+
if (fs__namespace.existsSync(xdgConfigPath)) {
|
|
170
|
+
const xdgConfig = JSON.parse(fs__namespace.readFileSync(xdgConfigPath, 'utf-8'));
|
|
171
|
+
config = { ...config, ...xdgConfig };
|
|
172
|
+
}
|
|
173
|
+
return config;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Command line options via yargs
|
|
178
|
+
*/
|
|
179
|
+
const options = {
|
|
180
|
+
openAIApiKey: { type: 'string', description: 'OpenAI API Key' },
|
|
181
|
+
tokenLimit: { type: 'number', description: 'Token limit' },
|
|
182
|
+
prompt: {
|
|
183
|
+
type: 'string',
|
|
184
|
+
alias: 'p',
|
|
185
|
+
description: 'Commit message prompt',
|
|
186
|
+
},
|
|
187
|
+
interactive: {
|
|
188
|
+
type: 'boolean',
|
|
189
|
+
alias: 'i',
|
|
190
|
+
description: 'Toggle interactive mode',
|
|
191
|
+
},
|
|
192
|
+
commit: {
|
|
193
|
+
type: 'boolean',
|
|
194
|
+
alias: 's',
|
|
195
|
+
description: 'Commit staged changes with generated commit message',
|
|
196
|
+
default: false,
|
|
197
|
+
},
|
|
198
|
+
openInEditor: {
|
|
199
|
+
type: 'boolean',
|
|
200
|
+
alias: 'e',
|
|
201
|
+
description: 'Open commit message in editor before proceeding',
|
|
202
|
+
},
|
|
203
|
+
verbose: {
|
|
204
|
+
type: 'boolean',
|
|
205
|
+
description: 'Enable verbose logging',
|
|
206
|
+
},
|
|
207
|
+
summarizePrompt: {
|
|
208
|
+
type: 'string',
|
|
209
|
+
description: 'Large file summary prompt',
|
|
210
|
+
},
|
|
211
|
+
ignoredFiles: {
|
|
212
|
+
type: 'array',
|
|
213
|
+
description: 'Ignored files',
|
|
214
|
+
},
|
|
215
|
+
ignoredExtensions: {
|
|
216
|
+
type: 'array',
|
|
217
|
+
description: 'Ignored extensions',
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
/**
|
|
221
|
+
* Load command line flags via yargs
|
|
222
|
+
*
|
|
223
|
+
* @returns {Partial<Config>} Updated config
|
|
224
|
+
*/
|
|
225
|
+
const loadArgv = () => {
|
|
226
|
+
return yargs(helpers.hideBin(process.argv)).options(options).parseSync();
|
|
227
|
+
};
|
|
228
|
+
/**
|
|
229
|
+
* Load command line flags
|
|
230
|
+
*
|
|
231
|
+
* Note: Arugments are parsed using yargs.
|
|
232
|
+
*
|
|
233
|
+
* @param {Config} config
|
|
234
|
+
* @returns {Config} Updated config
|
|
235
|
+
**/
|
|
236
|
+
function loadCmdLineFlags(config) {
|
|
237
|
+
const argv = loadArgv();
|
|
238
|
+
config = { ...config, ...argv };
|
|
239
|
+
return config;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const template$1 = `Write informative git commit message based on the diffs & file changes provided in the "Diff Summary" section.
|
|
243
|
+
Commit Messages must have a short description that is less than 50 characters followed by a newline character and then a more verbose detailed description.
|
|
244
|
+
- Write concisely using an informal tone
|
|
245
|
+
- List significant changes
|
|
246
|
+
- DO NOT use phrases like "this commit", "this change", etc.
|
|
247
|
+
- DO NOT use specific names or files from the code
|
|
248
|
+
- Wrap variable, class, function, components, and dependency names in back ticks e.g. \`variable\`
|
|
249
|
+
|
|
250
|
+
"""{summary}"""
|
|
251
|
+
|
|
252
|
+
Commit:`;
|
|
253
|
+
const inputVariables$1 = ['summary'];
|
|
254
|
+
const COMMIT_PROMPT = new prompts.PromptTemplate({
|
|
255
|
+
template: template$1,
|
|
256
|
+
inputVariables: inputVariables$1,
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
const template = `GOAL: Use functional abstractions to summarize the following text
|
|
260
|
+
|
|
261
|
+
RULES: Avoid phrases like "this change", "this code", or "this function" etc. Instead refer to the function, variable, or class by name.
|
|
262
|
+
|
|
263
|
+
TEXT:"""{text}"""
|
|
264
|
+
`;
|
|
265
|
+
const inputVariables = ['text'];
|
|
266
|
+
const SUMMARIZE_PROMPT = new prompts.PromptTemplate({
|
|
267
|
+
template,
|
|
268
|
+
inputVariables,
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Default Config
|
|
273
|
+
*
|
|
274
|
+
* @type {Config}
|
|
275
|
+
*/
|
|
276
|
+
const DEFAULT_CONFIG = {
|
|
277
|
+
openAIApiKey: '',
|
|
278
|
+
verbose: false,
|
|
279
|
+
tokenLimit: 1024,
|
|
280
|
+
prompt: COMMIT_PROMPT.template,
|
|
281
|
+
summarizePrompt: SUMMARIZE_PROMPT.template,
|
|
282
|
+
temperature: 0.4,
|
|
283
|
+
mode: 'stdout',
|
|
284
|
+
ignoredFiles: ['package-lock.json'],
|
|
285
|
+
ignoredExtensions: ['.map', '.lock'],
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Load application config
|
|
290
|
+
*
|
|
291
|
+
* Merge config from multiple sources.
|
|
292
|
+
*
|
|
293
|
+
* \* Order of precedence:
|
|
294
|
+
* \* 1. Command line flags
|
|
295
|
+
* \* 2. Environment variables
|
|
296
|
+
* \* 3. Project config
|
|
297
|
+
* \* 4. Git config
|
|
298
|
+
* \* 5. XDG config
|
|
299
|
+
* \* 6. .gitignore
|
|
300
|
+
* \* 7. .ignore
|
|
301
|
+
* \* 8. Default config
|
|
302
|
+
*
|
|
303
|
+
* @returns {Config} application config
|
|
304
|
+
**/
|
|
305
|
+
function loadConfig() {
|
|
306
|
+
// Default config
|
|
307
|
+
let config = DEFAULT_CONFIG;
|
|
308
|
+
config = loadGitignore(config);
|
|
309
|
+
config = loadIgnore(config);
|
|
310
|
+
config = loadXDGConfig(config);
|
|
311
|
+
config = loadGitConfig(config);
|
|
312
|
+
config = loadProjectConfig(config);
|
|
313
|
+
config = loadEnvConfig(config);
|
|
314
|
+
config = loadCmdLineFlags(config);
|
|
315
|
+
return config;
|
|
316
|
+
}
|
|
317
|
+
const config = loadConfig();
|
|
318
|
+
|
|
319
|
+
class Logger {
|
|
320
|
+
constructor(config) {
|
|
321
|
+
this.config = config;
|
|
322
|
+
this.spinner = null;
|
|
323
|
+
}
|
|
324
|
+
log(message, options = { color: 'blue' }) {
|
|
325
|
+
let outputMessage = message;
|
|
326
|
+
if (options.color) {
|
|
327
|
+
outputMessage = chalk[options.color](outputMessage);
|
|
328
|
+
}
|
|
329
|
+
console.log(outputMessage);
|
|
330
|
+
return this;
|
|
331
|
+
}
|
|
332
|
+
verbose(message, options = {}) {
|
|
333
|
+
if (!this.config?.verbose) {
|
|
334
|
+
return this;
|
|
335
|
+
}
|
|
336
|
+
this.log(message, options);
|
|
337
|
+
return this;
|
|
338
|
+
}
|
|
339
|
+
startTimer() {
|
|
340
|
+
this.timerStart = now();
|
|
341
|
+
return this;
|
|
342
|
+
}
|
|
343
|
+
stopTimer(message, options = { color: 'yellow' }) {
|
|
344
|
+
if (!this.config?.verbose || !this.timerStart) {
|
|
345
|
+
return this;
|
|
346
|
+
}
|
|
347
|
+
const elapsedTime = prettyMilliseconds(now() - this.timerStart);
|
|
348
|
+
let outputMessage = message
|
|
349
|
+
? `${message} (âē ${elapsedTime})`
|
|
350
|
+
: `âē ${elapsedTime}`;
|
|
351
|
+
if (options.color) {
|
|
352
|
+
outputMessage = chalk[options.color](outputMessage);
|
|
353
|
+
}
|
|
354
|
+
console.log(outputMessage);
|
|
355
|
+
return this;
|
|
356
|
+
}
|
|
357
|
+
startSpinner(message, options = { color: 'green' }) {
|
|
358
|
+
const spinnerMessage = options.color ? chalk[options.color](message) : message;
|
|
359
|
+
this.spinner = ora(spinnerMessage).start();
|
|
360
|
+
return this;
|
|
361
|
+
}
|
|
362
|
+
stopSpinner(message = '', options = { mode: 'succeed', color: 'green' }) {
|
|
363
|
+
const spinnerMessage = options?.color ? chalk[options.color](message) : message;
|
|
364
|
+
this.spinner?.[options.mode || 'succeed'](spinnerMessage);
|
|
365
|
+
this.spinner = null;
|
|
366
|
+
return this;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Extract the path from a file path string.
|
|
372
|
+
* @param {string} filePath - The full file path.
|
|
373
|
+
* @returns {string} The path portion of the file path.
|
|
374
|
+
*/
|
|
375
|
+
function getPathFromFilePath(filePath) {
|
|
376
|
+
return filePath.split('/').slice(0, -1).join('/');
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function summarize(documents, { chain, textSplitter, options }) {
|
|
380
|
+
const { returnIntermediateSteps = false } = options || {};
|
|
381
|
+
const docs = await textSplitter.splitDocuments(documents.map((doc) => new document.Document(doc)));
|
|
382
|
+
const res = await chain.call({
|
|
383
|
+
input_documents: docs,
|
|
384
|
+
returnIntermediateSteps,
|
|
385
|
+
});
|
|
386
|
+
if (res.error)
|
|
387
|
+
throw new Error(res.error);
|
|
388
|
+
return res.text && res.text.trim();
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Create groups from a given node info.
|
|
393
|
+
* @param {DiffNode} node - The node info to start grouping.
|
|
394
|
+
* @returns {DirectoryDiff[]} The groups created.
|
|
395
|
+
*/
|
|
396
|
+
function createDirectoryDiffs(node) {
|
|
397
|
+
const groupByPath = {};
|
|
398
|
+
function traverse(node) {
|
|
399
|
+
node.diffs.forEach((diff) => {
|
|
400
|
+
const path = getPathFromFilePath(diff.file);
|
|
401
|
+
if (!groupByPath[path]) {
|
|
402
|
+
groupByPath[path] = { diffs: [], path, tokenCount: 0 };
|
|
403
|
+
}
|
|
404
|
+
groupByPath[path].diffs.push(diff);
|
|
405
|
+
groupByPath[path].tokenCount += diff.tokenCount;
|
|
406
|
+
});
|
|
407
|
+
node.children.forEach(traverse);
|
|
408
|
+
}
|
|
409
|
+
traverse(node);
|
|
410
|
+
return Object.values(groupByPath);
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Summarize a directory diff asynchronously.
|
|
414
|
+
*/
|
|
415
|
+
async function summarizeDirectoryDiff(directory, { chain, textSplitter, tokenizer }) {
|
|
416
|
+
try {
|
|
417
|
+
const directorySummary = await summarize(directory.diffs.map((diff) => ({
|
|
418
|
+
pageContent: diff.diff,
|
|
419
|
+
metadata: {
|
|
420
|
+
file: diff.file,
|
|
421
|
+
summary: diff.summary,
|
|
422
|
+
},
|
|
423
|
+
})), {
|
|
424
|
+
chain,
|
|
425
|
+
textSplitter,
|
|
426
|
+
options: {
|
|
427
|
+
returnIntermediateSteps: true,
|
|
428
|
+
},
|
|
429
|
+
});
|
|
430
|
+
const newTokenTotal = tokenizer.encode(directorySummary).text.length;
|
|
431
|
+
return {
|
|
432
|
+
diffs: directory.diffs,
|
|
433
|
+
path: directory.path,
|
|
434
|
+
summary: directorySummary,
|
|
435
|
+
tokenCount: newTokenTotal,
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
catch (error) {
|
|
439
|
+
console.error(error);
|
|
440
|
+
return directory;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
const defaultOutputCallback = (group) => {
|
|
444
|
+
let output = `
|
|
445
|
+
-------\n* changes in "/${group.path}"\n\n`;
|
|
446
|
+
if (group.summary) {
|
|
447
|
+
output += `${group.diffs.map((diff) => ` âĒ ${diff.summary}`).join('\n')}\n\nSummary:${group.summary}\n\n`;
|
|
448
|
+
}
|
|
449
|
+
else {
|
|
450
|
+
output += `${group.diffs.map((diff) => ` âĒ ${diff.summary}\n\n${diff.diff}`).join('\n\n')}\n\n`;
|
|
451
|
+
}
|
|
452
|
+
return output;
|
|
453
|
+
};
|
|
454
|
+
async function summarizeDiffs(rootDiffNode, { tokenizer, maxTokens = 2048, textSplitter, chain, handleOutput = defaultOutputCallback, }) {
|
|
455
|
+
const logger = new Logger(config);
|
|
456
|
+
const queue = new PQueue({ concurrency: 8 });
|
|
457
|
+
logger.startTimer().startSpinner(`Organizing Diffs...`, { color: 'blue' });
|
|
458
|
+
const directoryDiffs = createDirectoryDiffs(rootDiffNode);
|
|
459
|
+
// Sort by token count descending
|
|
460
|
+
directoryDiffs.sort((a, b) => b.tokenCount - a.tokenCount);
|
|
461
|
+
let totalTokenCount = directoryDiffs.reduce((sum, group) => sum + group.tokenCount, 0);
|
|
462
|
+
logger.stopSpinner('Diffs Organized').stopTimer();
|
|
463
|
+
logger.startSpinner(`Consolidating Diffs`, { color: 'blue' });
|
|
464
|
+
const processingTasks = directoryDiffs.map((group, i) => {
|
|
465
|
+
return queue.add(async () => {
|
|
466
|
+
// If the diff token count is already less than the average req, we can skip summarizing.
|
|
467
|
+
const isLessThanAvgTokenReq = group.tokenCount <= maxTokens / directoryDiffs.length;
|
|
468
|
+
if (totalTokenCount <= maxTokens || isLessThanAvgTokenReq) {
|
|
469
|
+
return group;
|
|
470
|
+
}
|
|
471
|
+
group = await summarizeDirectoryDiff(group, {
|
|
472
|
+
chain,
|
|
473
|
+
textSplitter,
|
|
474
|
+
tokenizer,
|
|
475
|
+
});
|
|
476
|
+
// We need to subtract the old token count and add the new one
|
|
477
|
+
totalTokenCount = totalTokenCount - directoryDiffs[i].tokenCount + group.tokenCount;
|
|
478
|
+
directoryDiffs[i] = group;
|
|
479
|
+
logger
|
|
480
|
+
.verbose(`\n âĒ Summarized diffs in "/${group.path}" `, { color: 'blue' })
|
|
481
|
+
.verbose(`\nTotal token count: ${totalTokenCount}`, {
|
|
482
|
+
color: totalTokenCount > maxTokens ? 'yellow' : 'green',
|
|
483
|
+
});
|
|
484
|
+
return group;
|
|
485
|
+
}, { priority: group.tokenCount });
|
|
486
|
+
});
|
|
487
|
+
await Promise.all(processingTasks);
|
|
488
|
+
logger.stopSpinner(`Summarized Diffs`);
|
|
489
|
+
return directoryDiffs.map(handleOutput).join('');
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
class DiffTreeNode {
|
|
493
|
+
constructor(path) {
|
|
494
|
+
this.path = [];
|
|
495
|
+
this.files = [];
|
|
496
|
+
this.children = new Map();
|
|
497
|
+
if (path)
|
|
498
|
+
this.path = path;
|
|
499
|
+
}
|
|
500
|
+
addFile(file) {
|
|
501
|
+
this.files.push(file);
|
|
502
|
+
}
|
|
503
|
+
addChild(part, node) {
|
|
504
|
+
this.children.set(part, node);
|
|
505
|
+
}
|
|
506
|
+
getChild(part) {
|
|
507
|
+
return this.children.get(part);
|
|
508
|
+
}
|
|
509
|
+
getPath() {
|
|
510
|
+
return this.path.join('/');
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
const createDiffTree = (changes) => {
|
|
514
|
+
const root = new DiffTreeNode();
|
|
515
|
+
for (const change of changes) {
|
|
516
|
+
let currentParent = root;
|
|
517
|
+
const parts = change.filepath.split('/');
|
|
518
|
+
parts.pop();
|
|
519
|
+
for (const part of parts) {
|
|
520
|
+
let childNode = currentParent.getChild(part);
|
|
521
|
+
if (!childNode) {
|
|
522
|
+
childNode = new DiffTreeNode([...currentParent.path, part]);
|
|
523
|
+
currentParent.addChild(part, childNode);
|
|
524
|
+
}
|
|
525
|
+
currentParent = childNode;
|
|
526
|
+
}
|
|
527
|
+
// Create a NodeFile object and add it to the parent
|
|
528
|
+
currentParent.addFile({
|
|
529
|
+
filepath: change.filepath,
|
|
530
|
+
oldFilepath: change.oldFilepath,
|
|
531
|
+
summary: change.summary,
|
|
532
|
+
status: change.status,
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
return root;
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Asynchronously collect diffs for a given node and its children.
|
|
540
|
+
*/
|
|
541
|
+
async function collectDiffs(node, getFileDiff, tokenizer, logger = new Logger(config)) {
|
|
542
|
+
// Collect diffs for the files of the current node
|
|
543
|
+
const diffPromises = node.files.map(async (nodeFile) => {
|
|
544
|
+
const diff = await getFileDiff(nodeFile);
|
|
545
|
+
// TODO: Swap out the GPT3Tokenizer for LangChain tokenizer
|
|
546
|
+
const tokenizedDiff = tokenizer.encode(diff).text;
|
|
547
|
+
const tokenCount = tokenizedDiff.length;
|
|
548
|
+
logger.verbose(`Collected diff for ${nodeFile.filepath} (${tokenCount} tokens)`, {
|
|
549
|
+
color: 'magenta',
|
|
550
|
+
});
|
|
551
|
+
return {
|
|
552
|
+
file: nodeFile.filepath,
|
|
553
|
+
summary: nodeFile.summary,
|
|
554
|
+
diff,
|
|
555
|
+
tokenCount,
|
|
556
|
+
};
|
|
557
|
+
});
|
|
558
|
+
// Collect diffs for the children of the current node
|
|
559
|
+
const childrenPromises = Array.from(node.children.values()).map(async (child) => collectDiffs(child, getFileDiff, tokenizer));
|
|
560
|
+
const [diffs, children] = await Promise.all([
|
|
561
|
+
Promise.all(diffPromises),
|
|
562
|
+
Promise.all(childrenPromises),
|
|
563
|
+
]);
|
|
564
|
+
return {
|
|
565
|
+
path: node.getPath(),
|
|
566
|
+
diffs,
|
|
567
|
+
children,
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const readFile = util__namespace.promisify(fs__namespace.readFile);
|
|
572
|
+
|
|
573
|
+
const parseDefaultFileDiff = async (nodeFile, repo, headTree, index) => {
|
|
574
|
+
let result = '';
|
|
575
|
+
const diff = await nodegit.Diff.treeToIndex(repo, headTree, index, {
|
|
576
|
+
flags: 33554432 /* Diff.OPTION.SHOW_UNTRACKED_CONTENT */ | 16 /* Diff.OPTION.RECURSE_UNTRACKED_DIRS */,
|
|
577
|
+
pathspec: nodeFile.filepath,
|
|
578
|
+
});
|
|
579
|
+
const patches = await diff.patches();
|
|
580
|
+
for (const patch of patches) {
|
|
581
|
+
const hunks = await patch.hunks();
|
|
582
|
+
for (const hunk of hunks) {
|
|
583
|
+
const lines = await hunk.lines();
|
|
584
|
+
result += lines.map((line) => String.fromCharCode(line.origin()) + line.content()).join('');
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
return result;
|
|
588
|
+
};
|
|
589
|
+
const parseRenamedFileDiff = async (nodeFile, repo, headTree, index, logger) => {
|
|
590
|
+
let result = '';
|
|
591
|
+
const oldFilepath = nodeFile?.oldFilepath || nodeFile.filepath;
|
|
592
|
+
try {
|
|
593
|
+
const headEntry = await headTree.entryByPath(oldFilepath); // use old name to look up in latest commit
|
|
594
|
+
const indexEntry = index.getByPath(nodeFile.filepath); // use new name to look up in index
|
|
595
|
+
// Compare the file contents in the latest commit and index
|
|
596
|
+
const headBlob = await nodegit.Blob.lookup(repo, headEntry.sha());
|
|
597
|
+
const indexBlobContent = await readFile(indexEntry.path); // read file from filesystem
|
|
598
|
+
const headContent = headBlob.content().toString();
|
|
599
|
+
const indexContent = indexBlobContent.toString();
|
|
600
|
+
if (headContent !== indexContent) {
|
|
601
|
+
result = diff.createTwoFilesPatch(oldFilepath, nodeFile.filepath, headContent, indexContent, '', '', { context: 3 });
|
|
602
|
+
// remove the first 4 lines of the patch (they contain the old and new file names)
|
|
603
|
+
result = result.split('\n').slice(4).join('\n');
|
|
604
|
+
}
|
|
605
|
+
else {
|
|
606
|
+
result = 'File contents are unchanged.';
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
catch (err) {
|
|
610
|
+
logger.verbose(`Error comparing file contents for ${nodeFile.filepath}`, { color: 'red' });
|
|
611
|
+
result = 'Error comparing file contents.';
|
|
612
|
+
}
|
|
613
|
+
return result;
|
|
614
|
+
};
|
|
615
|
+
const parseFileDiff = async (nodeFile, repo, headTree, index, logger) => {
|
|
616
|
+
if (nodeFile.status === 'deleted') {
|
|
617
|
+
return 'This file has been deleted.';
|
|
618
|
+
}
|
|
619
|
+
if (nodeFile.status === 'renamed' && nodeFile.oldFilepath) {
|
|
620
|
+
return parseRenamedFileDiff(nodeFile, repo, headTree, index, logger);
|
|
621
|
+
}
|
|
622
|
+
// If not deleted or renamed, get the diff from the index
|
|
623
|
+
return parseDefaultFileDiff(nodeFile, repo, headTree, index);
|
|
624
|
+
};
|
|
625
|
+
|
|
626
|
+
// TODO: Extend this to support other models! ð
|
|
627
|
+
function getModel(fields, configuration) {
|
|
628
|
+
return new openai.OpenAI(fields, configuration);
|
|
629
|
+
// return new HuggingFaceInference({
|
|
630
|
+
// // model: 'gpt2',
|
|
631
|
+
// // model: 'bigcode/starcoder',
|
|
632
|
+
// model: 'bigscience/bloom',
|
|
633
|
+
// apiKey: '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',
|
|
634
|
+
// maxConcurrency: 4,
|
|
635
|
+
// cache: true,
|
|
636
|
+
// // maxTokens: 2046,
|
|
637
|
+
// })
|
|
638
|
+
}
|
|
639
|
+
function getTextSplitter(options = {}) {
|
|
640
|
+
return new text_splitter.RecursiveCharacterTextSplitter(options);
|
|
641
|
+
}
|
|
642
|
+
function getChain(model, options = { type: 'map_reduce' }) {
|
|
643
|
+
return chains.loadSummarizationChain(model, options);
|
|
644
|
+
}
|
|
645
|
+
function getPrompt({ template, variables, fallback }) {
|
|
646
|
+
if (!template && !fallback)
|
|
647
|
+
throw new Error('Must provide either a template or a fallback');
|
|
648
|
+
return (template
|
|
649
|
+
? new prompts.PromptTemplate({
|
|
650
|
+
template,
|
|
651
|
+
inputVariables: variables,
|
|
652
|
+
})
|
|
653
|
+
: fallback);
|
|
654
|
+
}
|
|
655
|
+
function validatePromptTemplate(text, inputVariables) {
|
|
656
|
+
if (!text) {
|
|
657
|
+
return 'Prompt template cannot be empty';
|
|
658
|
+
}
|
|
659
|
+
if (!inputVariables.some((entry) => text.includes(entry))) {
|
|
660
|
+
return ('Prompt template must include at least one of the following input variables: ' +
|
|
661
|
+
inputVariables.map((value) => `{${value}}`).join(', '));
|
|
662
|
+
}
|
|
663
|
+
return true;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
const MAX_TOKENS_PER_SUMMARY = 2048;
|
|
667
|
+
const fileChangeParser = async (changes, { tokenizer, repo, model }) => {
|
|
668
|
+
const logger = new Logger(config);
|
|
669
|
+
const head = await repo.getHeadCommit();
|
|
670
|
+
const headTree = await head.getTree();
|
|
671
|
+
const index = await repo.refreshIndex();
|
|
672
|
+
const textSplitter = getTextSplitter({ chunkSize: 2000, chunkOverlap: 125, });
|
|
673
|
+
const summarizationChain = getChain(model, {
|
|
674
|
+
type: 'map_reduce',
|
|
675
|
+
combineMapPrompt: SUMMARIZE_PROMPT,
|
|
676
|
+
combinePrompt: SUMMARIZE_PROMPT,
|
|
677
|
+
});
|
|
678
|
+
logger.startTimer();
|
|
679
|
+
const rootTreeNode = createDiffTree(changes);
|
|
680
|
+
logger.stopTimer('Created file hierarchy');
|
|
681
|
+
// Collect diffs
|
|
682
|
+
logger.startTimer().startSpinner(`Collecting Diffs...\n`, { color: 'blue' });
|
|
683
|
+
const diffs = await collectDiffs(rootTreeNode, (path) => parseFileDiff(path, repo, headTree, index, logger), tokenizer, logger);
|
|
684
|
+
logger.stopSpinner('Diffs Collected').stopTimer();
|
|
685
|
+
// Summarize diffs
|
|
686
|
+
logger.startTimer();
|
|
687
|
+
const summary = await summarizeDiffs(diffs, {
|
|
688
|
+
tokenizer,
|
|
689
|
+
maxTokens: MAX_TOKENS_PER_SUMMARY,
|
|
690
|
+
textSplitter,
|
|
691
|
+
chain: summarizationChain,
|
|
692
|
+
});
|
|
693
|
+
logger.stopTimer(`\nSummary generated for ${changes.length} staged files`, { color: 'green' });
|
|
694
|
+
return summary;
|
|
695
|
+
};
|
|
696
|
+
|
|
697
|
+
const SEPERATOR = chalk.blue('----------------');
|
|
698
|
+
const logCommit = (commit) => {
|
|
699
|
+
console.log(`\n${chalk.bgBlue(chalk.bold('Proposed Commit:'))}\n${SEPERATOR}\n${commit}\n${SEPERATOR}\n`);
|
|
700
|
+
};
|
|
701
|
+
const logSuccess = () => {
|
|
702
|
+
console.log(chalk.green(chalk.bold('\nAll set! ðĶūðĪ')));
|
|
703
|
+
};
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Wrapper around GPT3NodeTokenizer to handle default export.
|
|
707
|
+
*
|
|
708
|
+
* @see https://github.com/botisan-ai/gpt3-tokenizer/issues/18
|
|
709
|
+
*
|
|
710
|
+
* @returns {GPT3NodeTokenizer} The GPT3NodeTokenizer instance.
|
|
711
|
+
*/
|
|
712
|
+
const getTokenizer = () => {
|
|
713
|
+
let tokenizer;
|
|
714
|
+
// eslint-disable-next-line
|
|
715
|
+
// @ts-ignore
|
|
716
|
+
if (GPT3NodeTokenizer.default) {
|
|
717
|
+
// eslint-disable-next-line
|
|
718
|
+
// @ts-ignore
|
|
719
|
+
tokenizer = new GPT3NodeTokenizer.default({ type: 'gpt3' });
|
|
720
|
+
}
|
|
721
|
+
else {
|
|
722
|
+
tokenizer = new GPT3NodeTokenizer({ type: 'gpt3' });
|
|
723
|
+
}
|
|
724
|
+
return tokenizer;
|
|
725
|
+
};
|
|
726
|
+
|
|
727
|
+
const EMPTY_GIT_TREE_HASH = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
|
|
728
|
+
|
|
729
|
+
const getSummaryText = (patch) => {
|
|
730
|
+
const oldFilePath = patch.oldFile().path();
|
|
731
|
+
const newFilePath = patch.newFile().path();
|
|
732
|
+
let summary;
|
|
733
|
+
if (patch.isAdded()) {
|
|
734
|
+
summary = `added: ${newFilePath}`;
|
|
735
|
+
}
|
|
736
|
+
else if (patch.isDeleted()) {
|
|
737
|
+
summary = `deleted: ${oldFilePath}`;
|
|
738
|
+
}
|
|
739
|
+
else if (patch.isModified()) {
|
|
740
|
+
summary = `modified: ${newFilePath}`;
|
|
741
|
+
}
|
|
742
|
+
else if (patch.isRenamed()) {
|
|
743
|
+
summary = `renamed: ${oldFilePath} -> ${newFilePath}`;
|
|
744
|
+
}
|
|
745
|
+
else if (patch.isUntracked()) {
|
|
746
|
+
summary = `untracked: ${newFilePath}`;
|
|
747
|
+
}
|
|
748
|
+
else {
|
|
749
|
+
summary = `unknown: ${newFilePath}`;
|
|
750
|
+
}
|
|
751
|
+
return summary;
|
|
752
|
+
};
|
|
753
|
+
|
|
754
|
+
const getStatus = (patch) => {
|
|
755
|
+
let status;
|
|
756
|
+
if (patch.isAdded()) {
|
|
757
|
+
status = 'added';
|
|
758
|
+
}
|
|
759
|
+
else if (patch.isDeleted()) {
|
|
760
|
+
status = 'deleted';
|
|
761
|
+
}
|
|
762
|
+
else if (patch.isModified()) {
|
|
763
|
+
status = 'modified';
|
|
764
|
+
}
|
|
765
|
+
else if (patch.isRenamed()) {
|
|
766
|
+
status = 'renamed';
|
|
767
|
+
}
|
|
768
|
+
else if (patch.isUntracked()) {
|
|
769
|
+
status = 'untracked';
|
|
770
|
+
}
|
|
771
|
+
else if (patch.newFile()) {
|
|
772
|
+
status = 'new file';
|
|
773
|
+
}
|
|
774
|
+
else {
|
|
775
|
+
status = 'unknown';
|
|
776
|
+
}
|
|
777
|
+
return status;
|
|
778
|
+
};
|
|
779
|
+
|
|
780
|
+
const DEFAULT_IGNORED_FILES$1 = [
|
|
781
|
+
...(config?.ignoredFiles?.length && config?.ignoredFiles?.length > 0 ? config.ignoredFiles : []),
|
|
782
|
+
];
|
|
783
|
+
const DEFAULT_IGNORED_EXTENSIONS$1 = [
|
|
784
|
+
...(config?.ignoredExtensions?.length && config?.ignoredExtensions?.length > 0
|
|
785
|
+
? config.ignoredExtensions
|
|
786
|
+
: []),
|
|
787
|
+
];
|
|
788
|
+
/**
|
|
789
|
+
* Parse patches from a git diff.
|
|
790
|
+
*
|
|
791
|
+
* @param {ConvenientPatch[]} patches - An array of git patches.
|
|
792
|
+
* @param {string[]} [options.ignoredFiles] - An optional array of file patterns to ignore.
|
|
793
|
+
* If not provided, it defaults to the `ignoredFiles` configuration value from the app's config.
|
|
794
|
+
* @param {string[]} [options.ignoredExtensions] - An optional array of file extensions to ignore.
|
|
795
|
+
* If not provided, it defaults to the `ignoredExtensions` configuration value from the app's config.
|
|
796
|
+
* @returns {Promise<FileChange[]>} A Promise that resolves to an array of file changes.
|
|
797
|
+
**/
|
|
798
|
+
const parsePatches = async (patches, { ignoredFiles = DEFAULT_IGNORED_FILES$1, ignoredExtensions = DEFAULT_IGNORED_EXTENSIONS$1, }) => patches
|
|
799
|
+
.map((patch) => {
|
|
800
|
+
const summary = getSummaryText(patch);
|
|
801
|
+
const status = getStatus(patch);
|
|
802
|
+
return {
|
|
803
|
+
filepath: patch.newFile().path(),
|
|
804
|
+
oldFilepath: status === 'renamed' ? patch.oldFile().path() : undefined,
|
|
805
|
+
summary,
|
|
806
|
+
status,
|
|
807
|
+
};
|
|
808
|
+
})
|
|
809
|
+
.filter(Boolean)
|
|
810
|
+
// Filter out ignored files & extensions...
|
|
811
|
+
.filter(({ filepath }) => {
|
|
812
|
+
if (!filepath)
|
|
813
|
+
return false;
|
|
814
|
+
const extension = filepath.split('.').pop();
|
|
815
|
+
// Remove ignored extensions
|
|
816
|
+
if (extension && ignoredExtensions.includes(extension))
|
|
817
|
+
return false;
|
|
818
|
+
// Remove ignored files
|
|
819
|
+
if (ignoredFiles.some((pattern) => minimatch.minimatch(filepath, pattern)))
|
|
820
|
+
return false;
|
|
821
|
+
return true;
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
const DEFAULT_IGNORED_FILES = [
|
|
825
|
+
...(config?.ignoredFiles?.length && config?.ignoredFiles?.length > 0 ? config.ignoredFiles : []),
|
|
826
|
+
];
|
|
827
|
+
const DEFAULT_IGNORED_EXTENSIONS = [
|
|
828
|
+
...(config?.ignoredExtensions?.length && config?.ignoredExtensions?.length > 0
|
|
829
|
+
? config.ignoredExtensions
|
|
830
|
+
: []),
|
|
831
|
+
];
|
|
832
|
+
/**
|
|
833
|
+
* The 'git status' for coco
|
|
834
|
+
*
|
|
835
|
+
* Get paths of changed files in the Git repository, excluding ignored files and extensions.
|
|
836
|
+
*
|
|
837
|
+
* @param {string[]} [options.ignoredFiles] - An optional array of file patterns to ignore.
|
|
838
|
+
* If not provided, it defaults to the `ignoredFiles` configuration value from the app's config.
|
|
839
|
+
* @param {string[]} [options.ignoredExtensions] - An optional array of file extensions to ignore.
|
|
840
|
+
* If not provided, it defaults to the `ignoredExtensions` configuration value from the app's config.
|
|
841
|
+
* @returns {Promise<GetChangesResult>} A Promise that resolves to an array of changed file paths.
|
|
842
|
+
*
|
|
843
|
+
* @example
|
|
844
|
+
* const changes = await getStagedChanges()
|
|
845
|
+
* console.log(changes)
|
|
846
|
+
* // {
|
|
847
|
+
* // staged: [
|
|
848
|
+
* // {
|
|
849
|
+
* // filepath: 'src/index.ts',
|
|
850
|
+
* // action: 'modified'
|
|
851
|
+
* // },
|
|
852
|
+
* // ],
|
|
853
|
+
* // unstaged: [
|
|
854
|
+
* // {
|
|
855
|
+
* // filepath: 'src/index.test.ts',
|
|
856
|
+
* // action: 'added'
|
|
857
|
+
* // }
|
|
858
|
+
* // ]
|
|
859
|
+
* // }
|
|
860
|
+
*/
|
|
861
|
+
async function getChanges(repo, options = {}) {
|
|
862
|
+
const { ignoredFiles = DEFAULT_IGNORED_FILES, ignoredExtensions = DEFAULT_IGNORED_EXTENSIONS, ignoreUnstaged, ignoreUntracked, } = options;
|
|
863
|
+
const head = await repo.getHeadCommit();
|
|
864
|
+
const index = await repo.refreshIndex();
|
|
865
|
+
const tree = await (head ? await head.getTree() : nodegit.Tree.lookup(repo, EMPTY_GIT_TREE_HASH));
|
|
866
|
+
let unstaged = [];
|
|
867
|
+
let untracked = [];
|
|
868
|
+
if (!ignoreUnstaged) {
|
|
869
|
+
const unstagedDiff = await nodegit.Diff.indexToWorkdir(repo, index, {
|
|
870
|
+
flags: 16 /* Diff.OPTION.RECURSE_UNTRACKED_DIRS */,
|
|
871
|
+
});
|
|
872
|
+
const unstagedPatches = await unstagedDiff.patches();
|
|
873
|
+
unstaged = await parsePatches(unstagedPatches, { ignoredFiles, ignoredExtensions });
|
|
874
|
+
}
|
|
875
|
+
if (!ignoreUntracked) {
|
|
876
|
+
const untrackedDiff = await nodegit.Diff.treeToWorkdirWithIndex(repo, tree, {
|
|
877
|
+
flags: 33554432 /* Diff.OPTION.SHOW_UNTRACKED_CONTENT */,
|
|
878
|
+
});
|
|
879
|
+
const untrackedPatches = await untrackedDiff.patches();
|
|
880
|
+
untracked = (await parsePatches(untrackedPatches, { ignoredFiles, ignoredExtensions })).filter(({ status }) => status === 'untracked');
|
|
881
|
+
}
|
|
882
|
+
const diff = await nodegit.Diff.treeToIndex(repo, tree, index);
|
|
883
|
+
await diff.findSimilar({
|
|
884
|
+
flags: 1 /* Diff.FIND.RENAMES */,
|
|
885
|
+
});
|
|
886
|
+
const patches = await diff.patches();
|
|
887
|
+
return {
|
|
888
|
+
staged: await parsePatches(patches, { ignoredFiles, ignoredExtensions }),
|
|
889
|
+
unstaged,
|
|
890
|
+
untracked,
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
async function createCommit(commitMsg, repo) {
|
|
895
|
+
const author = await nodegit__namespace.Signature.default(repo);
|
|
896
|
+
const index = await repo.refreshIndex();
|
|
897
|
+
await index.addAll();
|
|
898
|
+
await index.write();
|
|
899
|
+
const oid = await index.writeTree();
|
|
900
|
+
const head = await nodegit__namespace.Reference.nameToId(repo, "HEAD");
|
|
901
|
+
const parent = await repo.getCommit(head);
|
|
902
|
+
return await repo.createCommit("HEAD", author, author, commitMsg, oid, [parent]);
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
const llm = async ({ llm, prompt, variables }) => {
|
|
906
|
+
const chain = new chains.LLMChain({ llm, prompt });
|
|
907
|
+
const res = await chain.call(variables);
|
|
908
|
+
if (res.error)
|
|
909
|
+
throw new Error(res.error);
|
|
910
|
+
return res.text.trim();
|
|
911
|
+
};
|
|
912
|
+
|
|
913
|
+
const noResult = async ({ repo, logger }) => {
|
|
914
|
+
const { staged, unstaged, untracked } = await getChanges(repo, {
|
|
915
|
+
ignoreUnstaged: false,
|
|
916
|
+
ignoreUntracked: false,
|
|
917
|
+
});
|
|
918
|
+
if (staged.length > 0) {
|
|
919
|
+
logger.log(`Staged files detected, but no summary generated...`, { color: 'red' });
|
|
920
|
+
logger.log(`Files are likely either:\n âĒ changed files are ignored\n âĒ file diff is too large.`, { color: 'yellow' });
|
|
921
|
+
}
|
|
922
|
+
else if (unstaged && unstaged.length > 0) {
|
|
923
|
+
logger.log('No staged files detected, but unstaged files detected.', { color: 'yellow' });
|
|
924
|
+
logger.verbose(`\n Unstaged Changes: \n ${unstaged.map(({ summary }) => summary).join('\n ')}`, {
|
|
925
|
+
color: 'yellow',
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
else if (untracked && untracked.length > 0) {
|
|
929
|
+
logger.log('No staged files detected, but untracked files detected.', { color: 'yellow' });
|
|
930
|
+
logger.verbose(`\n Untracked Changes: \n ${untracked.map(({ summary }) => summary).join('\n ')}`, {
|
|
931
|
+
color: 'yellow',
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
else {
|
|
935
|
+
logger.log('No repo changes detected.', { color: 'yellow' });
|
|
936
|
+
}
|
|
937
|
+
process.exit(0);
|
|
938
|
+
};
|
|
939
|
+
|
|
940
|
+
const argv = loadArgv();
|
|
941
|
+
const tokenizer = getTokenizer();
|
|
942
|
+
async function main(options) {
|
|
943
|
+
const logger = new Logger(config);
|
|
944
|
+
if (!config.openAIApiKey) {
|
|
945
|
+
logger.log(`No API Key found. ðïļðŠ`, { color: 'red' });
|
|
946
|
+
process.exit(1);
|
|
947
|
+
}
|
|
948
|
+
const repo = await nodegit.Repository.open('.');
|
|
949
|
+
const model = getModel({
|
|
950
|
+
temperature: 0.4,
|
|
951
|
+
maxConcurrency: 10,
|
|
952
|
+
openAIApiKey: config.openAIApiKey,
|
|
953
|
+
});
|
|
954
|
+
const INTERACTIVE = config?.mode === 'interactive' || options.interactive;
|
|
955
|
+
const { staged: changes } = await getChanges(repo, {
|
|
956
|
+
ignoreUnstaged: true,
|
|
957
|
+
ignoreUntracked: true,
|
|
958
|
+
});
|
|
959
|
+
let summary = '';
|
|
960
|
+
let commitMsg = '';
|
|
961
|
+
let promptTemplate = config?.prompt || '';
|
|
962
|
+
let modifyPrompt = false;
|
|
963
|
+
while (true) {
|
|
964
|
+
if (changes.length !== 0 && !summary.length) {
|
|
965
|
+
logger.verbose(`\nChanged Files: \n ${changes.map(({ summary }) => summary).join('\n ')}`, {
|
|
966
|
+
color: 'blue',
|
|
967
|
+
});
|
|
968
|
+
summary = await fileChangeParser(changes, { tokenizer, repo, model });
|
|
969
|
+
}
|
|
970
|
+
// Handle empty summary
|
|
971
|
+
if (!summary.length) {
|
|
972
|
+
noResult({ repo, logger });
|
|
973
|
+
}
|
|
974
|
+
// Prompt user for commit template prompt, if necessary
|
|
975
|
+
if (modifyPrompt) {
|
|
976
|
+
promptTemplate = await prompts$1.editor({
|
|
977
|
+
message: 'Edit the prompt',
|
|
978
|
+
default: promptTemplate.length ? promptTemplate : COMMIT_PROMPT.template,
|
|
979
|
+
waitForUseInput: false,
|
|
980
|
+
validate: (text) => validatePromptTemplate(text, COMMIT_PROMPT.inputVariables),
|
|
981
|
+
});
|
|
982
|
+
}
|
|
983
|
+
logger.startTimer().startSpinner(`Generating Commit Message\n`, {
|
|
984
|
+
color: 'blue',
|
|
985
|
+
});
|
|
986
|
+
commitMsg = await llm({
|
|
987
|
+
llm: model,
|
|
988
|
+
prompt: getPrompt({
|
|
989
|
+
template: promptTemplate,
|
|
990
|
+
variables: COMMIT_PROMPT.inputVariables,
|
|
991
|
+
fallback: COMMIT_PROMPT,
|
|
992
|
+
}),
|
|
993
|
+
variables: { summary },
|
|
994
|
+
});
|
|
995
|
+
if (!commitMsg) {
|
|
996
|
+
logger.stopSpinner('ð Failed to generate commit message.', {
|
|
997
|
+
mode: 'fail',
|
|
998
|
+
color: 'red',
|
|
999
|
+
});
|
|
1000
|
+
process.exit(0);
|
|
1001
|
+
}
|
|
1002
|
+
logger
|
|
1003
|
+
.stopSpinner('Generated Commit Message', {
|
|
1004
|
+
color: 'green',
|
|
1005
|
+
mode: 'succeed',
|
|
1006
|
+
})
|
|
1007
|
+
.stopTimer();
|
|
1008
|
+
if (INTERACTIVE) {
|
|
1009
|
+
logCommit(commitMsg);
|
|
1010
|
+
const reviewAnswer = await prompts$1.select({
|
|
1011
|
+
message: 'Would you like to make any changes to the commit message?',
|
|
1012
|
+
choices: [
|
|
1013
|
+
{
|
|
1014
|
+
name: 'âĻ Looks good!',
|
|
1015
|
+
value: 'approve',
|
|
1016
|
+
description: 'Commit staged changes with generated commit message',
|
|
1017
|
+
},
|
|
1018
|
+
{
|
|
1019
|
+
name: 'ð Edit',
|
|
1020
|
+
value: 'edit',
|
|
1021
|
+
description: 'Edit the commit message before proceeding',
|
|
1022
|
+
},
|
|
1023
|
+
{
|
|
1024
|
+
name: 'ðŠķ Modify Prompt',
|
|
1025
|
+
value: 'modifyPrompt',
|
|
1026
|
+
description: 'Modify the prompt template and regenerate the commit message',
|
|
1027
|
+
},
|
|
1028
|
+
{
|
|
1029
|
+
name: 'ð Retry - Message Only',
|
|
1030
|
+
value: 'retryMessageOnly',
|
|
1031
|
+
description: 'Restart the function execution from generating the commit message',
|
|
1032
|
+
},
|
|
1033
|
+
{
|
|
1034
|
+
name: 'ð Retry - Full',
|
|
1035
|
+
value: 'retryFull',
|
|
1036
|
+
description: 'Restart the function execution from the beginning, regenerating both the diff summary and commit message',
|
|
1037
|
+
},
|
|
1038
|
+
{
|
|
1039
|
+
name: 'ðĢ Cancel',
|
|
1040
|
+
value: 'cancel',
|
|
1041
|
+
},
|
|
1042
|
+
],
|
|
1043
|
+
});
|
|
1044
|
+
if (reviewAnswer === 'cancel') {
|
|
1045
|
+
process.exit(0);
|
|
1046
|
+
}
|
|
1047
|
+
if (reviewAnswer === 'edit') {
|
|
1048
|
+
config.openInEditor = true;
|
|
1049
|
+
}
|
|
1050
|
+
if (reviewAnswer === 'retryFull') {
|
|
1051
|
+
summary = '';
|
|
1052
|
+
commitMsg = '';
|
|
1053
|
+
promptTemplate = '';
|
|
1054
|
+
continue;
|
|
1055
|
+
}
|
|
1056
|
+
if (reviewAnswer === 'retryMessageOnly') {
|
|
1057
|
+
modifyPrompt = false;
|
|
1058
|
+
commitMsg = '';
|
|
1059
|
+
continue;
|
|
1060
|
+
}
|
|
1061
|
+
if (reviewAnswer === 'modifyPrompt') {
|
|
1062
|
+
modifyPrompt = true;
|
|
1063
|
+
commitMsg = '';
|
|
1064
|
+
continue;
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
if (config.openInEditor) {
|
|
1068
|
+
commitMsg = await prompts$1.editor({
|
|
1069
|
+
message: 'Edit the commit message',
|
|
1070
|
+
default: commitMsg,
|
|
1071
|
+
waitForUseInput: false,
|
|
1072
|
+
validate: (text) => {
|
|
1073
|
+
if (!text) {
|
|
1074
|
+
return 'Commit message cannot be empty';
|
|
1075
|
+
}
|
|
1076
|
+
return true;
|
|
1077
|
+
},
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
1080
|
+
const MODE = (options.interactive && 'interactive') ||
|
|
1081
|
+
(options.commit && 'interactive') ||
|
|
1082
|
+
config?.mode ||
|
|
1083
|
+
'stdout';
|
|
1084
|
+
// Handle resulting commit message
|
|
1085
|
+
switch (MODE) {
|
|
1086
|
+
case 'interactive':
|
|
1087
|
+
await createCommit(commitMsg, repo);
|
|
1088
|
+
logSuccess();
|
|
1089
|
+
break;
|
|
1090
|
+
case 'stdout':
|
|
1091
|
+
default:
|
|
1092
|
+
process.stdout.write(commitMsg, 'utf8');
|
|
1093
|
+
break;
|
|
1094
|
+
}
|
|
1095
|
+
process.exit(0);
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
main(argv).catch(console.error);
|