git-coco 0.6.2 β†’ 0.7.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.
@@ -1,455 +1,383 @@
1
1
  #!/usr/bin/env node
2
2
  import yargs from 'yargs';
3
+ import { PromptTemplate } from 'langchain/prompts';
4
+ import * as fs from 'fs';
5
+ import fs__default from 'fs';
6
+ import { confirm, editor, select, password, input } from '@inquirer/prompts';
7
+ import chalk from 'chalk';
8
+ import * as os from 'os';
9
+ import os__default from 'os';
10
+ import * as path from 'path';
11
+ import path__default from 'path';
12
+ import * as ini from 'ini';
13
+ import ora from 'ora';
14
+ import now from 'performance-now';
15
+ import prettyMilliseconds from 'pretty-ms';
3
16
  import pQueue from 'p-queue';
4
17
  import { Document } from 'langchain/document';
5
18
  import { HuggingFaceInference } from 'langchain/llms/hf';
6
- import { PromptTemplate } from 'langchain/prompts';
7
19
  import { loadSummarizationChain, LLMChain } from 'langchain/chains';
8
20
  import { OpenAI } from 'langchain/llms/openai';
9
21
  import { RecursiveCharacterTextSplitter } from 'langchain/text_splitter';
10
22
  import { createTwoFilesPatch } from 'diff';
11
- import GPT3NodeTokenizer from 'gpt3-tokenizer';
12
- import chalk from 'chalk';
13
- import ora from 'ora';
14
- import now from 'performance-now';
15
- import prettyMilliseconds from 'pretty-ms';
16
- import * as path from 'path';
17
- import path__default from 'path';
18
23
  import { minimatch } from 'minimatch';
19
- import * as fs from 'fs';
20
- import fs__default from 'fs';
21
- import { confirm, editor, select, password, input } from '@inquirer/prompts';
22
- import * as os from 'os';
23
- import os__default from 'os';
24
- import * as ini from 'ini';
25
24
  import { simpleGit } from 'simple-git';
25
+ import { encoding_for_model } from 'tiktoken';
26
+ import { exec } from 'child_process';
26
27
 
27
28
  /**
28
- * Extract the path from a file path string.
29
- * @param {string} filePath - The full file path.
30
- * @returns {string} The path portion of the file path.
29
+ * Returns a new object with all undefined keys removed
30
+ *
31
+ * @param obj Object to remove undefined keys from
32
+ * @returns
31
33
  */
