git-coco 0.3.0 → 0.3.2

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.
@@ -0,0 +1,16 @@
1
+ import { Argv, CommandBuilder } from 'yargs';
2
+ import { BaseCommandOptions } from '../types';
3
+ export interface CommitOptions extends BaseCommandOptions {
4
+ interactive: boolean;
5
+ tokenLimit: number;
6
+ prompt: string;
7
+ commit: boolean;
8
+ summarizePrompt: string;
9
+ openInEditor: boolean;
10
+ ignoredFiles: string[];
11
+ ignoredExtensions: string[];
12
+ }
13
+ export declare const command: string[];
14
+ export declare const description = "Generate a commit message based on the diff summary";
15
+ export declare const builder: CommandBuilder<CommitOptions>;
16
+ export declare function handler(argv: Argv<CommitOptions>["argv"]): Promise<void>;
package/dist/index.d.ts CHANGED
@@ -1,2 +1,4 @@
1
1
  #!/usr/bin/env node
2
- export {};
2
+ import * as commit from './commands/commit';
3
+ import { loadConfig } from './lib/config/loadConfig';
4
+ export { commit, loadConfig };
@@ -1,27 +1,26 @@
1
1
  #!/usr/bin/env node
2
+ import yargs from 'yargs';
2
3
  import { select, editor } from '@inquirer/prompts';
4
+ import { simpleGit } from 'simple-git';
3
5
  import * as fs from 'fs';
4
6
  import * as os from 'os';
5
7
  import * as path from 'path';
6
8
  import path__default from 'path';
7
9
  import * as ini from 'ini';
8
- import yargs from 'yargs';
9
- import { hideBin } from 'yargs/helpers';
10
10
  import { PromptTemplate } from 'langchain/prompts';
11
11
  import pQueue from 'p-queue';
12
- import chalk from 'chalk';
13
- import ora from 'ora';
14
- import now from 'performance-now';
15
- import prettyMilliseconds from 'pretty-ms';
16
12
  import { Document } from 'langchain/document';
17
13
  import { HuggingFaceInference } from 'langchain/llms/hf';
18
14
  import { loadSummarizationChain, LLMChain } from 'langchain/chains';
19
15
  import { OpenAI } from 'langchain/llms/openai';
20
16
  import { RecursiveCharacterTextSplitter } from 'langchain/text_splitter';
21
17
  import { createTwoFilesPatch } from 'diff';
18
+ import chalk from 'chalk';
22
19
  import GPT3NodeTokenizer from 'gpt3-tokenizer';
20
+ import ora from 'ora';
21
+ import now from 'performance-now';
22
+ import prettyMilliseconds from 'pretty-ms';
23
23
  import { minimatch } from 'minimatch';
24
- import { simpleGit } from 'simple-git';
25
24
 
