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