32
- function getPathFromFilePath(filePath) {
33
- return filePath.split('/').slice(0, -1).join('/');
34
+ function removeUndefined(obj) {
35
+ return Object.fromEntries(Object.entries(obj).filter(([, value]) => value !== undefined));
34
36
  }
35
37
 
36
- async function summarize(documents, { chain, textSplitter, options }) {
37
- const { returnIntermediateSteps = false } = options || {};
38
- const docs = await textSplitter.splitDocuments(documents.map((doc) => new Document(doc)));
39
- const res = await chain.call({
40
- input_documents: docs,
41
- returnIntermediateSteps,
42
- });
43
- if (res.error)
44
- throw new Error(res.error);
45
- return res.text && res.text.trim();
46
- }
38
+ const template$2 = `GOAL: Use functional abstractions to summarize the following text
39
+
40
+ RULES: Avoid phrases like "this change", "this code", or "this function" etc. Instead refer to the function, variable, or class by name.
41
+
42
+ TEXT:"""{text}"""
43
+ `;
44
+ const inputVariables$2 = ['text'];
45
+ const SUMMARIZE_PROMPT = new PromptTemplate({
46
+ template: template$2,
47
+ inputVariables: inputVariables$2,
48
+ });
47
49
 
48
50
  /**
49
- * Create groups from a given node info.
50
- * @param {DiffNode} node - The node info to start grouping.
51
- * @returns {DirectoryDiff[]} The groups created.
51
+ * Default Config
52
+ *
53
+ * @type {Config}
52
54
  */
53
- function createDirectoryDiffs(node) {
54
- const groupByPath = {};
55
- function traverse(node) {
56
- node.diffs.forEach((diff) => {
57
- const path = getPathFromFilePath(diff.file);
58
- if (!groupByPath[path]) {
59
- groupByPath[path] = { diffs: [], path, tokenCount: 0 };
60
- }
61
- groupByPath[path].diffs.push(diff);
62
- groupByPath[path].tokenCount += diff.tokenCount;
63
- });
64
- node.children.forEach(traverse);
65
- }
66
- traverse(node);
67
- return Object.values(groupByPath);
68
- }
55
+ const DEFAULT_CONFIG = {
56
+ service: 'openai/gpt-4',
57
+ verbose: false,
58
+ tokenLimit: 1024,
59
+ summarizePrompt: SUMMARIZE_PROMPT.template,
60
+ temperature: 0.4,
61
+ mode: 'stdout',
62
+ ignoredFiles: ['package-lock.json'],
63
+ ignoredExtensions: ['.map', '.lock'],
64
+ defaultBranch: 'main',
65
+ };
69
66
  /**
70
- * Summarize a directory diff asynchronously.
67
+ * Create a named export of all config keys for use in other modules.
68
+ *
69
+ * @see Currently used in `src/lib/config/services/env.ts` to validate all env vars.
70
+ *
71
+ * @type {string[]}
71
72
  */
72
- async function summarizeDirectoryDiff(directory, { chain, textSplitter, tokenizer }) {
73
- try {
74
- const directorySummary = await summarize(directory.diffs.map((diff) => ({
75
- pageContent: diff.diff,
76
- metadata: {
77
- file: diff.file,
78
- summary: diff.summary,
79
- },
80
- })), {
81
- chain,
82
- textSplitter,
83
- options: {
84
- returnIntermediateSteps: true,
85
- },
86
- });
87
- const newTokenTotal = tokenizer.encode(directorySummary).text.length;
88
- return {
89
- diffs: directory.diffs,
90
- path: directory.path,
91
- summary: directorySummary,
92
- tokenCount: newTokenTotal,
93
- };
73
+ const CONFIG_KEYS = Object.keys({
74
+ ...DEFAULT_CONFIG,
75
+ huggingFaceHubApiKey: '',
76
+ openAIApiKey: '',
77
+ prompt: '',
78
+ });
79
+
80
+ async function updateFileSection({ filePath, startComment, endComment, getNewContent, confirmUpdate = true, confirmMessage = (path) => `A section already exists in ${path}, do you want to override it?`, }) {
81
+ const lines = fs__default.existsSync(filePath) ? fs__default.readFileSync(filePath, 'utf-8').split(/\r?\n/) : [];
82
+ const newLines = [];
83
+ let foundSection = false;
84
+ for (let i = 0; i < lines.length; i++) {
85
+ if (lines[i].trim() === startComment) {
86
+ foundSection = true;
87
+ if (confirmUpdate) {
88
+ const confirmOverwrite = await confirm({
89
+ message: typeof confirmMessage === 'function' ? confirmMessage(filePath) : confirmMessage,
90
+ default: false,
91
+ });
92
+ if (!confirmOverwrite) {
93
+ // keep all lines until the end comment
94
+ while (i < lines.length && lines[i].trim() !== endComment) {
95
+ newLines.push(lines[i]);
96
+ i++;
97
+ }
98
+ newLines.push(endComment);
99
+ continue;
100
+ }
101
+ }
102
+ newLines.push(startComment);
103
+ // Insert the new content
104
+ const newContent = await getNewContent();
105
+ newLines.push(newContent);
106
+ // Skip the existing content of the section
107
+ while (i < lines.length && lines[i].trim() !== endComment) {
108
+ i++;
109
+ }
110
+ newLines.push(endComment);
111
+ continue;
112
+ }
113
+ if (!foundSection || lines[i].trim() !== endComment) {
114
+ newLines.push(lines[i]);
115
+ }
94
116
  }
95
- catch (error) {
96
- console.error(error);
97
- return directory;
117
+ // If section wasn't found, append it at the end
118
+ if (!foundSection) {
119
+ newLines.push('\n' + startComment);
120
+ const newContent = await getNewContent();
121
+ newLines.push(newContent);
122
+ newLines.push(endComment);
98
123
  }
124
+ // Write the updated contents back to the file
125
+ fs__default.writeFileSync(filePath, newLines.join('\n'));
99
126
  }
100
- const defaultOutputCallback = (group) => {
101
- let output = `
102
- -------\n* changes in "/${group.path}"\n\n`;
103
- if (group.summary) {
104
- output += `${group.diffs.map((diff) => ` β€’ ${diff.summary}`).join('\n')}\n\nSummary:\n\n${group.summary}\n\n`;
105
- }
106
- else {
107
- output += `${group.diffs.map((diff) => ` β€’ ${diff.summary}\n\n${diff.diff}`).join('\n\n')}\n\n`;
108
- }
109
- return output;
127
+
128
+ const isInteractive = (argv) => {
129
+ return argv?.mode === 'interactive' || argv.interactive;
110
130
  };
111
- async function summarizeDiffs(rootDiffNode, { tokenizer, logger, maxTokens = 2048, textSplitter, chain, handleOutput = defaultOutputCallback, }) {
112
- const queue = new pQueue({ concurrency: 8 });
113
- logger.startTimer().startSpinner(`Organizing Diffs...`, { color: 'blue' });
114
- const directoryDiffs = createDirectoryDiffs(rootDiffNode);
115
- // Sort by token count descending
116
- directoryDiffs.sort((a, b) => b.tokenCount - a.tokenCount);
117
- let totalTokenCount = directoryDiffs.reduce((sum, group) => sum + group.tokenCount, 0);
118
- logger.stopSpinner('Diffs Organized').stopTimer();
119
- logger.startSpinner(`Consolidating Diffs`, { color: 'blue' });
120
- const processingTasks = directoryDiffs.map((group, i) => {
121
- return queue.add(async () => {
122
- // If the diff token count is already less than the average req, we can skip summarizing.
123
- const isLessThanAvgTokenReq = group.tokenCount <= maxTokens / directoryDiffs.length;
124
- if (totalTokenCount <= maxTokens || isLessThanAvgTokenReq) {
125
- return group;
126
- }
127
- group = await summarizeDirectoryDiff(group, {
128
- chain,
129
- textSplitter,
130
- tokenizer,
131
- });
132
- // We need to subtract the old token count and add the new one
133
- totalTokenCount = totalTokenCount - directoryDiffs[i].tokenCount + group.tokenCount;
134
- directoryDiffs[i] = group;
135
- logger
136
- .verbose(`\n β€’ Summarized diffs in "/${group.path}" `, { color: 'blue' })
137
- .verbose(`\nTotal token count: ${totalTokenCount}`, {
138
- color: totalTokenCount > maxTokens ? 'yellow' : 'green',
139
- });
140
- return group;
141
- }, { priority: group.tokenCount });
131
+ const SEPERATOR = chalk.blue('─────────────');
132
+ const LOGO = chalk.green(`β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
133
+ β”‚β”Œβ”€β”β”Œβ”€β”β”Œβ”€β”β”Œβ”€β”β”‚
134
+ β”‚β”‚ β”‚ β”‚β”‚ β”‚ β”‚β”‚
135
+ β”‚β””β”€β”˜β””β”€β”˜β””β”€β”˜β””β”€β”˜β”‚
136
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
137
+ `);
138
+ const CONFIG_ALREADY_EXISTS = (path) => {
139
+ return `coco config found in '${path}', do you want to override it?`;
140
+ };
141
+
142
+ /**
143
+ * Load environment variables
144
+ *
145
+ * @param {Config} config
146
+ * @returns {Config} Updated config
147
+ **/
148
+ function loadEnvConfig(config) {
149
+ const envConfig = {};
150
+ CONFIG_KEYS.forEach((key) => {
151
+ const envVarName = toEnvVarName(key);
152
+ const envValue = parseEnvValue(key, process.env[envVarName]);
153
+ if (envValue === undefined)
154
+ return;
155
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
156
+ // @ts-ignore
157
+ envConfig[key] = envValue;
142
158
  });
143
- await Promise.all(processingTasks);
144
- logger.stopSpinner(`Summarized Diffs`);
145
- return directoryDiffs.map(handleOutput).join('');
159
+ return { ...config, ...removeUndefined(envConfig) };
146
160
  }
147
-
148
- class DiffTreeNode {
149
- constructor(path) {
150
- this.path = [];
151
- this.files = [];
152
- this.children = new Map();
153
- if (path)
154
- this.path = path;
161
+ function parseEnvValue(key, value) {
162
+ if (value === undefined) {
163
+ return undefined;
155
164
  }
156
- addFile(file) {
157
- this.files.push(file);
165
+ else if (key === 'tokenLimit' && typeof value === 'string') {
166
+ return parseInt(value);
158
167
  }
159
- addChild(part, node) {
160
- this.children.set(part, node);
168
+ else if ((key === 'ignoredFiles' || key === 'ignoredExtensions') &&
169
+ typeof value === 'string' &&
170
+ value.includes(',')) {
171
+ return value.split(',');
161
172
  }
162
- getChild(part) {
163
- return this.children.get(part);
173
+ return value;
174
+ }
175
+ function toEnvVarName(key) {
176
+ switch (key) {
177
+ case 'openAIApiKey':
178
+ return 'OPENAI_API_KEY';
179
+ case 'huggingFaceHubApiKey':
180
+ return 'HUGGINGFACE_HUB_API_KEY';
181
+ default:
182
+ return 'COCO_' + key.replace(/([A-Z])/g, '_$1').toUpperCase();
164
183
  }
165
- getPath() {
166
- return this.path.join('/');
184
+ }
185
+ function formatEnvValue(value) {
186
+ if (typeof value === 'number') {
187
+ return `${value}`;
167
188
  }
168
- print(indentation = 0) {
169
- const indent = ' '.repeat(indentation);
170
- let output = `${indent}- Path: ${this.getPath()}\n`;
171
- if (this.files.length > 0) {
172
- output += `${indent} Files:\n`;
173
- for (const file of this.files) {
174
- output += `${indent} - ${file.summary}\n`;
175
- }
176
- }
177
- if (this.children.size > 0) {
178
- output += `${indent} Children:\n`;
179
- for (const [, child] of this.children) {
180
- output += child.print(indentation + 4);
181
- }
182
- }
183
- return output;
189
+ else if (Array.isArray(value)) {
190
+ return `${value.join(',')}`;
184
191
  }
185
- }
186
- const createDiffTree = (changes) => {
187
- const root = new DiffTreeNode();
188
- for (const change of changes) {
189
- let currentParent = root;
190
- const parts = change.filePath.split('/');
191
- parts.pop();
192
- for (const part of parts) {
193
- let childNode = currentParent.getChild(part);
194
- if (!childNode) {
195
- childNode = new DiffTreeNode([...currentParent.path, part]);
196
- currentParent.addChild(part, childNode);
197
- }
198
- currentParent = childNode;
199
- }
200
- // Create a NodeFile object and add it to the parent
201
- currentParent.addFile({
202
- filePath: change.filePath,
203
- oldFilePath: change.oldFilePath,
204
- summary: change.summary,
205
- status: change.status,
206
- });
192
+ else if (typeof value === 'string') {
193
+ // Escape newlines and tabs in strings
194
+ return `${value.replace(/\n/g, '\\n').replace(/\t/g, '\\t')}`;
207
195
  }
208
- return root;
196
+ return `${value}`;
197
+ }
198
+ const appendToEnvFile = async (filePath, config) => {
199
+ const startComment = '# -- Start coco config --';
200
+ const endComment = '# -- End coco config --';
201
+ const getNewContent = async () => {
202
+ return Object.entries(config)
203
+ .map(([key, value]) => `${toEnvVarName(key)}=${formatEnvValue(value)}`)
204
+ .join('\n');
205
+ };
206
+ await updateFileSection({
207
+ filePath,
208
+ startComment,
209
+ endComment,
210
+ getNewContent,
211
+ confirmMessage: CONFIG_ALREADY_EXISTS,
212
+ });
209
213
  };
210
214
 
211
215
  /**
212
- * Asynchronously collect diffs for a given node and its children.
213
- */
214
- async function collectDiffs(node, getFileDiff, tokenizer, logger) {
215
- // Collect diffs for the files of the current node
216
- const diffPromises = node.files.map(async (nodeFile) => {
217
- const diff = await getFileDiff(nodeFile);
218
- // TODO: Swap out the GPT3Tokenizer for LangChain tokenizer
219
- const tokenizedDiff = tokenizer.encode(diff).text;
220
- const tokenCount = tokenizedDiff.length;
221
- logger.verbose(`Collected diff for ${nodeFile.filePath} (${tokenCount} tokens)`, {
222
- color: 'magenta',
223
- });
224
- return {
225
- file: nodeFile.filePath,
226
- summary: nodeFile.summary,
227
- diff,
228
- tokenCount,
216
+ * Load git profile config (from ~/.gitconfig)
217
+ *
218
+ * @param {Config} config
219
+ * @returns {Config} Updated config
220
+ **/
221
+ function loadGitConfig(config) {
222
+ const gitConfigPath = path.join(os.homedir(), '.gitconfig');
223
+ if (fs.existsSync(gitConfigPath)) {
224
+ const gitConfigRaw = fs.readFileSync(gitConfigPath, 'utf-8');
225
+ const gitConfigParsed = ini.parse(gitConfigRaw);
226
+ config = {
227
+ ...config,
228
+ service: gitConfigParsed.coco?.model || config.service,
229
+ openAIApiKey: gitConfigParsed.coco?.openAIApiKey || config.openAIApiKey,
230
+ huggingFaceHubApiKey: gitConfigParsed.coco?.huggingFaceHubApiKey || config.huggingFaceHubApiKey,
231
+ tokenLimit: parseInt(gitConfigParsed.coco?.tokenLimit) || config.tokenLimit,
232
+ prompt: gitConfigParsed.coco?.prompt || config.prompt,
233
+ mode: gitConfigParsed.coco?.mode || config.mode,
234
+ temperature: gitConfigParsed.coco?.temperature || config.temperature,
235
+ summarizePrompt: gitConfigParsed.coco?.summarizePrompt || config.summarizePrompt,
236
+ ignoredFiles: gitConfigParsed.coco?.ignoredFiles || config.ignoredFiles,
237
+ ignoredExtensions: gitConfigParsed.coco?.ignoredExtensions || config.ignoredExtensions,
238
+ defaultBranch: gitConfigParsed.coco?.defaultBranch || config.defaultBranch,
229
239
  };
230
- });
231
- // Collect diffs for the children of the current node
232
- const childrenPromises = Array.from(node.children.values()).map(async (child) => collectDiffs(child, getFileDiff, tokenizer, logger));
233
- const [diffs, children] = await Promise.all([
234
- Promise.all(diffPromises),
235
- Promise.all(childrenPromises),
236
- ]);
237
- return {
238
- path: node.getPath(),
239
- diffs,
240
- children,
241
- };
240
+ }
241
+ return config;
242
242
  }
243
-
244
243
  /**
245
- * Get LLM Model Based on Configuration
246
- * @param fields
247
- * @param configuration
248
- * @returns LLM Model
244
+ * Appends the provided configuration to a git config file.
245
+ *
246
+ * @param filePath - The path to the .gitconfig
247
+ * @param config - The configuration object to append.
249
248
  */
250
- function getModel(name, key, fields) {
251
- const [llm, model] = name.split(/\/(.*)/s);
252
- if (!model) {
253
- throw new Error(`Invalid model: ${name}`);
254
- }
255
- switch (llm) {
256
- case 'huggingface':
257
- return new HuggingFaceInference({
258
- model: model,
259
- apiKey: key,
260
- maxConcurrency: 4,
261
- ...fields,
262
- });
263
- case 'openai':
264
- default:
265
- return new OpenAI({
266
- openAIApiKey: key,
267
- modelName: model,
268
- ...fields,
269
- });
249
+ const appendToGitConfig = async (filePath, config) => {
250
+ if (!fs.existsSync(filePath)) {
251
+ throw new Error(`File ${filePath} does not exist.`);
270
252
  }
271
- }
253
+ const startComment = '# -- Start coco config --';
254
+ const endComment = '# -- End coco config --';
255
+ const header = '[coco]';
256
+ // Function to generate new content for the coco section
257
+ const getNewContent = async () => {
258
+ const contentLines = [header];
259
+ for (const key in config) {
260
+ // check if string has new lines, if so, wrap in quotes
261
+ if (typeof config[key] === 'string') {
262
+ const value = config[key];
263
+ if (value.includes('\n')) {
264
+ contentLines.push(`\t${key} = ${JSON.stringify(value)}`);
265
+ continue;
266
+ }
267
+ }
268
+ contentLines.push(`\t${key} = ${config[key]}`);
269
+ }
270
+ return contentLines.join('\n');
271
+ };
272
+ await updateFileSection({
273
+ filePath,
274
+ startComment,
275
+ endComment,
276
+ getNewContent,
277
+ confirmUpdate: true,
278
+ confirmMessage: CONFIG_ALREADY_EXISTS,
279
+ });
280
+ };
281
+
272
282
  /**
273
- * Retrieve appropriate API key based on selected model
274
- * @param name
275
- * @param options
283
+ * Load .gitignore in project root
284
+ *
285
+ * @param {Config} config
276
286
  * @returns
277
287
  */
278
- function getApiKeyForModel(name, options) {
279
- const [llm, model] = name.split(/\/(.*)/s);
280
- if (!model) {
281
- throw new Error(`Invalid model: ${name}`);
282
- }
283
- switch (llm) {
284
- case 'huggingface':
285
- return options.huggingFaceHubApiKey;
286
- case 'openai':
287
- default:
288
- return options.openAIApiKey;
288
+ function loadGitignore(config) {
289
+ if (fs.existsSync('.gitignore')) {
290
+ const gitignoreContent = fs.readFileSync('.gitignore', 'utf-8');
291
+ config.ignoredFiles = [
292
+ ...(config?.ignoredFiles || []),
293
+ ...gitignoreContent.split('\n').filter((line) => line.trim() !== '' && !line.startsWith('#')),
294
+ ];
289
295
  }
296
+ return config;
290
297
  }
291
298
  /**
292
- * Get Recursive Character Text Splitter
293
- * @param options
299
+ * Load .ignore in project root
300
+ *
301
+ * @param {Config} config
294
302
  * @returns
295
303
  */
296
- function getTextSplitter(options = {}) {
297
- return new RecursiveCharacterTextSplitter(options);
304
+ function loadIgnore(config) {
305
+ if (fs.existsSync('.ignore')) {
306
+ const ignoreContent = fs.readFileSync('.ignore', 'utf-8');
307
+ config.ignoredFiles = [
308
+ ...(config?.ignoredFiles || []),
309
+ ...ignoreContent.split('\n').filter((line) => line.trim() !== '' && !line.startsWith('#')),
310
+ ];
311
+ }
312
+ return config;
298
313
  }
314
+
299
315
  /**
300
- * Get Summarization Chain
301
- * @param model
302
- * @param options
303
- * @returns
304
- */
305
- function getChain(model, options = { type: 'map_reduce' }) {
306
- return loadSummarizationChain(model, options);
307
- }
308
- function getPrompt({ template, variables, fallback }) {
309
- if (!template && !fallback)
310
- throw new Error('Must provide either a template or a fallback');
311
- return (template
312
- ? new PromptTemplate({
313
- template,
314
- inputVariables: variables,
315
- })
316
- : fallback);
316
+ * Load project config
317
+ *
318
+ * @param {Config} config
319
+ * @returns {Config} Updated config
320
+ **/
321
+ function loadProjectConfig(config) {
322
+ // TODO: Add validation based of JSON schema?
323
+ // @see https://github.com/acornejo/jjv
324
+ if (fs.existsSync('.coco.config.json')) {
325
+ const projectConfig = JSON.parse(fs.readFileSync('.coco.config.json', 'utf-8'));
326
+ config = { ...config, ...projectConfig };
327
+ }
328
+ return config;
317
329
  }
330
+ const appendToProjectConfig = (filePath, config) => {
331
+ fs.writeFileSync(filePath, JSON.stringify({
332
+ $schema: 'https://git-co.co/schema.json',
333
+ ...config,
334
+ }, null, 2));
335
+ };
336
+
318
337
  /**
319
- * Verify template string contains all required input variables
320
- * @param text template string
321
- * @param inputVariables template variables
322
- * @returns boolean or error message
338
+ * Load XDG config
339
+ *
340
+ * @param {Config} config
341
+ * @returns {Config} Updated config
323
342
  */
324
- function validatePromptTemplate(text, inputVariables) {
325
- if (!text) {
326
- return 'Prompt template cannot be empty';
327
- }
328
- if (!inputVariables.some((entry) => text.includes(entry))) {
329
- return ('Prompt template must include at least one of the following input variables: ' +
330
- inputVariables.map((value) => `{${value}}`).join(', '));
331
- }
332
- return true;
333
- }
334
-
335
- const template$2 = `GOAL: Use functional abstractions to summarize the following text
336
-
337
- RULES: Avoid phrases like "this change", "this code", or "this function" etc. Instead refer to the function, variable, or class by name.
338
-
339
- TEXT:"""{text}"""
340
- `;
341
- const inputVariables$2 = ['text'];
342
- const SUMMARIZE_PROMPT = new PromptTemplate({
343
- template: template$2,
344
- inputVariables: inputVariables$2,
345
- });
346
-
347
- async function parseDefaultFileDiff(nodeFile, commit = '--staged', git) {
348
- if (commit !== '--staged') {
349
- return await git.diff([`${commit}~1..${commit}`, '--', nodeFile.filePath]);
350
- }
351
- return await git.diff([commit, nodeFile.filePath]);
352
- }
353
- async function parseRenamedFileDiff(nodeFile, commit, git, logger) {
354
- let result = '';
355
- const oldFilePath = nodeFile?.oldFilePath || nodeFile.filePath;
356
- let previousCommitHash = 'HEAD';
357
- let newCommitHash = '';
358
- if (commit !== '--staged') {
359
- try {
360
- previousCommitHash = await git.revparse([`${commit}~1`]);
361
- }
362
- catch (err) {
363
- logger.verbose(`Error getting previous commit hash for ${nodeFile.filePath}`, {
364
- color: 'red',
365
- });
366
- }
367
- newCommitHash = commit;
368
- }
369
- try {
370
- const [previousContent, newContent] = await Promise.all([
371
- git.show([`${previousCommitHash}:${oldFilePath}`]),
372
- git.show([`${newCommitHash}:${nodeFile.filePath}`]),
373
- ]);
374
- if (previousContent !== newContent) {
375
- result = createTwoFilesPatch(oldFilePath, nodeFile.filePath, previousContent, newContent, '', '', {
376
- context: 3,
377
- });
378
- // remove the first 4 lines of the patch (they contain the old and new file names)
379
- result = result.split('\n').slice(4).join('\n');
380
- }
381
- else {
382
- result = 'File contents are unchanged.';
383
- }
384
- }
385
- catch (err) {
386
- logger.verbose(`Error comparing file contents for ${nodeFile.filePath}`, { color: 'red' });
387
- result = 'Error comparing file contents.';
388
- }
389
- return result;
390
- }
391
- async function getDiff(nodeFile, commit, { git, logger, }) {
392
- if (nodeFile.status === 'deleted') {
393
- return 'This file has been deleted.';
394
- }
395
- if (nodeFile.status === 'renamed' && nodeFile.oldFilePath) {
396
- const renamedDiff = await parseRenamedFileDiff(nodeFile, commit, git, logger);
397
- return renamedDiff;
343
+ function loadXDGConfig(config) {
344
+ const xdgConfigHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
345
+ const xdgConfigPath = path.join(xdgConfigHome, 'coco', 'config.json');
346
+ if (fs.existsSync(xdgConfigPath)) {
347
+ const xdgConfig = JSON.parse(fs.readFileSync(xdgConfigPath, 'utf-8'));
348
+ config = { ...config, ...xdgConfig };
398
349
  }
399
- // If not deleted or renamed, get the diff from the index
400
- const defaultDiff = await parseDefaultFileDiff(nodeFile, commit, git);
401
- return defaultDiff;
402
- }
403
-
404
- const MAX_TOKENS_PER_SUMMARY = 2048;
405
- async function fileChangeParser({ changes, commit, options: { tokenizer, git, model, logger }, }) {
406
- const textSplitter = getTextSplitter({ chunkSize: 2000, chunkOverlap: 125 });
407
- const summarizationChain = getChain(model, {
408
- type: 'map_reduce',
409
- combineMapPrompt: SUMMARIZE_PROMPT,
410
- combinePrompt: SUMMARIZE_PROMPT,
411
- });
412
- logger.startTimer();
413
- const rootTreeNode = createDiffTree(changes);
414
- logger.stopTimer('Created file hierarchy');
415
- // Collect diffs
416
- logger.startTimer().startSpinner(`Collecting Diffs...\n`, { color: 'blue' });
417
- const diffs = await collectDiffs(rootTreeNode, (path) => getDiff(path, commit, { git, logger }), tokenizer, logger);
418
- logger.stopSpinner('Diffs Collected').stopTimer();
419
- // Summarize diffs
420
- logger.startTimer();
421
- const summary = await summarizeDiffs(diffs, {
422
- tokenizer,
423
- maxTokens: MAX_TOKENS_PER_SUMMARY,
424
- textSplitter,
425
- chain: summarizationChain,
426
- logger,
427
- });
428
- logger.stopTimer(`\nSummary generated for ${changes.length} staged files`, { color: 'green' });
429
- return summary;
350
+ return config;
430
351
  }
431
352
 
432
353
  /**
433
- * Wrapper around GPT3NodeTokenizer to handle default export.
354
+ * Load application config
434
355
  *
435
- * @see https://github.com/botisan-ai/gpt3-tokenizer/issues/18
356
+ * Merge config from multiple sources.
436
357
  *
437
- * @returns {GPT3NodeTokenizer} The GPT3NodeTokenizer instance.
438
- */
439
- const getTokenizer = () => {
440
- let tokenizer;
441
- // eslint-disable-next-line
442
- // @ts-ignore
443
- if (GPT3NodeTokenizer.default) {
444
- // eslint-disable-next-line
445
- // @ts-ignore
446
- tokenizer = new GPT3NodeTokenizer.default({ type: 'gpt3' });
447
- }
448
- else {
449
- tokenizer = new GPT3NodeTokenizer({ type: 'gpt3' });
450
- }
451
- return tokenizer;
452
- };
358
+ * \* Order of precedence:
359
+ * \* 1. Command line flags
360
+ * \* 2. Environment variables
361
+ * \* 3. Project config
362
+ * \* 4. Git config
363
+ * \* 5. XDG config
364
+ * \* 6. .gitignore
365
+ * \* 7. .ignore
366
+ * \* 8. Default config
367
+ *
368
+ * @returns {Config} application config
369
+ **/
370
+ function loadConfig(argv = {}) {
371
+ // Default config
372
+ let config = DEFAULT_CONFIG;
373
+ config = loadGitignore(config);
374
+ config = loadIgnore(config);
375
+ config = loadXDGConfig(config);
376
+ config = loadGitConfig(config);
377
+ config = loadProjectConfig(config);
378
+ config = loadEnvConfig(config);
379
+ return { ...config, ...argv };
380
+ }
453
381
 
454
382
  class Logger {
455
383
  constructor(config) {
@@ -502,391 +430,492 @@ class Logger {
502
430
  }
503
431
  }
504
432
 
505
- const template$1 = `Write informative git commit message, in the imperative, based on the diffs & file changes provided in the "Diff Summary" section.
506
- 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.
507
-
508
- - Typically a hyphen or asterisk is used for the bullet
509
- - Write concisely using an informal tone
510
- - DO NOT use phrases like "this commit", "this change", "this function", etc. Instead refer to the function, variable, or class by name
511
- - DO NOT use phrases like "this commit", "this change", "this function", etc. Instead refer to the function, variable, or class by name
512
- - DO NOT use specific names or files from the code
513
- - Wrap variable, class, function, components, and dependency names in back ticks e.g. \`variable\`
514
-
515
- """{summary}"""
516
-
517
- Commit:`;
518
- const inputVariables$1 = ['summary'];
519
- const COMMIT_PROMPT = new PromptTemplate({
520
- template: template$1,
521
- inputVariables: inputVariables$1,
522
- });
523
-
524
- function getStatus(file, location = 'index') {
525
- if ('index' in file && 'working_dir' in file) {
526
- const statusCode = file[location];
527
- switch (statusCode) {
528
- case 'A':
529
- return 'added';
530
- case 'D':
531
- return 'deleted';
532
- case 'M':
533
- return 'modified';
534
- case 'R':
535
- return 'renamed';
536
- case '?':
537
- return 'untracked';
538
- default:
539
- return 'unknown';
433
+ function commandExecutor(handler) {
434
+ return async (argv) => {
435
+ const options = loadConfig(argv);
436
+ const logger = new Logger(options);
437
+ try {
438
+ await handler(argv, logger);
540
439
  }
541
- }
542
- else if ('changes' in file && 'binary' in file) {
543
- if (file.changes === 0)
544
- return 'untracked';
545
- if (file.file.includes('=>'))
546
- return 'renamed';
547
- if (file.deletions === 0 && file.insertions > 0)
548
- return 'added';
549
- if (file.insertions === 0 && file.deletions > 0)
550
- return 'deleted';
551
- if ((file.insertions > 0 && file.deletions > 0) || file.changes > 0)
552
- return 'modified';
553
- return 'unknown';
554
- }
555
- else {
556
- throw new Error('Invalid file type');
557
- }
558
- }
559
-
560
- function getSummaryText(file, change) {
561
- const status = change.status || getStatus(file);
562
- let filePath;
563
- if ('path' in file) {
564
- filePath = file.path;
565
- }
566
- else if ('file' in file) {
567
- filePath = change?.filePath || file.file;
568
- }
569
- else {
570
- throw new Error('Invalid file type');
571
- }
572
- if (change.oldFilePath) {
573
- return `${status}: ${change.oldFilePath} -> ${filePath}`;
574
- }
575
- return `${status}: ${filePath}`;
440
+ catch (error) {
441
+ logger.log('\nFailed to execute command', { color: 'yellow' });
442
+ logger.verbose(`\nError: "${error.message}"`, { color: 'red' });
443
+ logger.log('\nThanks for using coco, make it a great day! πŸ‘‹πŸ€–', { color: 'blue' });
444
+ process.exit(0);
445
+ }
446
+ };
576
447
  }
577
448
 
578
449
  /**
579
- * Returns a new object with all undefined keys removed
580
- *
581
- * @param obj Object to remove undefined keys from
582
- * @returns
450
+ * Extract the path from a file path string.
451
+ * @param {string} filePath - The full file path.
452
+ * @returns {string} The path portion of the file path.
583
453
  */
584
- function removeUndefined(obj) {
585
- return Object.fromEntries(Object.entries(obj).filter(([, value]) => value !== undefined));
454
+ function getPathFromFilePath(filePath) {
455
+ return filePath.split('/').slice(0, -1).join('/');
456
+ }
457
+
458
+ async function summarize(documents, { chain, textSplitter, options }) {
459
+ const { returnIntermediateSteps = false } = options || {};
460
+ const docs = await textSplitter.splitDocuments(documents.map((doc) => new Document(doc)));
461
+ const res = await chain.call({
462
+ input_documents: docs,
463
+ returnIntermediateSteps,
464
+ });
465
+ if (res.error)
466
+ throw new Error(res.error);
467
+ return res.text && res.text.trim();
586
468
  }
587
469
 
588
470
  /**
589
- * Default Config
590
- *
591
- * @type {Config}
592
- */
593
- const DEFAULT_CONFIG = {
594
- model: 'openai/gpt-4',
595
- verbose: false,
596
- tokenLimit: 1024,
597
- summarizePrompt: SUMMARIZE_PROMPT.template,
598
- temperature: 0.4,
599
- mode: 'stdout',
600
- ignoredFiles: ['package-lock.json'],
601
- ignoredExtensions: ['.map', '.lock'],
602
- defaultBranch: 'main',
603
- };
604
- /**
605
- * Config keys
606
- *
607
- * @type {string[]}
471
+ * Create groups from a given node info.
472
+ * @param {DiffNode} node - The node info to start grouping.
473
+ * @returns {DirectoryDiff[]} The groups created.
608
474
  */
609
- const CONFIG_KEYS = Object.keys({
610
- ...DEFAULT_CONFIG,
611
- huggingFaceHubApiKey: '',
612
- openAIApiKey: '',
613
- prompt: '',
614
- });
615
-
616
- async function updateFileSection(filePath, startComment, endComment, getNewContent, confirmUpdate = true) {
617
- const lines = fs__default.existsSync(filePath) ? fs__default.readFileSync(filePath, 'utf-8').split(/\r?\n/) : [];
618
- const newLines = [];
619
- let foundSection = false;
620
- for (let i = 0; i < lines.length; i++) {
621
- if (lines[i].trim() === startComment) {
622
- foundSection = true;
623
- if (confirmUpdate) {
624
- const confirmOverwrite = await confirm({
625
- message: `A section already exists in ${filePath}, do you want to override it?`,
626
- default: false,
627
- });
628
- if (!confirmOverwrite) {
629
- // keep all lines until the end comment
630
- while (i < lines.length && lines[i].trim() !== endComment) {
631
- newLines.push(lines[i]);
632
- i++;
633
- }
634
- newLines.push(endComment);
635
- continue;
636
- }
637
- }
638
- newLines.push(startComment);
639
- // Insert the new content
640
- const newContent = await getNewContent();
641
- newLines.push(newContent);
642
- // Skip the existing content of the section
643
- while (i < lines.length && lines[i].trim() !== endComment) {
644
- i++;
475
+ function createDirectoryDiffs(node) {
476
+ const groupByPath = {};
477
+ function traverse(node) {
478
+ node.diffs.forEach((diff) => {
479
+ const path = getPathFromFilePath(diff.file);
480
+ if (!groupByPath[path]) {
481
+ groupByPath[path] = { diffs: [], path, tokenCount: 0 };
645
482
  }
646
- newLines.push(endComment);
647
- continue;
648
- }
649
- if (!foundSection || lines[i].trim() !== endComment) {
650
- newLines.push(lines[i]);
651
- }
652
- }
653
- // If section wasn't found, append it at the end
654
- if (!foundSection) {
655
- newLines.push('\n' + startComment);
656
- const newContent = await getNewContent();
657
- newLines.push(newContent);
658
- newLines.push(endComment);
483
+ groupByPath[path].diffs.push(diff);
484
+ groupByPath[path].tokenCount += diff.tokenCount;
485
+ });
486
+ node.children.forEach(traverse);
659
487
  }
660
- // Write the updated contents back to the file
661
- fs__default.writeFileSync(filePath, newLines.join('\n'));
488
+ traverse(node);
489
+ return Object.values(groupByPath);
662
490
  }
663
-
664
491
  /**
665
- * Load environment variables
666
- *
667
- * @param {Config} config
668
- * @returns {Config} Updated config
669
- **/
670
- function loadEnvConfig(config) {
671
- const envConfig = {};
672
- CONFIG_KEYS.forEach((key) => {
673
- const envVarName = toEnvVarName(key);
674
- const envValue = parseEnvValue(key, process.env[envVarName]);
675
- if (envValue === undefined)
676
- return;
677
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
678
- // @ts-ignore
679
- envConfig[key] = envValue;
680
- });
681
- return { ...config, ...removeUndefined(envConfig) };
682
- }
683
- function parseEnvValue(key, value) {
684
- if (value === undefined) {
685
- return undefined;
686
- }
687
- else if (key === 'tokenLimit' && typeof value === 'string') {
688
- return parseInt(value);
492
+ * Summarize a directory diff asynchronously.
493
+ */
494
+ async function summarizeDirectoryDiff(directory, { chain, textSplitter, tokenizer }) {
495
+ try {
496
+ const directorySummary = await summarize(directory.diffs.map((diff) => ({
497
+ pageContent: diff.diff,
498
+ metadata: {
499
+ file: diff.file,
500
+ summary: diff.summary,
501
+ },
502
+ })), {
503
+ chain,
504
+ textSplitter,
505
+ options: {
506
+ returnIntermediateSteps: true,
507
+ },
508
+ });
509
+ const newTokenTotal = tokenizer(directorySummary);
510
+ return {
511
+ diffs: directory.diffs,
512
+ path: directory.path,
513
+ summary: directorySummary,
514
+ tokenCount: newTokenTotal,
515
+ };
689
516
  }
690
- else if ((key === 'ignoredFiles' || key === 'ignoredExtensions') &&
691
- typeof value === 'string' &&
692
- value.includes(',')) {
693
- return value.split(',');
517
+ catch (error) {
518
+ console.error(error);
519
+ return directory;
694
520
  }
695
- return value;
696
521
  }
697
- function toEnvVarName(key) {
698
- switch (key) {
699
- case 'openAIApiKey':
700
- return 'OPENAI_API_KEY';
701
- case 'huggingFaceHubApiKey':
702
- return 'HUGGINGFACE_HUB_API_KEY';
703
- default:
704
- return 'COCO_' + key.replace(/([A-Z])/g, '_$1').toUpperCase();
522
+ const defaultOutputCallback = (group) => {
523
+ let output = `
524
+ -------\n* changes in "/${group.path}"\n\n`;
525
+ if (group.summary) {
526
+ output += `${group.diffs.map((diff) => ` β€’ ${diff.summary}`).join('\n')}\n\nSummary:\n\n${group.summary}\n\n`;
527
+ }
528
+ else {
529
+ output += `${group.diffs.map((diff) => ` β€’ ${diff.summary}\n\n${diff.diff}`).join('\n\n')}\n\n`;
705
530
  }
531
+ return output;
532
+ };
533
+ async function summarizeDiffs(rootDiffNode, { tokenizer, logger, maxTokens = 2048, textSplitter, chain, handleOutput = defaultOutputCallback, }) {
534
+ const queue = new pQueue({ concurrency: 8 });
535
+ logger.startTimer().startSpinner(`Organizing Diffs...`, { color: 'blue' });
536
+ const directoryDiffs = createDirectoryDiffs(rootDiffNode);
537
+ // Sort by token count descending
538
+ directoryDiffs.sort((a, b) => b.tokenCount - a.tokenCount);
539
+ let totalTokenCount = directoryDiffs.reduce((sum, group) => sum + group.tokenCount, 0);
540
+ logger.stopSpinner('Diffs Organized').stopTimer();
541
+ logger.startSpinner(`Consolidating Diffs`, { color: 'blue' });
542
+ const processingTasks = directoryDiffs.map((group, i) => {
543
+ return queue.add(async () => {
544
+ // If the diff token count is already less than the average req, we can skip summarizing.
545
+ const isLessThanAvgTokenReq = group.tokenCount <= maxTokens / directoryDiffs.length;
546
+ if (totalTokenCount <= maxTokens || isLessThanAvgTokenReq) {
547
+ return group;
548
+ }
549
+ group = await summarizeDirectoryDiff(group, {
550
+ chain,
551
+ textSplitter,
552
+ tokenizer,
553
+ });
554
+ // We need to subtract the old token count and add the new one
555
+ totalTokenCount = totalTokenCount - directoryDiffs[i].tokenCount + group.tokenCount;
556
+ directoryDiffs[i] = group;
557
+ logger
558
+ .verbose(`\n β€’ Summarized diffs in "/${group.path}" `, { color: 'blue' })
559
+ .verbose(`\nTotal token count: ${totalTokenCount}`, {
560
+ color: totalTokenCount > maxTokens ? 'yellow' : 'green',
561
+ });
562
+ return group;
563
+ }, { priority: group.tokenCount });
564
+ });
565
+ await Promise.all(processingTasks);
566
+ logger.stopSpinner(`Summarized Diffs`);
567
+ return directoryDiffs.map(handleOutput).join('');
706
568
  }
707
- function formatEnvValue(value) {
708
- if (typeof value === 'number') {
709
- return `${value}`;
569
+
570
+ class DiffTreeNode {
571
+ constructor(path) {
572
+ this.path = [];
573
+ this.files = [];
574
+ this.children = new Map();
575
+ if (path)
576
+ this.path = path;
710
577
  }
711
- else if (Array.isArray(value)) {
712
- return `${value.join(',')}`;
578
+ addFile(file) {
579
+ this.files.push(file);
713
580
  }
714
- else if (typeof value === 'string') {
715
- // Escape newlines and tabs in strings
716
- return `${value.replace(/\n/g, '\\n').replace(/\t/g, '\\t')}`;
581
+ addChild(part, node) {
582
+ this.children.set(part, node);
583
+ }
584
+ getChild(part) {
585
+ return this.children.get(part);
586
+ }
587
+ getPath() {
588
+ return this.path.join('/');
589
+ }
590
+ print(indentation = 0) {
591
+ const indent = ' '.repeat(indentation);
592
+ let output = `${indent}- Path: ${this.getPath()}\n`;
593
+ if (this.files.length > 0) {
594
+ output += `${indent} Files:\n`;
595
+ for (const file of this.files) {
596
+ output += `${indent} - ${file.summary}\n`;
597
+ }
598
+ }
599
+ if (this.children.size > 0) {
600
+ output += `${indent} Children:\n`;
601
+ for (const [, child] of this.children) {
602
+ output += child.print(indentation + 4);
603
+ }
604
+ }
605
+ return output;
717
606
  }
718
- return `${value}`;
719
607
  }
720
- const appendToEnvFile = async (filePath, config) => {
721
- const startComment = '# -- Start coco config --';
722
- const endComment = '# -- End coco config --';
723
- const getNewContent = async () => {
724
- return Object.entries(config)
725
- .map(([key, value]) => `${toEnvVarName(key)}=${formatEnvValue(value)}`)
726
- .join('\n');
727
- };
728
- await updateFileSection(filePath, startComment, endComment, getNewContent);
608
+ const createDiffTree = (changes) => {
609
+ const root = new DiffTreeNode();
610
+ for (const change of changes) {
611
+ let currentParent = root;
612
+ const parts = change.filePath.split('/');
613
+ parts.pop();
614
+ for (const part of parts) {
615
+ let childNode = currentParent.getChild(part);
616
+ if (!childNode) {
617
+ childNode = new DiffTreeNode([...currentParent.path, part]);
618
+ currentParent.addChild(part, childNode);
619
+ }
620
+ currentParent = childNode;
621
+ }
622
+ // Create a NodeFile object and add it to the parent
623
+ currentParent.addFile({
624
+ filePath: change.filePath,
625
+ oldFilePath: change.oldFilePath,
626
+ summary: change.summary,
627
+ status: change.status,
628
+ });
629
+ }
630
+ return root;
729
631
  };
730
632
 
731
633
  /**
732
- * Load git profile config (from ~/.gitconfig)
733
- *
734
- * @param {Config} config
735
- * @returns {Config} Updated config
736
- **/
737
- function loadGitConfig(config) {
738
- const gitConfigPath = path.join(os.homedir(), '.gitconfig');
739
- if (fs.existsSync(gitConfigPath)) {
740
- const gitConfigRaw = fs.readFileSync(gitConfigPath, 'utf-8');
741
- const gitConfigParsed = ini.parse(gitConfigRaw);
742
- config = {
743
- ...config,
744
- model: gitConfigParsed.coco?.model || config.model,
745
- openAIApiKey: gitConfigParsed.coco?.openAIApiKey || config.openAIApiKey,
746
- huggingFaceHubApiKey: gitConfigParsed.coco?.huggingFaceHubApiKey || config.huggingFaceHubApiKey,
747
- tokenLimit: parseInt(gitConfigParsed.coco?.tokenLimit) || config.tokenLimit,
748
- prompt: gitConfigParsed.coco?.prompt || config.prompt,
749
- mode: gitConfigParsed.coco?.mode || config.mode,
750
- temperature: gitConfigParsed.coco?.temperature || config.temperature,
751
- summarizePrompt: gitConfigParsed.coco?.summarizePrompt || config.summarizePrompt,
752
- ignoredFiles: gitConfigParsed.coco?.ignoredFiles || config.ignoredFiles,
753
- ignoredExtensions: gitConfigParsed.coco?.ignoredExtensions || config.ignoredExtensions,
754
- defaultBranch: gitConfigParsed.coco?.defaultBranch || config.defaultBranch,
634
+ * Asynchronously collect diffs for a given node and its children.
635
+ */
636
+ async function collectDiffs(node, getFileDiff, tokenizer, logger) {
637
+ // Collect diffs for the files of the current node
638
+ const diffPromises = node.files.map(async (nodeFile) => {
639
+ const diff = await getFileDiff(nodeFile);
640
+ const tokenCount = tokenizer(diff);
641
+ logger.verbose(`Collected diff for ${nodeFile.filePath} (${tokenCount} tokens)`, {
642
+ color: 'magenta',
643
+ });
644
+ return {
645
+ file: nodeFile.filePath,
646
+ summary: nodeFile.summary,
647
+ diff,
648
+ tokenCount,
755
649
  };
650
+ });
651
+ // Collect diffs for the children of the current node
652
+ const childrenPromises = Array.from(node.children.values()).map(async (child) => collectDiffs(child, getFileDiff, tokenizer, logger));
653
+ const [diffs, children] = await Promise.all([
654
+ Promise.all(diffPromises),
655
+ Promise.all(childrenPromises),
656
+ ]);
657
+ return {
658
+ path: node.getPath(),
659
+ diffs,
660
+ children,
661
+ };
662
+ }
663
+
664
+ function getModelAndProviderFromService(service) {
665
+ const [provider, model] = service.split(/\/(.*)/s);
666
+ if (!model || !provider) {
667
+ throw new Error(`Invalid service: ${service}`);
756
668
  }
757
- return config;
669
+ return { provider, model };
670
+ }
671
+ function getModelFromService(service) {
672
+ const { model } = getModelAndProviderFromService(service);
673
+ return model;
758
674
  }
759
675
  /**
760
- * Appends the provided configuration to a git config file.
761
- *
762
- * @param filePath - The path to the .gitconfig
763
- * @param config - The configuration object to append.
676
+ * Get LLM Model Based on Configuration
677
+ * @param fields
678
+ * @param configuration
679
+ * @returns LLM Model
764
680
  */
765
- const appendToGitConfig = async (filePath, config) => {
766
- if (!fs.existsSync(filePath)) {
767
- throw new Error(`File ${filePath} does not exist.`);
681
+ function getLlm(service, key, fields) {
682
+ const { provider, model } = getModelAndProviderFromService(service);
683
+ if (!model) {
684
+ throw new Error(`Invalid LLM Service: ${service}`);
768
685
  }
769
- const startComment = '# -- Start coco config --';
770
- const endComment = '# -- End coco config --';
771
- const header = '[coco]';
772
- // Function to generate new content for the coco section
773
- const getNewContent = async () => {
774
- const contentLines = [header];
775
- for (const key in config) {
776
- // check if string has new lines, if so, wrap in quotes
777
- if (typeof config[key] === 'string') {
778
- const value = config[key];
779
- if (value.includes('\n')) {
780
- contentLines.push(`\t${key} = ${JSON.stringify(value)}`);
781
- continue;
782
- }
783
- }
784
- contentLines.push(`\t${key} = ${config[key]}`);
785
- }
786
- return contentLines.join('\n');
787
- };
788
- // Use the updateFileSection utility
789
- await updateFileSection(filePath, startComment, endComment, getNewContent);
790
- };
791
-
686
+ switch (provider) {
687
+ case 'huggingface':
688
+ return new HuggingFaceInference({
689
+ model: model,
690
+ apiKey: key,
691
+ maxConcurrency: 4,
692
+ ...fields,
693
+ });
694
+ case 'openai':
695
+ default:
696
+ return new OpenAI({
697
+ openAIApiKey: key,
698
+ modelName: model,
699
+ ...fields,
700
+ });
701
+ }
702
+ }
792
703
  /**
793
- * Load .gitignore in project root
794
- *
795
- * @param {Config} config
704
+ * Retrieve appropriate API key based on selected model
705
+ * @param service
706
+ * @param options
796
707
  * @returns
797
708
  */
798
- function loadGitignore(config) {
799
- if (fs.existsSync('.gitignore')) {
800
- const gitignoreContent = fs.readFileSync('.gitignore', 'utf-8');
801
- config.ignoredFiles = [
802
- ...(config?.ignoredFiles || []),
803
- ...gitignoreContent.split('\n').filter((line) => line.trim() !== '' && !line.startsWith('#')),
804
- ];
709
+ function getApiKeyForModel(service, options) {
710
+ const { provider } = getModelAndProviderFromService(service);
711
+ switch (provider) {
712
+ case 'huggingface':
713
+ return options.huggingFaceHubApiKey;
714
+ case 'openai':
715
+ default:
716
+ return options.openAIApiKey;
805
717
  }
806
- return config;
807
718
  }
808
719
  /**
809
- * Load .ignore in project root
810
- *
811
- * @param {Config} config
720
+ * Get Recursive Character Text Splitter
721
+ * @param options
812
722
  * @returns
813
723
  */
814
- function loadIgnore(config) {
815
- if (fs.existsSync('.ignore')) {
816
- const ignoreContent = fs.readFileSync('.ignore', 'utf-8');
817
- config.ignoredFiles = [
818
- ...(config?.ignoredFiles || []),
819
- ...ignoreContent.split('\n').filter((line) => line.trim() !== '' && !line.startsWith('#')),
820
- ];
724
+ function getTextSplitter(options = {}) {
725
+ return new RecursiveCharacterTextSplitter(options);
726
+ }
727
+ /**
728
+ * Get Summarization Chain
729
+ * @param model
730
+ * @param options
731
+ * @returns
732
+ */
733
+ function getSummarizationChain(model, options = { type: 'map_reduce' }) {
734
+ return loadSummarizationChain(model, options);
735
+ }
736
+ function getPrompt({ template, variables, fallback }) {
737
+ if (!template && !fallback)
738
+ throw new Error('Must provide either a template or a fallback');
739
+ return (template
740
+ ? new PromptTemplate({
741
+ template,
742
+ inputVariables: variables,
743
+ })
744
+ : fallback);
745
+ }
746
+ /**
747
+ * Verify template string contains all required input variables
748
+ * @param text template string
749
+ * @param inputVariables template variables
750
+ * @returns boolean or error message
751
+ */
752
+ function validatePromptTemplate(text, inputVariables) {
753
+ if (!text) {
754
+ return 'Prompt template cannot be empty';
755
+ }
756
+ if (!inputVariables.some((entry) => text.includes(entry))) {
757
+ return ('Prompt template must include at least one of the following input variables: ' +
758
+ inputVariables.map((value) => `{${value}}`).join(', '));
759
+ }
760
+ return true;
761
+ }
762
+
763
+ async function parseDefaultFileDiff(nodeFile, commit = '--staged', git) {
764
+ if (commit !== '--staged') {
765
+ return await git.diff([`${commit}~1..${commit}`, '--', nodeFile.filePath]);
766
+ }
767
+ return await git.diff([commit, nodeFile.filePath]);
768
+ }
769
+ async function parseRenamedFileDiff(nodeFile, commit, git, logger) {
770
+ let result = '';
771
+ const oldFilePath = nodeFile?.oldFilePath || nodeFile.filePath;
772
+ let previousCommitHash = 'HEAD';
773
+ let newCommitHash = '';
774
+ if (commit !== '--staged') {
775
+ try {
776
+ previousCommitHash = await git.revparse([`${commit}~1`]);
777
+ }
778
+ catch (err) {
779
+ logger.verbose(`Error getting previous commit hash for ${nodeFile.filePath}`, {
780
+ color: 'red',
781
+ });
782
+ }
783
+ newCommitHash = commit;
784
+ }
785
+ try {
786
+ const [previousContent, newContent] = await Promise.all([
787
+ git.show([`${previousCommitHash}:${oldFilePath}`]),
788
+ git.show([`${newCommitHash}:${nodeFile.filePath}`]),
789
+ ]);
790
+ if (previousContent !== newContent) {
791
+ result = createTwoFilesPatch(oldFilePath, nodeFile.filePath, previousContent, newContent, '', '', {
792
+ context: 3,
793
+ });
794
+ // remove the first 4 lines of the patch (they contain the old and new file names)
795
+ result = result.split('\n').slice(4).join('\n');
796
+ }
797
+ else {
798
+ result = 'File contents are unchanged.';
799
+ }
800
+ }
801
+ catch (err) {
802
+ logger.verbose(`Error comparing file contents for ${nodeFile.filePath}`, { color: 'red' });
803
+ result = 'Error comparing file contents.';
804
+ }
805
+ return result;
806
+ }
807
+ async function getDiff(nodeFile, commit, { git, logger, }) {
808
+ if (nodeFile.status === 'deleted') {
809
+ return 'This file has been deleted.';
810
+ }
811
+ if (nodeFile.status === 'renamed' && nodeFile.oldFilePath) {
812
+ const renamedDiff = await parseRenamedFileDiff(nodeFile, commit, git, logger);
813
+ return renamedDiff;
814
+ }
815
+ // If not deleted or renamed, get the diff from the index
816
+ const defaultDiff = await parseDefaultFileDiff(nodeFile, commit, git);
817
+ return defaultDiff;
818
+ }
819
+
820
+ const MAX_TOKENS_PER_SUMMARY = 2048;
821
+ async function fileChangeParser({ changes, commit, options: { tokenizer, git, llm: model, logger }, }) {
822
+ const textSplitter = getTextSplitter({ chunkSize: 2000, chunkOverlap: 125 });
823
+ const summarizationChain = getSummarizationChain(model, {
824
+ type: 'map_reduce',
825
+ combineMapPrompt: SUMMARIZE_PROMPT,
826
+ combinePrompt: SUMMARIZE_PROMPT,
827
+ });
828
+ logger.startTimer();
829
+ const rootTreeNode = createDiffTree(changes);
830
+ logger.stopTimer('Created file hierarchy');
831
+ // Collect diffs
832
+ logger.startTimer().startSpinner(`Collecting Diffs...\n`, { color: 'blue' });
833
+ const diffs = await collectDiffs(rootTreeNode, (path) => getDiff(path, commit, { git, logger }), tokenizer, logger);
834
+ logger.stopSpinner('Diffs Collected').stopTimer();
835
+ // Summarize diffs
836
+ logger.startTimer();
837
+ const summary = await summarizeDiffs(diffs, {
838
+ tokenizer,
839
+ maxTokens: MAX_TOKENS_PER_SUMMARY,
840
+ textSplitter,
841
+ chain: summarizationChain,
842
+ logger,
843
+ });
844
+ logger.stopTimer(`\nSummary generated for ${changes.length} staged files`, { color: 'green' });
845
+ return summary;
846
+ }
847
+
848
+ const template$1 = `Write informative git commit message, in the imperative, based on the diffs & file changes provided in the "Diff Summary" section.
849
+ 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.
850
+
851
+ - Typically a hyphen or asterisk is used for the bullet
852
+ - Write concisely using an informal tone
853
+ - DO NOT use phrases like "this commit", "this change", "this function", etc. Instead refer to the function, variable, or class by name
854
+ - DO NOT use phrases like "this commit", "this change", "this function", etc. Instead refer to the function, variable, or class by name
855
+ - DO NOT use specific names or files from the code
856
+ - Wrap variable, class, function, components, and dependency names in back ticks e.g. \`variable\`
857
+
858
+ """{summary}"""
859
+
860
+ Commit:`;
861
+ const inputVariables$1 = ['summary'];
862
+ const COMMIT_PROMPT = new PromptTemplate({
863
+ template: template$1,
864
+ inputVariables: inputVariables$1,
865
+ });
866
+
867
+ function getStatus(file, location = 'index') {
868
+ if ('index' in file && 'working_dir' in file) {
869
+ const statusCode = file[location];
870
+ switch (statusCode) {
871
+ case 'A':
872
+ return 'added';
873
+ case 'D':
874
+ return 'deleted';
875
+ case 'M':
876
+ return 'modified';
877
+ case 'R':
878
+ return 'renamed';
879
+ case '?':
880
+ return 'untracked';
881
+ default:
882
+ return 'unknown';
883
+ }
884
+ }
885
+ else if ('changes' in file && 'binary' in file) {
886
+ if (file.changes === 0)
887
+ return 'untracked';
888
+ if (file.file.includes('=>'))
889
+ return 'renamed';
890
+ if (file.deletions === 0 && file.insertions > 0)
891
+ return 'added';
892
+ if (file.insertions === 0 && file.deletions > 0)
893
+ return 'deleted';
894
+ if ((file.insertions > 0 && file.deletions > 0) || file.changes > 0)
895
+ return 'modified';
896
+ return 'unknown';
821
897
  }
822
- return config;
823
- }
824
-
825
- /**
826
- * Load project config
827
- *
828
- * @param {Config} config
829
- * @returns {Config} Updated config
830
- **/
831
- function loadProjectConfig(config) {
832
- // TODO: Add validation based of JSON schema?
833
- // @see https://github.com/acornejo/jjv
834
- if (fs.existsSync('.coco.config.json')) {
835
- const projectConfig = JSON.parse(fs.readFileSync('.coco.config.json', 'utf-8'));
836
- config = { ...config, ...projectConfig };
898
+ else {
899
+ throw new Error('Invalid file type');
837
900
  }
838
- return config;
839
901
  }
840
- const appendToProjectConfig = (filePath, config) => {
841
- fs.writeFileSync(filePath, JSON.stringify({
842
- $schema: 'https://git-co.co/schema.json',
843
- ...config,
844
- }, null, 2));
845
- };
846
902
 
847
- /**
848
- * Load XDG config
849
- *
850
- * @param {Config} config
851
- * @returns {Config} Updated config
852
- */
853
- function loadXDGConfig(config) {
854
- const xdgConfigHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
855
- const xdgConfigPath = path.join(xdgConfigHome, 'coco', 'config.json');
856
- if (fs.existsSync(xdgConfigPath)) {
857
- const xdgConfig = JSON.parse(fs.readFileSync(xdgConfigPath, 'utf-8'));
858
- config = { ...config, ...xdgConfig };
903
+ function getSummaryText(file, change) {
904
+ const status = change.status || getStatus(file);
905
+ let filePath;
906
+ if ('path' in file) {
907
+ filePath = file.path;
859
908
  }
860
- return config;
861
- }
862
-
863
- /**
864
- * Load application config
865
- *
866
- * Merge config from multiple sources.
867
- *
868
- * \* Order of precedence:
869
- * \* 1. Command line flags
870
- * \* 2. Environment variables
871
- * \* 3. Project config
872
- * \* 4. Git config
873
- * \* 5. XDG config
874
- * \* 6. .gitignore
875
- * \* 7. .ignore
876
- * \* 8. Default config
877
- *
878
- * @returns {Config} application config
879
- **/
880
- function loadConfig(argv = {}) {
881
- // Default config
882
- let config = DEFAULT_CONFIG;
883
- config = loadGitignore(config);
884
- config = loadIgnore(config);
885
- config = loadXDGConfig(config);
886
- config = loadGitConfig(config);
887
- config = loadProjectConfig(config);
888
- config = loadEnvConfig(config);
889
- return { ...config, ...argv };
909
+ else if ('file' in file) {
910
+ filePath = change?.filePath || file.file;
911
+ }
912
+ else {
913
+ throw new Error('Invalid file type');
914
+ }
915
+ if (change.oldFilePath) {
916
+ return `${status}: ${change.oldFilePath} -> ${filePath}`;
917
+ }
918
+ return `${status}: ${filePath}`;
890
919
  }
891
920
 
892
921
  const config = loadConfig();
@@ -974,11 +1003,6 @@ async function noResult({ git, logger }) {
974
1003
  }
975
1004
  }
976
1005
 
977
- const isInteractive = (argv) => {
978
- return argv?.mode === 'interactive' || argv.interactive;
979
- };
980
- const SEPERATOR = chalk.blue('----------------');
981
-
982
1006
  function logResult(label, result) {
983
1007
  console.log(`\n${chalk.bgBlue(chalk.bold(`Proposed ${label}:`))}\n${SEPERATOR}\n${result}\n${SEPERATOR}\n`);
984
1008
  }
@@ -995,41 +1019,51 @@ async function editResult(result, options) {
995
1019
  return result;
996
1020
  }
997
1021
 
998
- async function getUserReviewDecision() {
999
- return await select({
1000
- message: 'Would you like to make any changes to the commit message?',
1001
- choices: [
1002
- {
1003
- name: '✨ Looks good!',
1004
- value: 'approve',
1005
- description: 'Commit staged changes with generated commit message',
1006
- },
1007
- {
1008
- name: 'πŸ“ Edit',
1009
- value: 'edit',
1010
- description: 'Edit the commit message before proceeding',
1011
- },
1012
- {
1013
- name: 'πŸͺΆ Modify Prompt',
1014
- value: 'modifyPrompt',
1015
- description: 'Modify the prompt template and regenerate the commit message',
1016
- },
1017
- {
1018
- name: 'πŸ”„ Retry - Message Only',
1019
- value: 'retryMessageOnly',
1020
- description: 'Restart the function execution from generating the commit message',
1021
- },
1022
- {
1023
- name: 'πŸ”„ Retry - Full',
1024
- value: 'retryFull',
1025
- description: 'Restart the function execution from the beginning, regenerating both the diff summary and commit message',
1026
- },
1027
- {
1028
- name: 'πŸ’£ Cancel',
1029
- value: 'cancel',
1030
- },
1031
- ],
1022
+ async function getUserReviewDecision({ label, descriptions, enableRetry = true, enableFullRetry = true, enableModifyPrompt = true, }) {
1023
+ const choices = [
1024
+ {
1025
+ name: '✨ Looks good!',
1026
+ value: 'approve',
1027
+ description: descriptions?.approve || `Continue with the generated ${label}`,
1028
+ },
1029
+ {
1030
+ name: 'πŸ“ Edit',
1031
+ value: 'edit',
1032
+ description: descriptions?.edit || `Edit the generated ${label} before proceeding`,
1033
+ },
1034
+ ];
1035
+ if (enableModifyPrompt) {
1036
+ choices.push({
1037
+ name: 'πŸͺΆ Modify Prompt',
1038
+ value: 'modifyPrompt',
1039
+ description: descriptions?.modifyPrompt || `Modify the prompt template and regenerate the ${label}`,
1040
+ });
1041
+ }
1042
+ if (enableRetry) {
1043
+ choices.push({
1044
+ name: 'πŸ”„ Retry',
1045
+ value: 'retryMessageOnly',
1046
+ description: descriptions?.retryMessageOnly ||
1047
+ `Restart the function execution from generating the ${label}`,
1048
+ });
1049
+ }
1050
+ if (enableFullRetry) {
1051
+ choices.push({
1052
+ name: 'πŸ”„ Retry Full',
1053
+ value: 'retryFull',
1054
+ description: descriptions?.retryFull ||
1055
+ `Restart the function execution from the beginning, regenerating both the summary and ${label}`,
1056
+ });
1057
+ }
1058
+ choices.push({
1059
+ name: 'πŸ’£ Cancel',
1060
+ value: 'cancel',
1061
+ description: descriptions?.cancel || `Cancel the ${label}`,
1032
1062
  });
1063
+ return (await select({
1064
+ message: `Would you like to make any changes to the ${label}?`,
1065
+ choices,
1066
+ }));
1033
1067
  }
1034
1068
 
1035
1069
  async function editPrompt(options) {
@@ -1082,7 +1116,10 @@ async function generateAndReviewLoop({ label, factory, parser, noResult, agent,
1082
1116
  .stopTimer();
1083
1117
  if (options?.interactive) {
1084
1118
  logResult(label, result);
1085
- const reviewAnswer = await getUserReviewDecision();
1119
+ const reviewAnswer = await getUserReviewDecision({
1120
+ label,
1121
+ ...options?.review || {},
1122
+ });
1086
1123
  if (reviewAnswer === 'cancel') {
1087
1124
  process.exit(0);
1088
1125
  }
@@ -1136,20 +1173,20 @@ const executeChain = async ({ llm, prompt, variables }) => {
1136
1173
  return res.text.trim();
1137
1174
  };
1138
1175
 
1139
- async function createCommit(commitMsg, git) {
1140
- return await git.commit(commitMsg);
1141
- }
1142
-
1143
1176
  const logSuccess = () => {
1144
1177
  console.log(chalk.green(chalk.bold('\nAll set! πŸ¦ΎπŸ€–')));
1145
1178
  };
1146
1179
 
1147
- const handleResult = async (result, { mode, git }) => {
1148
- // Handle resulting commit message
1180
+ async function handleResult({ result, mode, interactiveHandler }) {
1149
1181
  switch (mode) {
1150
1182
  case 'interactive':
1151
- await createCommit(result, git);
1152
- logSuccess();
1183
+ if (interactiveHandler) {
1184
+ await interactiveHandler(result);
1185
+ }
1186
+ else {
1187
+ console.warn('No result handler provided for interactive mode.');
1188
+ logSuccess();
1189
+ }
1153
1190
  break;
1154
1191
  case 'stdout':
1155
1192
  default:
@@ -1157,7 +1194,7 @@ const handleResult = async (result, { mode, git }) => {
1157
1194
  break;
1158
1195
  }
1159
1196
  process.exit(0);
1160
- };
1197
+ }
1161
1198
 
1162
1199
  const getRepo = () => {
1163
1200
  let git;
@@ -1171,21 +1208,38 @@ const getRepo = () => {
1171
1208
  return git;
1172
1209
  };
1173
1210
 
1174
- async function handler$2(argv) {
1175
- const tokenizer = getTokenizer();
1211
+ const getTikToken = async (modelName) => {
1212
+ return await encoding_for_model(modelName);
1213
+ };
1214
+ const getTokenCounter = async (modelName) => getTikToken(modelName).then((tokenizer) => (text) => {
1215
+ // console.log('Running GetTokenCount', { tokenizer, length: text.length })
1216
+ const tokens = tokenizer.encode(text);
1217
+ // console.log('Tokens', { tokenCount: tokens.length })
1218
+ return tokens.length;
1219
+ });
1220
+
1221
+ async function createCommit(commitMsg, git) {
1222
+ return await git.commit(commitMsg);
1223
+ }
1224
+
1225
+ const handler$2 = async (argv, logger) => {
1176
1226
  const git = getRepo();
1177
1227
  const options = loadConfig(argv);
1178
- const logger = new Logger(options);
1179
- const key = getApiKeyForModel(options.model, options);
1228
+ const { service } = options;
1229
+ const key = getApiKeyForModel(service, options);
1230
+ const tokenizer = await getTokenCounter(getModelFromService(service));
1180
1231
  if (!key) {
1181
1232
  logger.log(`No API Key found. πŸ—οΈπŸšͺ`, { color: 'red' });
1182
1233
  process.exit(1);
1183
1234
  }
1184
- const model = getModel(options.model, key, {
1235
+ const llm = getLlm(service, key, {
1185
1236
  temperature: 0.4,
1186
1237
  maxConcurrency: 10,
1187
1238
  });
1188
1239
  const INTERACTIVE = isInteractive(options);
1240
+ if (INTERACTIVE) {
1241
+ logger.log(LOGO);
1242
+ }
1189
1243
  async function factory() {
1190
1244
  const changes = await getChanges({ git });
1191
1245
  return changes.staged;
@@ -1194,16 +1248,16 @@ async function handler$2(argv) {
1194
1248
  return await fileChangeParser({
1195
1249
  changes,
1196
1250
  commit: '--staged',
1197
- options: { tokenizer, git, model, logger },
1251
+ options: { tokenizer, git, llm, logger },
1198
1252
  });
1199
1253
  }
1200
1254
  const commitMsg = await generateAndReviewLoop({
1201
- label: 'Commit Message',
1255
+ label: 'commit message',
1202
1256
  factory,
1203
1257
  parser,
1204
1258
  agent: async (context, options) => {
1205
1259
  return await executeChain({
1206
- llm: model,
1260
+ llm,
1207
1261
  prompt: getPrompt({
1208
1262
  template: options.prompt,
1209
1263
  variables: COMMIT_PROMPT.inputVariables,
@@ -1221,14 +1275,27 @@ async function handler$2(argv) {
1221
1275
  prompt: options.prompt || COMMIT_PROMPT.template,
1222
1276
  logger,
1223
1277
  interactive: INTERACTIVE,
1278
+ review: {
1279
+ descriptions: {
1280
+ approve: `Commit staged changes with generated commit message`,
1281
+ edit: 'Edit the commit message before proceeding',
1282
+ modifyPrompt: 'Modify the prompt template and regenerate the commit message',
1283
+ retryMessageOnly: 'Restart the function execution from generating the commit message',
1284
+ retryFull: 'Restart the function execution from the beginning, regenerating both the diff summary and commit message',
1285
+ },
1286
+ },
1224
1287
  },
1225
1288
  });
1226
1289
  const MODE = (INTERACTIVE && 'interactive') || (options.commit && 'interactive') || options?.mode || 'stdout';
1227
- handleResult(commitMsg, {
1290
+ handleResult({
1291
+ result: commitMsg,
1292
+ interactiveHandler: async (result) => {
1293
+ await createCommit(result, git);
1294
+ logSuccess();
1295
+ },
1228
1296
  mode: MODE,
1229
- git,
1230
1297
  });
1231
- }
1298
+ };
1232
1299
 
1233
1300
  /**
1234
1301
  * Command line options via yargs
@@ -1287,7 +1354,7 @@ var commit = {
1287
1354
  command: 'commit',
1288
1355
  desc: 'Generate commit message',
1289
1356
  builder: builder$2,
1290
- handler: handler$2,
1357
+ handler: commandExecutor(handler$2),
1291
1358
  options: options$2,
1292
1359
  };
1293
1360
 
@@ -1364,20 +1431,22 @@ async function getCommitLogCurrentBranch({ git, logger, comparisonBranch = 'main
1364
1431
  return [];
1365
1432
  }
1366
1433
 
1367
- async function handler$1(argv) {
1434
+ const handler$1 = async (argv, logger) => {
1368
1435
  const options = loadConfig(argv);
1369
- const logger = new Logger(options);
1370
1436
  const git = getRepo();
1371
- const key = getApiKeyForModel(options.model, options);
1437
+ const key = getApiKeyForModel(options.service, options);
1372
1438
  if (!key) {
1373
1439
  logger.log(`No API Key found. πŸ—οΈπŸšͺ`, { color: 'red' });
1374
1440
  process.exit(1);
1375
1441
  }
1376
- const model = getModel(options.model, key, {
1442
+ const model = getLlm(options.service, key, {
1377
1443
  temperature: 0.4,
1378
1444
  maxConcurrency: 10,
1379
1445
  });
1380
1446
  const INTERACTIVE = isInteractive(options);
1447
+ if (INTERACTIVE) {
1448
+ logger.log(LOGO);
1449
+ }
1381
1450
  async function factory() {
1382
1451
  if (options.range) {
1383
1452
  const [from, to] = options.range?.split(':');
@@ -1395,7 +1464,7 @@ async function handler$1(argv) {
1395
1464
  return result;
1396
1465
  }
1397
1466
  const changelogMsg = await generateAndReviewLoop({
1398
- label: 'Changelog',
1467
+ label: 'changelog',
1399
1468
  factory,
1400
1469
  parser,
1401
1470
  agent: async (context, options) => {
@@ -1423,14 +1492,20 @@ async function handler$1(argv) {
1423
1492
  prompt: options.prompt || CHANGELOG_PROMPT.template,
1424
1493
  logger,
1425
1494
  interactive: INTERACTIVE,
1495
+ review: {
1496
+ enableFullRetry: false,
1497
+ },
1426
1498
  },
1427
1499
  });
1428
1500
  const MODE = (INTERACTIVE && 'interactive') || (options.commit && 'interactive') || options?.mode || 'stdout';
1429
- handleResult(changelogMsg, {
1501
+ handleResult({
1502
+ result: changelogMsg,
1503
+ interactiveHandler: async () => {
1504
+ logSuccess();
1505
+ },
1430
1506
  mode: MODE,
1431
- git,
1432
1507
  });
1433
- }
1508
+ };
1434
1509
 
1435
1510
  /**
1436
1511
  * Command line options via yargs
@@ -1489,39 +1564,138 @@ var changelog = {
1489
1564
  command: 'changelog',
1490
1565
  desc: 'Generate a changelog from a commit range',
1491
1566
  builder: builder$1,
1492
- handler: handler$1,
1567
+ handler: commandExecutor(handler$1),
1493
1568
  options: options$1,
1494
1569
  };
1495
1570
 
1496
- const handleProjectLevelConfig = async () => {
1497
- const projectConfiguration = await select({
1498
- message: 'select type project level configuration:',
1499
- choices: [
1500
- {
1501
- name: '.coco.config.json',
1502
- value: '.coco.config.json',
1503
- },
1504
- {
1505
- name: '.env',
1506
- value: '.env',
1507
- },
1508
- ],
1571
+ /**
1572
+ * Finds the project root directory starting from the given current directory.
1573
+ * It checks if the `.git` directory or `package.json` file exists in the current directory or any of its parent directories.
1574
+ * If found, it returns the path to the project root directory.
1575
+ * If not found, it throws an error.
1576
+ *
1577
+ * @param currentDir - The current directory to start searching from.
1578
+ * @returns The path to the project root directory.
1579
+ * @throws Error if the project root directory cannot be found.
1580
+ */
1581
+ function findProjectRoot(currentDir) {
1582
+ const root = path__default.parse(currentDir).root;
1583
+ while (currentDir !== root) {
1584
+ if (fs__default.existsSync(path__default.join(currentDir, '.git')) ||
1585
+ fs__default.existsSync(path__default.join(currentDir, 'package.json'))) {
1586
+ return currentDir;
1587
+ }
1588
+ currentDir = path__default.dirname(currentDir);
1589
+ }
1590
+ throw new Error('Unable to find project root. Are you in the right directory?');
1591
+ }
1592
+
1593
+ // Function to execute a command and return a promise
1594
+ function execPromise(command, options = {}) {
1595
+ return new Promise((resolve, reject) => {
1596
+ exec(command, options, (error, stdout, stderr) => {
1597
+ if (error) {
1598
+ reject(`Execution error: ${error}`);
1599
+ }
1600
+ else {
1601
+ resolve({ stdout, stderr });
1602
+ }
1603
+ });
1509
1604
  });
1510
- let configFile = '.coco.config.json';
1511
- if (projectConfiguration === '.env') {
1512
- configFile = '.env';
1513
- if (!fs__default.existsSync('.env')) {
1514
- fs__default.writeFileSync('.env', '');
1605
+ }
1606
+
1607
+ /**
1608
+ * Installs a package using npm.
1609
+ *
1610
+ * @param {InstallPackageInput} options - The options for installing the package.
1611
+ * @returns {Promise<boolean>} - A promise that resolves to true if the package is installed successfully, false otherwise.
1612
+ */
1613
+ async function installNpmPackage({ name, flags = [], cwd = process.cwd(), }) {
1614
+ const { stdout, stderr } = await execPromise(`npm i ${name} ${flags.join(' ')} --yes`, { cwd });
1615
+ if (stderr) {
1616
+ console.error(`Execution error: ${stderr}`);
1617
+ return false;
1618
+ }
1619
+ console.log(stdout);
1620
+ console.error(stderr);
1621
+ return true;
1622
+ }
1623
+
1624
+ /**
1625
+ * Checks if a package is installed in a project.
1626
+ *
1627
+ * @param packageName - The name of the package to check.
1628
+ * @param projectPath - The path to the project.
1629
+ * @returns True if the package is installed, false otherwise.
1630
+ */
1631
+ function isPackageInstalled(packageName, projectPath) {
1632
+ try {
1633
+ // Construct the path to the package.json file
1634
+ const packageJsonPath = path__default.join(projectPath, 'package.json');
1635
+ // Read the package.json file
1636
+ const packageJson = JSON.parse(fs__default.readFileSync(packageJsonPath, 'utf8'));
1637
+ // Check both dependencies and devDependencies
1638
+ const dependencies = packageJson.dependencies || {};
1639
+ const devDependencies = packageJson.devDependencies || {};
1640
+ // Return true if the package is found in either
1641
+ return dependencies.hasOwnProperty(packageName) || devDependencies.hasOwnProperty(packageName);
1642
+ }
1643
+ catch (error) {
1644
+ console.error(`Error checking package installation: ${error.message}`);
1645
+ return false;
1646
+ }
1647
+ }
1648
+
1649
+ // TODO: QoL improvement to import this from `package.json`
1650
+ const packageName = 'git-coco';
1651
+ async function checkAndHandlePackageInstallation({ global = false, logger, }) {
1652
+ try {
1653
+ // Global installation
1654
+ if (global) {
1655
+ logger.startSpinner(`Installing '${packageName}' globally...`, { color: 'blue' });
1656
+ await installNpmPackage({ name: packageName, flags: ['-g'] });
1657
+ logger.stopSpinner(`Installed '${packageName}' globally`);
1658
+ return;
1659
+ }
1660
+ // Project level installation
1661
+ const projectRoot = findProjectRoot(process.cwd());
1662
+ let shouldInstall = false;
1663
+ if (isPackageInstalled(packageName, projectRoot)) {
1664
+ shouldInstall = await confirm({
1665
+ message: `'${packageName}' is already installed in '${projectRoot}/package.json', would you like to update?`,
1666
+ default: shouldInstall,
1667
+ });
1668
+ }
1669
+ else {
1670
+ shouldInstall = true;
1671
+ }
1672
+ if (!shouldInstall) {
1673
+ return;
1515
1674
  }
1675
+ logger.startSpinner(`Installing '${packageName}' in project...`, { color: 'blue' });
1676
+ await installNpmPackage({ name: packageName, cwd: projectRoot });
1677
+ logger.stopSpinner(`Installed '${packageName}' in project`);
1516
1678
  }
1517
- return configFile;
1518
- };
1519
- const handleSystemLevelConfig = () => {
1679
+ catch (error) {
1680
+ console.error(`Error: ${error.message}`);
1681
+ }
1682
+ }
1683
+
1684
+ function getPathToUsersGitConfig() {
1520
1685
  return path__default.join(os__default.homedir(), '.gitconfig');
1521
- };
1522
- async function handler(argv) {
1523
- const options = loadConfig(argv);
1524
- const logger = new Logger(options);
1686
+ }
1687
+
1688
+ async function createProjectFileAndReturnPath(fileName, contents) {
1689
+ const projectRoot = findProjectRoot(process.cwd());
1690
+ const configFile = `${projectRoot}/${fileName}`;
1691
+ if (!fs__default.existsSync(configFile)) {
1692
+ fs__default.writeFileSync(configFile, contents || '');
1693
+ }
1694
+ return configFile;
1695
+ }
1696
+
1697
+ const handler = async (argv, logger) => {
1698
+ logger.log(LOGO);
1525
1699
  const level = await select({
1526
1700
  message: 'configure coco at the system or project level:',
1527
1701
  choices: [
@@ -1539,11 +1713,25 @@ async function handler(argv) {
1539
1713
  });
1540
1714
  let configFilePath = '';
1541
1715
  switch (level) {
1542
- case 'system':
1543
- configFilePath = await handleSystemLevelConfig();
1544
- break;
1545
1716
  case 'project':
1546
- configFilePath = await handleProjectLevelConfig();
1717
+ const projectConfiguration = await select({
1718
+ message: 'select type project level configuration:',
1719
+ choices: [
1720
+ {
1721
+ name: '.coco.config.json',
1722
+ value: '.coco.config.json',
1723
+ },
1724
+ {
1725
+ name: '.env',
1726
+ value: '.env',
1727
+ },
1728
+ ],
1729
+ });
1730
+ configFilePath = await createProjectFileAndReturnPath(projectConfiguration);
1731
+ break;
1732
+ case 'system':
1733
+ default:
1734
+ configFilePath = getPathToUsersGitConfig();
1547
1735
  break;
1548
1736
  }
1549
1737
  // interactive v.s stdout mode
@@ -1640,7 +1828,7 @@ async function handler(argv) {
1640
1828
  // add to config after logging, so that the API key is not logged
1641
1829
  config.openAIApiKey = apiKey;
1642
1830
  const isApproved = await confirm({
1643
- message: 'look good? (hiding API key for security)',
1831
+ message: 'looking good? (API key hidden for security)',
1644
1832
  });
1645
1833
  if (isApproved) {
1646
1834
  if (configFilePath.endsWith('.gitconfig')) {
@@ -1652,62 +1840,19 @@ async function handler(argv) {
1652
1840
  else if (configFilePath === '.coco.config.json') {
1653
1841
  await appendToProjectConfig(configFilePath, config);
1654
1842
  }
1655
- logger.log(`init successful! πŸ¦ΎπŸ€–πŸŽ‰`, { color: 'green' });
1843
+ // After config is written, check for package installation
1844
+ await checkAndHandlePackageInstallation({ global: level === 'system', logger });
1845
+ logger.log(`\ninit successful! πŸ¦ΎπŸ€–πŸŽ‰`, { color: 'green' });
1656
1846
  }
1657
1847
  else {
1658
- logger.log('init cancelled.', { color: 'yellow' });
1848
+ logger.log('\ninit cancelled.', { color: 'yellow' });
1659
1849
  }
1660
- }
1850
+ };
1661
1851
 
1662
1852
  /**
1663
1853
  * Command line options via yargs
1664
1854
  */
1665
- const options = {
1666
- model: { type: 'string', description: 'LLM/Model-Name' },
1667
- openAIApiKey: {
1668
- type: 'string',
1669
- description: 'OpenAI API Key',
1670
- conflicts: 'huggingFaceHubApiKey',
1671
- },
1672
- huggingFaceHubApiKey: {
1673
- type: 'string',
1674
- description: 'HuggingFace Hub API Key',
1675
- conflicts: 'openAIApiKey',
1676
- },
1677
- tokenLimit: { type: 'number', description: 'Token limit' },
1678
- prompt: {
1679
- type: 'string',
1680
- alias: 'p',
1681
- description: 'Commit message prompt',
1682
- },
1683
- i: {
1684
- type: 'boolean',
1685
- alias: 'interactive',
1686
- description: 'Toggle interactive mode',
1687
- },
1688
- s: {
1689
- type: 'boolean',
1690
- description: 'Automatically commit staged changes with generated commit message',
1691
- default: false,
1692
- },
1693
- e: {
1694
- type: 'boolean',
1695
- alias: 'edit',
1696
- description: 'Open commit message in editor before proceeding',
1697
- },
1698
- summarizePrompt: {
1699
- type: 'string',
1700
- description: 'Large file summary prompt',
1701
- },
1702
- ignoredFiles: {
1703
- type: 'array',
1704
- description: 'Ignored files',
1705
- },
1706
- ignoredExtensions: {
1707
- type: 'array',
1708
- description: 'Ignored extensions',
1709
- },
1710
- };
1855
+ const options = {};
1711
1856
  const builder = (yargs) => {
1712
1857
  return yargs.options(options);
1713
1858
  };
@@ -1716,7 +1861,7 @@ var init = {
1716
1861
  command: 'init',
1717
1862
  desc: 'Setup coco for a new project or system',
1718
1863
  builder,
1719
- handler,
1864
+ handler: commandExecutor(handler),
1720
1865
  options,
1721
1866
  };
1722
1867
 
@@ -1724,25 +1869,23 @@ var types = /*#__PURE__*/Object.freeze({
1724
1869
  __proto__: null
1725
1870
  });
1726
1871
 
1727
- yargs
1728
- .scriptName('coco')
1729
- .usage('$0 <cmd> [args]')
1730
- .command([commit.command, '$0'], commit.desc,
1872
+ const y = yargs();
1873
+ y.scriptName('coco').usage('$0 <cmd> [args]');
1874
+ y.command([commit.command, '$0'], commit.desc,
1731
1875
  // TODO: fix type on builder
1732
1876
  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
1733
1877
  // @ts-ignore
1734
- commit.builder, commit.handler)
1735
- .command(changelog.command, changelog.desc,
1878
+ commit.builder, commit.handler);
1879
+ y.command(changelog.command, changelog.desc,
1736
1880
  // TODO: fix type on builder
1737
1881
  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
1738
1882
  // @ts-ignore
1739
- changelog.builder, changelog.handler)
1740
- .command(init.command, init.desc,
1883
+ changelog.builder, changelog.handler);
1884
+ y.command(init.command, init.desc,
1741
1885
  // TODO: fix type on builder
1742
1886
  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
1743
1887
  // @ts-ignore
1744
- init.builder, init.handler)
1745
- .demandCommand()
1746
- .help().argv;
1888
+ init.builder, init.handler);
1889
+ y.parse(process.argv.slice(2));
1747
1890
 
1748
1891
  export { changelog, commit, init, types };