26
25
  /**
27
26
  * Returns a new object with all undefined keys removed
@@ -152,74 +151,6 @@ function loadXDGConfig(config) {
152
151
  return config;
153
152
  }
154
153
 
155
- /**
156
- * Command line options via yargs
157
- */
158
- const options = {
159
- model: { type: 'string', description: 'LLM/Model-Name' },
160
- openAIApiKey: { type: 'string', description: 'OpenAI API Key' },
161
- huggingFaceHubApiKey: { type: 'string', description: 'HuggingFace Hub API Key' },
162
- tokenLimit: { type: 'number', description: 'Token limit' },
163
- prompt: {
164
- type: 'string',
165
- alias: 'p',
166
- description: 'Commit message prompt',
167
- },
168
- interactive: {
169
- type: 'boolean',
170
- alias: 'i',
171
- description: 'Toggle interactive mode',
172
- },
173
- commit: {
174
- type: 'boolean',
175
- alias: 's',
176
- description: 'Commit staged changes with generated commit message',
177
- default: false,
178
- },
179
- openInEditor: {
180
- type: 'boolean',
181
- alias: 'e',
182
- description: 'Open commit message in editor before proceeding',
183
- },
184
- verbose: {
185
- type: 'boolean',
186
- description: 'Enable verbose logging',
187
- },
188
- summarizePrompt: {
189
- type: 'string',
190
- description: 'Large file summary prompt',
191
- },
192
- ignoredFiles: {
193
- type: 'array',
194
- description: 'Ignored files',
195
- },
196
- ignoredExtensions: {
197
- type: 'array',
198
- description: 'Ignored extensions',
199
- },
200
- };
201
- /**
202
- * Load command line flags via yargs
203
- *
204
- * @returns {Partial<Config>} Updated config
205
- */
206
- const loadArgv = () => {
207
- return yargs(hideBin(process.argv)).options(options).parseSync();
208
- };
209
- /**
210
- * Load command line flags
211
- *
212
- * Note: Arugments are parsed using yargs.
213
- *
214
- * @param {Config} config
215
- * @returns {Config} Updated config
216
- **/
217
- function loadCmdLineFlags(config) {
218
- const argv = loadArgv();
219
- config = { ...config, ...argv };
220
- return config;
221
- }
222
-
223
154
  const template$1 = `Write informative git commit message based on the diffs & file changes provided in the "Diff Summary" section.
224
155
  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.
225
156
  - Write concisely using an informal tone
@@ -282,7 +213,7 @@ const DEFAULT_CONFIG = {
282
213
  *
283
214
  * @returns {Config} application config
284
215
  **/
285
- function loadConfig() {
216
+ function loadConfig(argv = {}) {
286
217
  // Default config
287
218
  let config = DEFAULT_CONFIG;
288
219
  config = loadGitignore(config);
@@ -291,60 +222,7 @@ function loadConfig() {
291
222
  config = loadGitConfig(config);
292
223
  config = loadProjectConfig(config);
293
224
  config = loadEnvConfig(config);
294
- config = loadCmdLineFlags(config);
295
- return config;
296
- }
297
- const config = loadConfig();
298
-
299
- class Logger {
300
- constructor(config) {
301
- this.config = config;
302
- this.spinner = null;
303
- }
304
- log(message, options = { color: 'blue' }) {
305
- let outputMessage = message;
306
- if (options.color) {
307
- outputMessage = chalk[options.color](outputMessage);
308
- }
309
- console.log(outputMessage);
310
- return this;
311
- }
312
- verbose(message, options = {}) {
313
- if (!this.config?.verbose) {
314
- return this;
315
- }
316
- this.log(message, options);
317
- return this;
318
- }
319
- startTimer() {
320
- this.timerStart = now();
321
- return this;
322
- }
323
- stopTimer(message, options = { color: 'yellow' }) {
324
- if (!this.config?.verbose || !this.timerStart) {
325
- return this;
326
- }
327
- const elapsedTime = prettyMilliseconds(now() - this.timerStart);
328
- let outputMessage = message
329
- ? `${message} (⏲ ${elapsedTime})`
330
- : `⏲ ${elapsedTime}`;
331
- if (options.color) {
332
- outputMessage = chalk[options.color](outputMessage);
333
- }
334
- console.log(outputMessage);
335
- return this;
336
- }
337
- startSpinner(message, options = { color: 'green' }) {
338
- const spinnerMessage = options.color ? chalk[options.color](message) : message;
339
- this.spinner = ora(spinnerMessage).start();
340
- return this;
341
- }
342
- stopSpinner(message = '', options = { mode: 'succeed', color: 'green' }) {
343
- const spinnerMessage = options?.color ? chalk[options.color](message) : message;
344
- this.spinner?.[options.mode || 'succeed'](spinnerMessage);
345
- this.spinner = null;
346
- return this;
347
- }
225
+ return { ...config, ...argv };
348
226
  }
349
227
 
350
228
  /**
@@ -431,8 +309,7 @@ const defaultOutputCallback = (group) => {
431
309
  }
432
310
  return output;
433
311
  };
434
- async function summarizeDiffs(rootDiffNode, { tokenizer, maxTokens = 2048, textSplitter, chain, handleOutput = defaultOutputCallback, }) {
435
- const logger = new Logger(config);
312
+ async function summarizeDiffs(rootDiffNode, { tokenizer, logger, maxTokens = 2048, textSplitter, chain, handleOutput = defaultOutputCallback, }) {
436
313
  const queue = new pQueue({ concurrency: 8 });
437
314
  logger.startTimer().startSpinner(`Organizing Diffs...`, { color: 'blue' });
438
315
  const directoryDiffs = createDirectoryDiffs(rootDiffNode);
@@ -518,7 +395,7 @@ const createDiffTree = (changes) => {
518
395
  /**
519
396
  * Asynchronously collect diffs for a given node and its children.
520
397
  */
521
- async function collectDiffs(node, getFileDiff, tokenizer, logger = new Logger(config)) {
398
+ async function collectDiffs(node, getFileDiff, tokenizer, logger) {
522
399
  // Collect diffs for the files of the current node
523
400
  const diffPromises = node.files.map(async (nodeFile) => {
524
401
  const diff = await getFileDiff(nodeFile);
@@ -536,7 +413,7 @@ async function collectDiffs(node, getFileDiff, tokenizer, logger = new Logger(co
536
413
  };
537
414
  });
538
415
  // Collect diffs for the children of the current node
539
- const childrenPromises = Array.from(node.children.values()).map(async (child) => collectDiffs(child, getFileDiff, tokenizer));
416
+ const childrenPromises = Array.from(node.children.values()).map(async (child) => collectDiffs(child, getFileDiff, tokenizer, logger));
540
417
  const [diffs, children] = await Promise.all([
541
418
  Promise.all(diffPromises),
542
419
  Promise.all(childrenPromises),
@@ -550,36 +427,65 @@ async function collectDiffs(node, getFileDiff, tokenizer, logger = new Logger(co
550
427
 
551
428
  /**
552
429
  * Get LLM Model Based on Configuration
553
- *
554
430
  * @param fields
555
431
  * @param configuration
556
432
  * @returns LLM Model
557
433
  */
558
- function getModel(fields, configuration) {
559
- const [llm, model] = config.model.split(/\/(.*)/s);
434
+ function getModel(name, key, fields, configuration) {
435
+ const [llm, model] = name.split(/\/(.*)/s);
560
436
  if (!model) {
561
- throw new Error(`Invalid model: ${config.model}`);
437
+ throw new Error(`Invalid model: ${name}`);
562
438
  }
563
439
  switch (llm) {
564
440
  case 'huggingface':
565
441
  return new HuggingFaceInference({
566
442
  model: model,
567
- apiKey: config.huggingFaceHubApiKey,
443
+ apiKey: key,
568
444
  maxConcurrency: 4,
569
445
  ...fields,
570
446
  });
571
447
  case 'openai':
572
448
  default:
573
449
  return new OpenAI({
574
- openAIApiKey: config.openAIApiKey,
450
+ openAIApiKey: key,
575
451
  modelName: model,
576
452
  ...fields,
577
453
  }, configuration);
578
454
  }
579
455
  }
456
+ /**
457
+ * Retrieve appropriate API key based on selected model
458
+ * @param name
459
+ * @param options
460
+ * @returns
461
+ */
462
+ function getModelAPIKey(name, options) {
463
+ const [llm, model] = name.split(/\/(.*)/s);
464
+ if (!model) {
465
+ throw new Error(`Invalid model: ${name}`);
466
+ }
467
+ switch (llm) {
468
+ case 'huggingface':
469
+ return options.huggingFaceHubApiKey;
470
+ case 'openai':
471
+ default:
472
+ return options.openAIApiKey;
473
+ }
474
+ }
475
+ /**
476
+ * Get Recursive Character Text Splitter
477
+ * @param options
478
+ * @returns
479
+ */
580
480
  function getTextSplitter(options = {}) {
581
481
  return new RecursiveCharacterTextSplitter(options);
582
482
  }
483
+ /**
484
+ * Get Summarization Chain
485
+ * @param model
486
+ * @param options
487
+ * @returns
488
+ */
583
489
  function getChain(model, options = { type: 'map_reduce' }) {
584
490
  return loadSummarizationChain(model, options);
585
491
  }
@@ -593,6 +499,12 @@ function getPrompt({ template, variables, fallback }) {
593
499
  })
594
500
  : fallback);
595
501
  }
502
+ /**
503
+ * Verify template string contains all required input variables
504
+ * @param text template string
505
+ * @param inputVariables template variables
506
+ * @returns boolean or error message
507
+ */
596
508
  function validatePromptTemplate(text, inputVariables) {
597
509
  if (!text) {
598
510
  return 'Prompt template cannot be empty';
@@ -646,8 +558,7 @@ const getDiff = async (nodeFile, { git, logger, }) => {
646
558
  };
647
559
 
648
560
  const MAX_TOKENS_PER_SUMMARY = 2048;
649
- const fileChangeParser = async (changes, { tokenizer, git, model }) => {
650
- const logger = new Logger(config);
561
+ const fileChangeParser = async (changes, { tokenizer, git, model, logger }) => {
651
562
  const textSplitter = getTextSplitter({ chunkSize: 2000, chunkOverlap: 125, });
652
563
  const summarizationChain = getChain(model, {
653
564
  type: 'map_reduce',
@@ -668,6 +579,7 @@ const fileChangeParser = async (changes, { tokenizer, git, model }) => {
668
579
  maxTokens: MAX_TOKENS_PER_SUMMARY,
669
580
  textSplitter,
670
581
  chain: summarizationChain,
582
+ logger
671
583
  });
672
584
  logger.stopTimer(`\nSummary generated for ${changes.length} staged files`, { color: 'green' });
673
585
  return summary;
@@ -703,6 +615,57 @@ const getTokenizer = () => {
703
615
  return tokenizer;
704
616
  };
705
617
 
618
+ class Logger {
619
+ constructor(config) {
620
+ this.config = config;
621
+ this.spinner = null;
622
+ }
623
+ log(message, options = { color: 'blue' }) {
624
+ let outputMessage = message;
625
+ if (options.color) {
626
+ outputMessage = chalk[options.color](outputMessage);
627
+ }
628
+ console.log(outputMessage);
629
+ return this;
630
+ }
631
+ verbose(message, options = {}) {
632
+ if (!this.config?.verbose) {
633
+ return this;
634
+ }
635
+ this.log(message, options);
636
+ return this;
637
+ }
638
+ startTimer() {
639
+ this.timerStart = now();
640
+ return this;
641
+ }
642
+ stopTimer(message, options = { color: 'yellow' }) {
643
+ if (!this.config?.verbose || !this.timerStart) {
644
+ return this;
645
+ }
646
+ const elapsedTime = prettyMilliseconds(now() - this.timerStart);
647
+ let outputMessage = message
648
+ ? `${message} (⏲ ${elapsedTime})`
649
+ : `⏲ ${elapsedTime}`;
650
+ if (options.color) {
651
+ outputMessage = chalk[options.color](outputMessage);
652
+ }
653
+ console.log(outputMessage);
654
+ return this;
655
+ }
656
+ startSpinner(message, options = { color: 'green' }) {
657
+ const spinnerMessage = options.color ? chalk[options.color](message) : message;
658
+ this.spinner = ora(spinnerMessage).start();
659
+ return this;
660
+ }
661
+ stopSpinner(message = '', options = { mode: 'succeed', color: 'green' }) {
662
+ const spinnerMessage = options?.color ? chalk[options.color](message) : message;
663
+ this.spinner?.[options.mode || 'succeed'](spinnerMessage);
664
+ this.spinner = null;
665
+ return this;
666
+ }
667
+ }
668
+
706
669
  const llm = async ({ llm, prompt, variables }) => {
707
670
  if (!llm || !prompt || !variables) {
708
671
  throw new Error('The input parameters "llm", "prompt", and "variables" are all required.');
@@ -760,6 +723,7 @@ const getSummaryText = (file, change) => {
760
723
  return `${status}: ${file.path}`;
761
724
  };
762
725
 
726
+ const config = loadConfig();
763
727
  const DEFAULT_IGNORED_FILES = config?.ignoredFiles?.length ? config.ignoredFiles : [];
764
728
  const DEFAULT_IGNORED_EXTENSIONS = config?.ignoredExtensions?.length ? config.ignoredExtensions : [];
765
729
  async function getChanges(git, options = {}) {
@@ -814,62 +778,117 @@ async function getChanges(git, options = {}) {
814
778
 
815
779
  const noResult = async ({ git, logger }) => {
816
780
  const { staged, unstaged, untracked } = await getChanges(git);
817
- if (staged.length > 0) {
781
+ const hasStaged = staged && staged.length > 0;
782
+ const hasUnstaged = unstaged && unstaged.length > 0;
783
+ const hasUntracked = untracked && untracked.length > 0;
784
+ if (hasStaged) {
818
785
  logger.log(`Staged files detected, but no summary generated...`, { color: 'red' });
819
786
  logger.log(`Files are likely either:\n • changed files are ignored\n • file diff is too large.`, { color: 'yellow' });
820
787
  }
821
- else if (unstaged && unstaged.length > 0) {
822
- logger.log('No staged files detected, but unstaged files detected.', { color: 'yellow' });
823
- logger.verbose(`\n Unstaged Changes: \n ${unstaged.map(({ summary }) => summary).join('\n ')}`, {
824
- color: 'yellow',
825
- });
826
- }
827
- else if (untracked && untracked.length > 0) {
828
- logger.log('No staged files detected, but untracked files detected.', { color: 'yellow' });
829
- logger.verbose(`\n Untracked Changes: \n ${untracked.map(({ summary }) => summary).join('\n ')}`, {
830
- color: 'yellow',
831
- });
788
+ else if (hasUnstaged || hasUntracked) {
789
+ logger.log('Forget something? No staged changes found... 👻', { color: 'red' });
790
+ if (hasUnstaged) {
791
+ logger.log('\nDetected unstaged changes', { color: 'yellow' });
792
+ logger.verbose(`\t${unstaged.map(({ summary }) => summary).join('\n\t')}`, {
793
+ color: 'red',
794
+ });
795
+ }
796
+ if (hasUntracked) {
797
+ logger.log('\nDetected untracked changes', { color: 'yellow' });
798
+ logger.verbose(`\t${untracked.map(({ summary }) => summary).join('\n\t')}`, {
799
+ color: 'red',
800
+ });
801
+ }
832
802
  }
833
803
  else {
834
- logger.log('No repo changes detected.', { color: 'yellow' });
804
+ logger.log('No repo changes detected. 👀', { color: 'blue' });
835
805
  }
836
- process.exit(0);
837
806
  };
838
807
 
839
808
  async function createCommit(commitMsg, git) {
840
809
  return await git.commit(commitMsg);
841
810
  }
842
811
 
843
- const argv = loadArgv();
812
+ // const argv = loadArgv()
844
813
  const tokenizer = getTokenizer();
845
814
  const git = simpleGit();
846
- async function main(options) {
847
- const logger = new Logger(config);
848
- if (!config.openAIApiKey) {
815
+ const command = ['commit', '$0'];
816
+ const description = 'Generate a commit message based on the diff summary';
817
+ const builder = {
818
+ model: { type: 'string', description: 'LLM/Model-Name' },
819
+ openAIApiKey: {
820
+ type: 'string',
821
+ description: 'OpenAI API Key',
822
+ conflicts: 'huggingFaceHubApiKey',
823
+ },
824
+ huggingFaceHubApiKey: {
825
+ type: 'string',
826
+ description: 'HuggingFace Hub API Key',
827
+ conflicts: 'openAIApiKey',
828
+ },
829
+ tokenLimit: { type: 'number', description: 'Token limit' },
830
+ prompt: {
831
+ type: 'string',
832
+ alias: 'p',
833
+ description: 'Commit message prompt',
834
+ },
835
+ i: {
836
+ type: 'boolean',
837
+ alias: 'interactive',
838
+ description: 'Toggle interactive mode',
839
+ },
840
+ s: {
841
+ type: 'boolean',
842
+ description: 'Automatically commit staged changes with generated commit message',
843
+ default: false,
844
+ },
845
+ e: {
846
+ type: 'boolean',
847
+ alias: 'edit',
848
+ description: 'Open commit message in editor before proceeding',
849
+ },
850
+ summarizePrompt: {
851
+ type: 'string',
852
+ description: 'Large file summary prompt',
853
+ },
854
+ ignoredFiles: {
855
+ type: 'array',
856
+ description: 'Ignored files',
857
+ },
858
+ ignoredExtensions: {
859
+ type: 'array',
860
+ description: 'Ignored extensions',
861
+ },
862
+ };
863
+ async function handler(argv) {
864
+ const options = loadConfig(argv);
865
+ const logger = new Logger(options);
866
+ const key = getModelAPIKey(options.model, options);
867
+ if (!key) {
849
868
  logger.log(`No API Key found. 🗝️🚪`, { color: 'red' });
850
869
  process.exit(1);
851
870
  }
852
- const model = getModel({
871
+ const model = getModel(options.model, key, {
853
872
  temperature: 0.4,
854
873
  maxConcurrency: 10,
855
- openAIApiKey: config.openAIApiKey,
856
874
  });
857
- const INTERACTIVE = config?.mode === 'interactive' || options.interactive;
875
+ const INTERACTIVE = options?.mode === 'interactive' || options.interactive;
858
876
  const { staged: changes } = await getChanges(git);
859
877
  let summary = '';
860
878
  let commitMsg = '';
861
- let promptTemplate = config?.prompt || '';
879
+ let promptTemplate = options?.prompt || '';
862
880
  let modifyPrompt = false;
863
881
  while (true) {
864
882
  if (changes.length !== 0 && !summary.length) {
865
883
  logger.verbose(`\nChanged Files: \n ${changes.map(({ summary }) => summary).join('\n ')}`, {
866
884
  color: 'blue',
867
885
  });
868
- summary = await fileChangeParser(changes, { tokenizer, git, model });
886
+ summary = await fileChangeParser(changes, { tokenizer, git, model, logger });
869
887
  }
870
888
  // Handle empty summary
871
889
  if (!summary.length) {
872
- noResult({ git, logger });
890
+ await noResult({ git, logger });
891
+ process.exit(0);
873
892
  }
874
893
  // Prompt user for commit template prompt, if necessary
875
894
  if (modifyPrompt) {
@@ -945,7 +964,7 @@ async function main(options) {
945
964
  process.exit(0);
946
965
  }
947
966
  if (reviewAnswer === 'edit') {
948
- config.openInEditor = true;
967
+ options.openInEditor = true;
949
968
  }
950
969
  if (reviewAnswer === 'retryFull') {
951
970
  summary = '';
@@ -964,7 +983,7 @@ async function main(options) {
964
983
  continue;
965
984
  }
966
985
  }
967
- if (config.openInEditor) {
986
+ if (options.openInEditor) {
968
987
  commitMsg = await editor({
969
988
  message: 'Edit the commit message',
970
989
  default: commitMsg,
@@ -979,7 +998,7 @@ async function main(options) {
979
998
  }
980
999
  const MODE = (options.interactive && 'interactive') ||
981
1000
  (options.commit && 'interactive') ||
982
- config?.mode ||
1001
+ options?.mode ||
983
1002
  'stdout';
984
1003
  // Handle resulting commit message
985
1004
  switch (MODE) {
@@ -995,5 +1014,28 @@ async function main(options) {
995
1014
  process.exit(0);
996
1015
  }
997
1016
  }
998
- main(argv).catch(console.error);
1017
+
1018
+ var commit = /*#__PURE__*/Object.freeze({
1019
+ __proto__: null,
1020
+ builder: builder,
1021
+ command: command,
1022
+ description: description,
1023
+ handler: handler
1024
+ });
1025
+
1026
+ yargs
1027
+ .scriptName('coco')
1028
+ .commandDir('./commands', {
1029
+ extensions: ['ts'],
1030
+ })
1031
+ .demandCommand()
1032
+ .strict()
1033
+ .option('h', { alias: 'help' })
1034
+ .option('v', {
1035
+ alias: 'verbose',
1036
+ type: 'boolean',
1037
+ description: 'Run with verbose logging',
1038
+ }).argv;
1039
+
1040
+ export { commit, loadConfig };
999
1041
  //# sourceMappingURL=index.esm.mjs.map