git-coco 0.6.3 β†’ 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,163 +1,585 @@
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 chalk from 'chalk';
12
- import ora from 'ora';
13
- import now from 'performance-now';
14
- import prettyMilliseconds from 'pretty-ms';
15
- import * as path from 'path';
16
- import path__default from 'path';
17
23
  import { minimatch } from 'minimatch';
18
- import * as fs from 'fs';
19
- import fs__default from 'fs';
20
- import { confirm, editor, select, password, input } from '@inquirer/prompts';
21
- import * as os from 'os';
22
- import os__default from 'os';
23
- import * as ini from 'ini';
24
24
  import { simpleGit } from 'simple-git';
25
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(directorySummary);
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;
155
- }
156
- addFile(file) {
157
- this.files.push(file);
161
+ function parseEnvValue(key, value) {
162
+ if (value === undefined) {
163
+ return undefined;
158
164
  }
159
- addChild(part, node) {
160
- this.children.set(part, node);
165
+ else if (key === 'tokenLimit' && typeof value === 'string') {
166
+ return parseInt(value);
167
+ }
168
+ else if ((key === 'ignoredFiles' || key === 'ignoredExtensions') &&
169
+ typeof value === 'string' &&
170
+ value.includes(',')) {
171
+ return value.split(',');
172
+ }
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();
183
+ }
184
+ }
185
+ function formatEnvValue(value) {
186
+ if (typeof value === 'number') {
187
+ return `${value}`;
188
+ }
189
+ else if (Array.isArray(value)) {
190
+ return `${value.join(',')}`;
191
+ }
192
+ else if (typeof value === 'string') {
193
+ // Escape newlines and tabs in strings
194
+ return `${value.replace(/\n/g, '\\n').replace(/\t/g, '\\t')}`;
195
+ }
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
+ });
213
+ };
214
+
215
+ /**
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,
239
+ };
240
+ }
241
+ return config;
242
+ }
243
+ /**
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.
248
+ */
249
+ const appendToGitConfig = async (filePath, config) => {
250
+ if (!fs.existsSync(filePath)) {
251
+ throw new Error(`File ${filePath} does not exist.`);
252
+ }
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
+
282
+ /**
283
+ * Load .gitignore in project root
284
+ *
285
+ * @param {Config} config
286
+ * @returns
287
+ */
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
+ ];
295
+ }
296
+ return config;
297
+ }
298
+ /**
299
+ * Load .ignore in project root
300
+ *
301
+ * @param {Config} config
302
+ * @returns
303
+ */
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;
313
+ }
314
+
315
+ /**
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;
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
+
337
+ /**
338
+ * Load XDG config
339
+ *
340
+ * @param {Config} config
341
+ * @returns {Config} Updated config
342
+ */
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 };
349
+ }
350
+ return config;
351
+ }
352
+
353
+ /**
354
+ * Load application config
355
+ *
356
+ * Merge config from multiple sources.
357
+ *
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
+ }
381
+
382
+ class Logger {
383
+ constructor(config) {
384
+ this.config = config;
385
+ this.spinner = null;
386
+ }
387
+ log(message, options = { color: 'blue' }) {
388
+ let outputMessage = message;
389
+ if (options.color) {
390
+ outputMessage = chalk[options.color](outputMessage);
391
+ }
392
+ console.log(outputMessage);
393
+ return this;
394
+ }
395
+ verbose(message, options = {}) {
396
+ if (!this.config?.verbose) {
397
+ return this;
398
+ }
399
+ this.log(message, options);
400
+ return this;
401
+ }
402
+ startTimer() {
403
+ this.timerStart = now();
404
+ return this;
405
+ }
406
+ stopTimer(message, options = { color: 'yellow' }) {
407
+ if (!this.config?.verbose || !this.timerStart) {
408
+ return this;
409
+ }
410
+ const elapsedTime = prettyMilliseconds(now() - this.timerStart);
411
+ let outputMessage = message
412
+ ? `${message} (⏲ ${elapsedTime})`
413
+ : `⏲ ${elapsedTime}`;
414
+ if (options.color) {
415
+ outputMessage = chalk[options.color](outputMessage);
416
+ }
417
+ console.log(outputMessage);
418
+ return this;
419
+ }
420
+ startSpinner(message, options = { color: 'green' }) {
421
+ const spinnerMessage = options.color ? chalk[options.color](message) : message;
422
+ this.spinner = ora(spinnerMessage).start();
423
+ return this;
424
+ }
425
+ stopSpinner(message = '', options = { mode: 'succeed', color: 'green' }) {
426
+ const spinnerMessage = options?.color ? chalk[options.color](message) : message;
427
+ this.spinner?.[options.mode || 'succeed'](spinnerMessage);
428
+ this.spinner = null;
429
+ return this;
430
+ }
431
+ }
432
+
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);
439
+ }
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
+ };
447
+ }
448
+
449
+ /**
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.
453
+ */
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();
468
+ }
469
+
470
+ /**
471
+ * Create groups from a given node info.
472
+ * @param {DiffNode} node - The node info to start grouping.
473
+ * @returns {DirectoryDiff[]} The groups created.
474
+ */
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 };
482
+ }
483
+ groupByPath[path].diffs.push(diff);
484
+ groupByPath[path].tokenCount += diff.tokenCount;
485
+ });
486
+ node.children.forEach(traverse);
487
+ }
488
+ traverse(node);
489
+ return Object.values(groupByPath);
490
+ }
491
+ /**
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
+ };
516
+ }
517
+ catch (error) {
518
+ console.error(error);
519
+ return directory;
520
+ }
521
+ }
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`;
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('');
568
+ }
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;
577
+ }
578
+ addFile(file) {
579
+ this.files.push(file);
580
+ }
581
+ addChild(part, node) {
582
+ this.children.set(part, node);
161
583
  }
162
584
  getChild(part) {
163
585
  return this.children.get(part);
@@ -338,18 +760,6 @@ function validatePromptTemplate(text, inputVariables) {
338
760
  return true;
339
761
  }
340
762
 
341
- const template$2 = `GOAL: Use functional abstractions to summarize the following text
342
-
343
- RULES: Avoid phrases like "this change", "this code", or "this function" etc. Instead refer to the function, variable, or class by name.
344
-
345
- TEXT:"""{text}"""
346
- `;
347
- const inputVariables$2 = ['text'];
348
- const SUMMARIZE_PROMPT = new PromptTemplate({
349
- template: template$2,
350
- inputVariables: inputVariables$2,
351
- });
352
-
353
763
  async function parseDefaultFileDiff(nodeFile, commit = '--staged', git) {
354
764
  if (commit !== '--staged') {
355
765
  return await git.diff([`${commit}~1..${commit}`, '--', nodeFile.filePath]);
@@ -431,59 +841,8 @@ async function fileChangeParser({ changes, commit, options: { tokenizer, git, ll
431
841
  chain: summarizationChain,
432
842
  logger,
433
843
  });
434
- logger.stopTimer(`\nSummary generated for ${changes.length} staged files`, { color: 'green' });
435
- return summary;
436
- }
437
-
438
- class Logger {
439
- constructor(config) {
440
- this.config = config;
441
- this.spinner = null;
442
- }
443
- log(message, options = { color: 'blue' }) {
444
- let outputMessage = message;
445
- if (options.color) {
446
- outputMessage = chalk[options.color](outputMessage);
447
- }
448
- console.log(outputMessage);
449
- return this;
450
- }
451
- verbose(message, options = {}) {
452
- if (!this.config?.verbose) {
453
- return this;
454
- }
455
- this.log(message, options);
456
- return this;
457
- }
458
- startTimer() {
459
- this.timerStart = now();
460
- return this;
461
- }
462
- stopTimer(message, options = { color: 'yellow' }) {
463
- if (!this.config?.verbose || !this.timerStart) {
464
- return this;
465
- }
466
- const elapsedTime = prettyMilliseconds(now() - this.timerStart);
467
- let outputMessage = message
468
- ? `${message} (⏲ ${elapsedTime})`
469
- : `⏲ ${elapsedTime}`;
470
- if (options.color) {
471
- outputMessage = chalk[options.color](outputMessage);
472
- }
473
- console.log(outputMessage);
474
- return this;
475
- }
476
- startSpinner(message, options = { color: 'green' }) {
477
- const spinnerMessage = options.color ? chalk[options.color](message) : message;
478
- this.spinner = ora(spinnerMessage).start();
479
- return this;
480
- }
481
- stopSpinner(message = '', options = { mode: 'succeed', color: 'green' }) {
482
- const spinnerMessage = options?.color ? chalk[options.color](message) : message;
483
- this.spinner?.[options.mode || 'succeed'](spinnerMessage);
484
- this.spinner = null;
485
- return this;
486
- }
844
+ logger.stopTimer(`\nSummary generated for ${changes.length} staged files`, { color: 'green' });
845
+ return summary;
487
846
  }
488
847
 
489
848
  const template$1 = `Write informative git commit message, in the imperative, based on the diffs & file changes provided in the "Diff Summary" section.
@@ -511,368 +870,52 @@ function getStatus(file, location = 'index') {
511
870
  switch (statusCode) {
512
871
  case 'A':
513
872
  return 'added';
514
- case 'D':
515
- return 'deleted';
516
- case 'M':
517
- return 'modified';
518
- case 'R':
519
- return 'renamed';
520
- case '?':
521
- return 'untracked';
522
- default:
523
- return 'unknown';
524
- }
525
- }
526
- else if ('changes' in file && 'binary' in file) {
527
- if (file.changes === 0)
528
- return 'untracked';
529
- if (file.file.includes('=>'))
530
- return 'renamed';
531
- if (file.deletions === 0 && file.insertions > 0)
532
- return 'added';
533
- if (file.insertions === 0 && file.deletions > 0)
534
- return 'deleted';
535
- if ((file.insertions > 0 && file.deletions > 0) || file.changes > 0)
536
- return 'modified';
537
- return 'unknown';
538
- }
539
- else {
540
- throw new Error('Invalid file type');
541
- }
542
- }
543
-
544
- function getSummaryText(file, change) {
545
- const status = change.status || getStatus(file);
546
- let filePath;
547
- if ('path' in file) {
548
- filePath = file.path;
549
- }
550
- else if ('file' in file) {
551
- filePath = change?.filePath || file.file;
552
- }
553
- else {
554
- throw new Error('Invalid file type');
555
- }
556
- if (change.oldFilePath) {
557
- return `${status}: ${change.oldFilePath} -> ${filePath}`;
558
- }
559
- return `${status}: ${filePath}`;
560
- }
561
-
562
- /**
563
- * Returns a new object with all undefined keys removed
564
- *
565
- * @param obj Object to remove undefined keys from
566
- * @returns
567
- */
568
- function removeUndefined(obj) {
569
- return Object.fromEntries(Object.entries(obj).filter(([, value]) => value !== undefined));
570
- }
571
-
572
- /**
573
- * Default Config
574
- *
575
- * @type {Config}
576
- */
577
- const DEFAULT_CONFIG = {
578
- service: 'openai/gpt-4',
579
- verbose: false,
580
- tokenLimit: 1024,
581
- summarizePrompt: SUMMARIZE_PROMPT.template,
582
- temperature: 0.4,
583
- mode: 'stdout',
584
- ignoredFiles: ['package-lock.json'],
585
- ignoredExtensions: ['.map', '.lock'],
586
- defaultBranch: 'main',
587
- };
588
- /**
589
- * Create a named export of all config keys for use in other modules.
590
- *
591
- * @see Currently used in `src/lib/config/services/env.ts` to validate all env vars.
592
- *
593
- * @type {string[]}
594
- */
595
- const CONFIG_KEYS = Object.keys({
596
- ...DEFAULT_CONFIG,
597
- huggingFaceHubApiKey: '',
598
- openAIApiKey: '',
599
- prompt: '',
600
- });
601
-
602
- async function updateFileSection(filePath, startComment, endComment, getNewContent, confirmUpdate = true) {
603
- const lines = fs__default.existsSync(filePath) ? fs__default.readFileSync(filePath, 'utf-8').split(/\r?\n/) : [];
604
- const newLines = [];
605
- let foundSection = false;
606
- for (let i = 0; i < lines.length; i++) {
607
- if (lines[i].trim() === startComment) {
608
- foundSection = true;
609
- if (confirmUpdate) {
610
- const confirmOverwrite = await confirm({
611
- message: `A section already exists in ${filePath}, do you want to override it?`,
612
- default: false,
613
- });
614
- if (!confirmOverwrite) {
615
- // keep all lines until the end comment
616
- while (i < lines.length && lines[i].trim() !== endComment) {
617
- newLines.push(lines[i]);
618
- i++;
619
- }
620
- newLines.push(endComment);
621
- continue;
622
- }
623
- }
624
- newLines.push(startComment);
625
- // Insert the new content
626
- const newContent = await getNewContent();
627
- newLines.push(newContent);
628
- // Skip the existing content of the section
629
- while (i < lines.length && lines[i].trim() !== endComment) {
630
- i++;
631
- }
632
- newLines.push(endComment);
633
- continue;
634
- }
635
- if (!foundSection || lines[i].trim() !== endComment) {
636
- newLines.push(lines[i]);
637
- }
638
- }
639
- // If section wasn't found, append it at the end
640
- if (!foundSection) {
641
- newLines.push('\n' + startComment);
642
- const newContent = await getNewContent();
643
- newLines.push(newContent);
644
- newLines.push(endComment);
645
- }
646
- // Write the updated contents back to the file
647
- fs__default.writeFileSync(filePath, newLines.join('\n'));
648
- }
649
-
650
- /**
651
- * Load environment variables
652
- *
653
- * @param {Config} config
654
- * @returns {Config} Updated config
655
- **/
656
- function loadEnvConfig(config) {
657
- const envConfig = {};
658
- CONFIG_KEYS.forEach((key) => {
659
- const envVarName = toEnvVarName(key);
660
- const envValue = parseEnvValue(key, process.env[envVarName]);
661
- if (envValue === undefined)
662
- return;
663
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
664
- // @ts-ignore
665
- envConfig[key] = envValue;
666
- });
667
- return { ...config, ...removeUndefined(envConfig) };
668
- }
669
- function parseEnvValue(key, value) {
670
- if (value === undefined) {
671
- return undefined;
672
- }
673
- else if (key === 'tokenLimit' && typeof value === 'string') {
674
- return parseInt(value);
675
- }
676
- else if ((key === 'ignoredFiles' || key === 'ignoredExtensions') &&
677
- typeof value === 'string' &&
678
- value.includes(',')) {
679
- return value.split(',');
680
- }
681
- return value;
682
- }
683
- function toEnvVarName(key) {
684
- switch (key) {
685
- case 'openAIApiKey':
686
- return 'OPENAI_API_KEY';
687
- case 'huggingFaceHubApiKey':
688
- return 'HUGGINGFACE_HUB_API_KEY';
689
- default:
690
- return 'COCO_' + key.replace(/([A-Z])/g, '_$1').toUpperCase();
691
- }
692
- }
693
- function formatEnvValue(value) {
694
- if (typeof value === 'number') {
695
- return `${value}`;
696
- }
697
- else if (Array.isArray(value)) {
698
- return `${value.join(',')}`;
699
- }
700
- else if (typeof value === 'string') {
701
- // Escape newlines and tabs in strings
702
- return `${value.replace(/\n/g, '\\n').replace(/\t/g, '\\t')}`;
703
- }
704
- return `${value}`;
705
- }
706
- const appendToEnvFile = async (filePath, config) => {
707
- const startComment = '# -- Start coco config --';
708
- const endComment = '# -- End coco config --';
709
- const getNewContent = async () => {
710
- return Object.entries(config)
711
- .map(([key, value]) => `${toEnvVarName(key)}=${formatEnvValue(value)}`)
712
- .join('\n');
713
- };
714
- await updateFileSection(filePath, startComment, endComment, getNewContent);
715
- };
716
-
717
- /**
718
- * Load git profile config (from ~/.gitconfig)
719
- *
720
- * @param {Config} config
721
- * @returns {Config} Updated config
722
- **/
723
- function loadGitConfig(config) {
724
- const gitConfigPath = path.join(os.homedir(), '.gitconfig');
725
- if (fs.existsSync(gitConfigPath)) {
726
- const gitConfigRaw = fs.readFileSync(gitConfigPath, 'utf-8');
727
- const gitConfigParsed = ini.parse(gitConfigRaw);
728
- config = {
729
- ...config,
730
- service: gitConfigParsed.coco?.model || config.service,
731
- openAIApiKey: gitConfigParsed.coco?.openAIApiKey || config.openAIApiKey,
732
- huggingFaceHubApiKey: gitConfigParsed.coco?.huggingFaceHubApiKey || config.huggingFaceHubApiKey,
733
- tokenLimit: parseInt(gitConfigParsed.coco?.tokenLimit) || config.tokenLimit,
734
- prompt: gitConfigParsed.coco?.prompt || config.prompt,
735
- mode: gitConfigParsed.coco?.mode || config.mode,
736
- temperature: gitConfigParsed.coco?.temperature || config.temperature,
737
- summarizePrompt: gitConfigParsed.coco?.summarizePrompt || config.summarizePrompt,
738
- ignoredFiles: gitConfigParsed.coco?.ignoredFiles || config.ignoredFiles,
739
- ignoredExtensions: gitConfigParsed.coco?.ignoredExtensions || config.ignoredExtensions,
740
- defaultBranch: gitConfigParsed.coco?.defaultBranch || config.defaultBranch,
741
- };
742
- }
743
- return config;
744
- }
745
- /**
746
- * Appends the provided configuration to a git config file.
747
- *
748
- * @param filePath - The path to the .gitconfig
749
- * @param config - The configuration object to append.
750
- */
751
- const appendToGitConfig = async (filePath, config) => {
752
- if (!fs.existsSync(filePath)) {
753
- throw new Error(`File ${filePath} does not exist.`);
754
- }
755
- const startComment = '# -- Start coco config --';
756
- const endComment = '# -- End coco config --';
757
- const header = '[coco]';
758
- // Function to generate new content for the coco section
759
- const getNewContent = async () => {
760
- const contentLines = [header];
761
- for (const key in config) {
762
- // check if string has new lines, if so, wrap in quotes
763
- if (typeof config[key] === 'string') {
764
- const value = config[key];
765
- if (value.includes('\n')) {
766
- contentLines.push(`\t${key} = ${JSON.stringify(value)}`);
767
- continue;
768
- }
769
- }
770
- contentLines.push(`\t${key} = ${config[key]}`);
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';
771
883
  }
772
- return contentLines.join('\n');
773
- };
774
- // Use the updateFileSection utility
775
- await updateFileSection(filePath, startComment, endComment, getNewContent);
776
- };
777
-
778
- /**
779
- * Load .gitignore in project root
780
- *
781
- * @param {Config} config
782
- * @returns
783
- */
784
- function loadGitignore(config) {
785
- if (fs.existsSync('.gitignore')) {
786
- const gitignoreContent = fs.readFileSync('.gitignore', 'utf-8');
787
- config.ignoredFiles = [
788
- ...(config?.ignoredFiles || []),
789
- ...gitignoreContent.split('\n').filter((line) => line.trim() !== '' && !line.startsWith('#')),
790
- ];
791
884
  }
792
- return config;
793
- }
794
- /**
795
- * Load .ignore in project root
796
- *
797
- * @param {Config} config
798
- * @returns
799
- */
800
- function loadIgnore(config) {
801
- if (fs.existsSync('.ignore')) {
802
- const ignoreContent = fs.readFileSync('.ignore', 'utf-8');
803
- config.ignoredFiles = [
804
- ...(config?.ignoredFiles || []),
805
- ...ignoreContent.split('\n').filter((line) => line.trim() !== '' && !line.startsWith('#')),
806
- ];
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';
807
897
  }
808
- return config;
809
- }
810
-
811
- /**
812
- * Load project config
813
- *
814
- * @param {Config} config
815
- * @returns {Config} Updated config
816
- **/
817
- function loadProjectConfig(config) {
818
- // TODO: Add validation based of JSON schema?
819
- // @see https://github.com/acornejo/jjv
820
- if (fs.existsSync('.coco.config.json')) {
821
- const projectConfig = JSON.parse(fs.readFileSync('.coco.config.json', 'utf-8'));
822
- config = { ...config, ...projectConfig };
898
+ else {
899
+ throw new Error('Invalid file type');
823
900
  }
824
- return config;
825
901
  }
826
- const appendToProjectConfig = (filePath, config) => {
827
- fs.writeFileSync(filePath, JSON.stringify({
828
- $schema: 'https://git-co.co/schema.json',
829
- ...config,
830
- }, null, 2));
831
- };
832
902
 
833
- /**
834
- * Load XDG config
835
- *
836
- * @param {Config} config
837
- * @returns {Config} Updated config
838
- */
839
- function loadXDGConfig(config) {
840
- const xdgConfigHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
841
- const xdgConfigPath = path.join(xdgConfigHome, 'coco', 'config.json');
842
- if (fs.existsSync(xdgConfigPath)) {
843
- const xdgConfig = JSON.parse(fs.readFileSync(xdgConfigPath, 'utf-8'));
844
- 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;
845
908
  }
846
- return config;
847
- }
848
-
849
- /**
850
- * Load application config
851
- *
852
- * Merge config from multiple sources.
853
- *
854
- * \* Order of precedence:
855
- * \* 1. Command line flags
856
- * \* 2. Environment variables
857
- * \* 3. Project config
858
- * \* 4. Git config
859
- * \* 5. XDG config
860
- * \* 6. .gitignore
861
- * \* 7. .ignore
862
- * \* 8. Default config
863
- *
864
- * @returns {Config} application config
865
- **/
866
- function loadConfig(argv = {}) {
867
- // Default config
868
- let config = DEFAULT_CONFIG;
869
- config = loadGitignore(config);
870
- config = loadIgnore(config);
871
- config = loadXDGConfig(config);
872
- config = loadGitConfig(config);
873
- config = loadProjectConfig(config);
874
- config = loadEnvConfig(config);
875
- 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}`;
876
919
  }
877
920
 
878
921
  const config = loadConfig();
@@ -960,11 +1003,6 @@ async function noResult({ git, logger }) {
960
1003
  }
961
1004
  }
962
1005
 
963
- const isInteractive = (argv) => {
964
- return argv?.mode === 'interactive' || argv.interactive;
965
- };
966
- const SEPERATOR = chalk.blue('----------------');
967
-
968
1006
  function logResult(label, result) {
969
1007
  console.log(`\n${chalk.bgBlue(chalk.bold(`Proposed ${label}:`))}\n${SEPERATOR}\n${result}\n${SEPERATOR}\n`);
970
1008
  }
@@ -1146,7 +1184,7 @@ async function handleResult({ result, mode, interactiveHandler }) {
1146
1184
  await interactiveHandler(result);
1147
1185
  }
1148
1186
  else {
1149
- console.error('No result handler provided for interactive mode');
1187
+ console.warn('No result handler provided for interactive mode.');
1150
1188
  logSuccess();
1151
1189
  }
1152
1190
  break;
@@ -1184,11 +1222,10 @@ async function createCommit(commitMsg, git) {
1184
1222
  return await git.commit(commitMsg);
1185
1223
  }
1186
1224
 
1187
- async function handler$2(argv) {
1225
+ const handler$2 = async (argv, logger) => {
1188
1226
  const git = getRepo();
1189
1227
  const options = loadConfig(argv);
1190
1228
  const { service } = options;
1191
- const logger = new Logger(options);
1192
1229
  const key = getApiKeyForModel(service, options);
1193
1230
  const tokenizer = await getTokenCounter(getModelFromService(service));
1194
1231
  if (!key) {
@@ -1200,6 +1237,9 @@ async function handler$2(argv) {
1200
1237
  maxConcurrency: 10,
1201
1238
  });
1202
1239
  const INTERACTIVE = isInteractive(options);
1240
+ if (INTERACTIVE) {
1241
+ logger.log(LOGO);
1242
+ }
1203
1243
  async function factory() {
1204
1244
  const changes = await getChanges({ git });
1205
1245
  return changes.staged;
@@ -1255,7 +1295,7 @@ async function handler$2(argv) {
1255
1295
  },
1256
1296
  mode: MODE,
1257
1297
  });
1258
- }
1298
+ };
1259
1299
 
1260
1300
  /**
1261
1301
  * Command line options via yargs
@@ -1314,7 +1354,7 @@ var commit = {
1314
1354
  command: 'commit',
1315
1355
  desc: 'Generate commit message',
1316
1356
  builder: builder$2,
1317
- handler: handler$2,
1357
+ handler: commandExecutor(handler$2),
1318
1358
  options: options$2,
1319
1359
  };
1320
1360
 
@@ -1391,9 +1431,8 @@ async function getCommitLogCurrentBranch({ git, logger, comparisonBranch = 'main
1391
1431
  return [];
1392
1432
  }
1393
1433
 
1394
- async function handler$1(argv) {
1434
+ const handler$1 = async (argv, logger) => {
1395
1435
  const options = loadConfig(argv);
1396
- const logger = new Logger(options);
1397
1436
  const git = getRepo();
1398
1437
  const key = getApiKeyForModel(options.service, options);
1399
1438
  if (!key) {
@@ -1405,6 +1444,9 @@ async function handler$1(argv) {
1405
1444
  maxConcurrency: 10,
1406
1445
  });
1407
1446
  const INTERACTIVE = isInteractive(options);
1447
+ if (INTERACTIVE) {
1448
+ logger.log(LOGO);
1449
+ }
1408
1450
  async function factory() {
1409
1451
  if (options.range) {
1410
1452
  const [from, to] = options.range?.split(':');
@@ -1452,7 +1494,7 @@ async function handler$1(argv) {
1452
1494
  interactive: INTERACTIVE,
1453
1495
  review: {
1454
1496
  enableFullRetry: false,
1455
- }
1497
+ },
1456
1498
  },
1457
1499
  });
1458
1500
  const MODE = (INTERACTIVE && 'interactive') || (options.commit && 'interactive') || options?.mode || 'stdout';
@@ -1463,7 +1505,7 @@ async function handler$1(argv) {
1463
1505
  },
1464
1506
  mode: MODE,
1465
1507
  });
1466
- }
1508
+ };
1467
1509
 
1468
1510
  /**
1469
1511
  * Command line options via yargs
@@ -1522,39 +1564,138 @@ var changelog = {
1522
1564
  command: 'changelog',
1523
1565
  desc: 'Generate a changelog from a commit range',
1524
1566
  builder: builder$1,
1525
- handler: handler$1,
1567
+ handler: commandExecutor(handler$1),
1526
1568
  options: options$1,
1527
1569
  };
1528
1570
 
1529
- const handleProjectLevelConfig = async () => {
1530
- const projectConfiguration = await select({
1531
- message: 'select type project level configuration:',
1532
- choices: [
1533
- {
1534
- name: '.coco.config.json',
1535
- value: '.coco.config.json',
1536
- },
1537
- {
1538
- name: '.env',
1539
- value: '.env',
1540
- },
1541
- ],
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
+ });
1542
1604
  });
1543
- let configFile = '.coco.config.json';
1544
- if (projectConfiguration === '.env') {
1545
- configFile = '.env';
1546
- if (!fs__default.existsSync('.env')) {
1547
- 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;
1548
1674
  }
1675
+ logger.startSpinner(`Installing '${packageName}' in project...`, { color: 'blue' });
1676
+ await installNpmPackage({ name: packageName, cwd: projectRoot });
1677
+ logger.stopSpinner(`Installed '${packageName}' in project`);
1549
1678
  }
1550
- return configFile;
1551
- };
1552
- const handleSystemLevelConfig = () => {
1679
+ catch (error) {
1680
+ console.error(`Error: ${error.message}`);
1681
+ }
1682
+ }
1683
+
1684
+ function getPathToUsersGitConfig() {
1553
1685
  return path__default.join(os__default.homedir(), '.gitconfig');
1554
- };
1555
- async function handler(argv) {
1556
- const options = loadConfig(argv);
1557
- 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);
1558
1699
  const level = await select({
1559
1700
  message: 'configure coco at the system or project level:',
1560
1701
  choices: [
@@ -1572,11 +1713,25 @@ async function handler(argv) {
1572
1713
  });
1573
1714
  let configFilePath = '';
1574
1715
  switch (level) {
1575
- case 'system':
1576
- configFilePath = await handleSystemLevelConfig();
1577
- break;
1578
1716
  case 'project':
1579
- 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();
1580
1735
  break;
1581
1736
  }
1582
1737
  // interactive v.s stdout mode
@@ -1673,7 +1828,7 @@ async function handler(argv) {
1673
1828
  // add to config after logging, so that the API key is not logged
1674
1829
  config.openAIApiKey = apiKey;
1675
1830
  const isApproved = await confirm({
1676
- message: 'look good? (hiding API key for security)',
1831
+ message: 'looking good? (API key hidden for security)',
1677
1832
  });
1678
1833
  if (isApproved) {
1679
1834
  if (configFilePath.endsWith('.gitconfig')) {
@@ -1685,62 +1840,19 @@ async function handler(argv) {
1685
1840
  else if (configFilePath === '.coco.config.json') {
1686
1841
  await appendToProjectConfig(configFilePath, config);
1687
1842
  }
1688
- 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' });
1689
1846
  }
1690
1847
  else {
1691
- logger.log('init cancelled.', { color: 'yellow' });
1848
+ logger.log('\ninit cancelled.', { color: 'yellow' });
1692
1849
  }
1693
- }
1850
+ };
1694
1851
 
1695
1852
  /**
1696
1853
  * Command line options via yargs
1697
1854
  */
1698
- const options = {
1699
- model: { type: 'string', description: 'LLM/Model-Name' },
1700
- openAIApiKey: {
1701
- type: 'string',
1702
- description: 'OpenAI API Key',
1703
- conflicts: 'huggingFaceHubApiKey',
1704
- },
1705
- huggingFaceHubApiKey: {
1706
- type: 'string',
1707
- description: 'HuggingFace Hub API Key',
1708
- conflicts: 'openAIApiKey',
1709
- },
1710
- tokenLimit: { type: 'number', description: 'Token limit' },
1711
- prompt: {
1712
- type: 'string',
1713
- alias: 'p',
1714
- description: 'Commit message prompt',
1715
- },
1716
- i: {
1717
- type: 'boolean',
1718
- alias: 'interactive',
1719
- description: 'Toggle interactive mode',
1720
- },
1721
- s: {
1722
- type: 'boolean',
1723
- description: 'Automatically commit staged changes with generated commit message',
1724
- default: false,
1725
- },
1726
- e: {
1727
- type: 'boolean',
1728
- alias: 'edit',
1729
- description: 'Open commit message in editor before proceeding',
1730
- },
1731
- summarizePrompt: {
1732
- type: 'string',
1733
- description: 'Large file summary prompt',
1734
- },
1735
- ignoredFiles: {
1736
- type: 'array',
1737
- description: 'Ignored files',
1738
- },
1739
- ignoredExtensions: {
1740
- type: 'array',
1741
- description: 'Ignored extensions',
1742
- },
1743
- };
1855
+ const options = {};
1744
1856
  const builder = (yargs) => {
1745
1857
  return yargs.options(options);
1746
1858
  };
@@ -1749,7 +1861,7 @@ var init = {
1749
1861
  command: 'init',
1750
1862
  desc: 'Setup coco for a new project or system',
1751
1863
  builder,
1752
- handler,
1864
+ handler: commandExecutor(handler),
1753
1865
  options,
1754
1866
  };
1755
1867