git-coco 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,14 @@
1
- # `coco` 🤖🦍
1
+ # `coco` 🤖 🦍
2
+
3
+ [![GitHub issues](https://img.shields.io/github/issues/gfargo/coco)](https://github.com/gfargo/coco/issues)
4
+ [![GitHub pull requests](https://img.shields.io/github/issues-pr/gfargo/coco)](https://github.com/gfargo/coco/pulls)
5
+ [![Last Commit](https://img.shields.io/github/last-commit/gfargo/coco)](https://github.com/gfargo/coco/tree/main)
6
+ [![NPM Version](https://img.shields.io/npm/v/git-coco.svg)](https://www.npmjs.com/package/git-coco)
7
+ [![NPM Downloads](https://img.shields.io/npm/dt/git-coco.svg)](https://www.npmjs.com/package/git-coco)
8
+
9
+ Commit Copilot, or `coco`, is your personal scribe for git commit messages. Leveraging the power of [LangChain🦜🔗](https://js.langchain.com/) and LLMs to encapsulate your staged changes into meaningful commit messages!
10
+
2
11
 
3
- Commit Copilot, or `coco`, is your personal scribe for git commit messages. Using [LangChain🦜🔗](https://js.langchain.com/) to automate the task of creating meaningful commit messages based on your staged changes!
4
12
 
5
13
  ## Installation
6
14
 
@@ -1,10 +1,9 @@
1
1
  #!/usr/bin/env node
2
- import * as nodegit from 'nodegit';
3
- import { Diff, Blob, Tree, Repository } from 'nodegit';
4
2
  import { select, editor } from '@inquirer/prompts';
5
3
  import * as fs from 'fs';
6
4
  import * as os from 'os';
7
5
  import * as path from 'path';
6
+ import path__default from 'path';
8
7
  import * as ini from 'ini';
9
8
  import yargs from 'yargs';
10
9
  import { hideBin } from 'yargs/helpers';
@@ -15,13 +14,13 @@ import ora from 'ora';
15
14
  import now from 'performance-now';
16
15
  import prettyMilliseconds from 'pretty-ms';
17
16
  import { Document } from 'langchain/document';
18
- import { createTwoFilesPatch } from 'diff';
19
- import * as util from 'util';
20
17
  import { loadSummarizationChain, LLMChain } from 'langchain/chains';
21
18
  import { OpenAI } from 'langchain/llms/openai';
22
19
  import { RecursiveCharacterTextSplitter } from 'langchain/text_splitter';
20
+ import { createTwoFilesPatch } from 'diff';
23
21
  import GPT3NodeTokenizer from 'gpt3-tokenizer';
24
22
  import { minimatch } from 'minimatch';
23
+ import { simpleGit } from 'simple-git';
25
24
 
26
25
  /**
27
26
  * Returns a new object with all undefined keys removed
@@ -419,7 +418,7 @@ const defaultOutputCallback = (group) => {
419
418
  let output = `
420
419
  -------\n* changes in "/${group.path}"\n\n`;
421
420
  if (group.summary) {
422
- output += `${group.diffs.map((diff) => ` • ${diff.summary}`).join('\n')}\n\nSummary:${group.summary}\n\n`;
421
+ output += `${group.diffs.map((diff) => ` • ${diff.summary}`).join('\n')}\n\nSummary:\n\n${group.summary}\n\n`;
423
422
  }
424
423
  else {
425
424
  output += `${group.diffs.map((diff) => ` • ${diff.summary}\n\n${diff.diff}`).join('\n\n')}\n\n`;
@@ -543,61 +542,6 @@ async function collectDiffs(node, getFileDiff, tokenizer, logger = new Logger(co
543
542
  };
544
543
  }
545
544
 
546
- const readFile = util.promisify(fs.readFile);
547
-
548
- const parseDefaultFileDiff = async (nodeFile, repo, headTree, index) => {
549
- let result = '';
550
- const diff = await Diff.treeToIndex(repo, headTree, index, {
551
- flags: 33554432 /* Diff.OPTION.SHOW_UNTRACKED_CONTENT */ | 16 /* Diff.OPTION.RECURSE_UNTRACKED_DIRS */,
552
- pathspec: nodeFile.filepath,
553
- });
554
- const patches = await diff.patches();
555
- for (const patch of patches) {
556
- const hunks = await patch.hunks();
557
- for (const hunk of hunks) {
558
- const lines = await hunk.lines();
559
- result += lines.map((line) => String.fromCharCode(line.origin()) + line.content()).join('');
560
- }
561
- }
562
- return result;
563
- };
564
- const parseRenamedFileDiff = async (nodeFile, repo, headTree, index, logger) => {
565
- let result = '';
566
- const oldFilepath = nodeFile?.oldFilepath || nodeFile.filepath;
567
- try {
568
- const headEntry = await headTree.entryByPath(oldFilepath); // use old name to look up in latest commit
569
- const indexEntry = index.getByPath(nodeFile.filepath); // use new name to look up in index
570
- // Compare the file contents in the latest commit and index
571
- const headBlob = await Blob.lookup(repo, headEntry.sha());
572
- const indexBlobContent = await readFile(indexEntry.path); // read file from filesystem
573
- const headContent = headBlob.content().toString();
574
- const indexContent = indexBlobContent.toString();
575
- if (headContent !== indexContent) {
576
- result = createTwoFilesPatch(oldFilepath, nodeFile.filepath, headContent, indexContent, '', '', { context: 3 });
577
- // remove the first 4 lines of the patch (they contain the old and new file names)
578
- result = result.split('\n').slice(4).join('\n');
579
- }
580
- else {
581
- result = 'File contents are unchanged.';
582
- }
583
- }
584
- catch (err) {
585
- logger.verbose(`Error comparing file contents for ${nodeFile.filepath}`, { color: 'red' });
586
- result = 'Error comparing file contents.';
587
- }
588
- return result;
589
- };
590
- const parseFileDiff = async (nodeFile, repo, headTree, index, logger) => {
591
- if (nodeFile.status === 'deleted') {
592
- return 'This file has been deleted.';
593
- }
594
- if (nodeFile.status === 'renamed' && nodeFile.oldFilepath) {
595
- return parseRenamedFileDiff(nodeFile, repo, headTree, index, logger);
596
- }
597
- // If not deleted or renamed, get the diff from the index
598
- return parseDefaultFileDiff(nodeFile, repo, headTree, index);
599
- };
600
-
601
545
  // TODO: Extend this to support other models! 🎉
602
546
  function getModel(fields, configuration) {
603
547
  return new OpenAI(fields, configuration);
@@ -638,12 +582,50 @@ function validatePromptTemplate(text, inputVariables) {
638
582
  return true;
639
583
  }
640
584
 
585
+ const parseDefaultFileDiff = async (nodeFile, git) => {
586
+ return await git.diff(['--staged', nodeFile.filepath]);
587
+ };
588
+ const parseRenamedFileDiff = async (nodeFile, git, logger) => {
589
+ let result = '';
590
+ const oldFilepath = nodeFile?.oldFilepath || nodeFile.filepath;
591
+ try {
592
+ const [headContent, indexContent] = await Promise.all([
593
+ git.show([`HEAD:${oldFilepath}`]),
594
+ git.show([`:${nodeFile.filepath}`]),
595
+ ]);
596
+ if (headContent !== indexContent) {
597
+ result = createTwoFilesPatch(oldFilepath, nodeFile.filepath, headContent, indexContent, '', '', {
598
+ context: 3,
599
+ });
600
+ // remove the first 4 lines of the patch (they contain the old and new file names)
601
+ result = result.split('\n').slice(4).join('\n');
602
+ }
603
+ else {
604
+ result = 'File contents are unchanged.';
605
+ }
606
+ }
607
+ catch (err) {
608
+ logger.verbose(`Error comparing file contents for ${nodeFile.filepath}`, { color: 'red' });
609
+ result = 'Error comparing file contents.';
610
+ }
611
+ return result;
612
+ };
613
+ const getDiff = async (nodeFile, { git, logger, }) => {
614
+ if (nodeFile.status === 'deleted') {
615
+ return 'This file has been deleted.';
616
+ }
617
+ if (nodeFile.status === 'renamed' && nodeFile.oldFilepath) {
618
+ const renamedDiff = await parseRenamedFileDiff(nodeFile, git, logger);
619
+ return renamedDiff;
620
+ }
621
+ // If not deleted or renamed, get the diff from the index
622
+ const defaultDiff = await parseDefaultFileDiff(nodeFile, git);
623
+ return defaultDiff;
624
+ };
625
+
641
626
  const MAX_TOKENS_PER_SUMMARY = 2048;
642
- const fileChangeParser = async (changes, { tokenizer, repo, model }) => {
627
+ const fileChangeParser = async (changes, { tokenizer, git, model }) => {
643
628
  const logger = new Logger(config);
644
- const head = await repo.getHeadCommit();
645
- const headTree = await head.getTree();
646
- const index = await repo.refreshIndex();
647
629
  const textSplitter = getTextSplitter({ chunkSize: 2000, chunkOverlap: 125, });
648
630
  const summarizationChain = getChain(model, {
649
631
  type: 'map_reduce',
@@ -655,7 +637,7 @@ const fileChangeParser = async (changes, { tokenizer, repo, model }) => {
655
637
  logger.stopTimer('Created file hierarchy');
656
638
  // Collect diffs
657
639
  logger.startTimer().startSpinner(`Collecting Diffs...\n`, { color: 'blue' });
658
- const diffs = await collectDiffs(rootTreeNode, (path) => parseFileDiff(path, repo, headTree, index, logger), tokenizer, logger);
640
+ const diffs = await collectDiffs(rootTreeNode, (path) => getDiff(path, { git, logger }), tokenizer, logger);
659
641
  logger.stopSpinner('Diffs Collected').stopTimer();
660
642
  // Summarize diffs
661
643
  logger.startTimer();
@@ -699,197 +681,102 @@ const getTokenizer = () => {
699
681
  return tokenizer;
700
682
  };
701
683
 
702
- const EMPTY_GIT_TREE_HASH = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
703
-
704
- const getSummaryText = (patch) => {
705
- const oldFilePath = patch.oldFile().path();
706
- const newFilePath = patch.newFile().path();
707
- let summary;
708
- if (patch.isAdded()) {
709
- summary = `added: ${newFilePath}`;
710
- }
711
- else if (patch.isDeleted()) {
712
- summary = `deleted: ${oldFilePath}`;
713
- }
714
- else if (patch.isModified()) {
715
- summary = `modified: ${newFilePath}`;
716
- }
717
- else if (patch.isRenamed()) {
718
- summary = `renamed: ${oldFilePath} -> ${newFilePath}`;
719
- }
720
- else if (patch.isUntracked()) {
721
- summary = `untracked: ${newFilePath}`;
722
- }
723
- else {
724
- summary = `unknown: ${newFilePath}`;
725
- }
726
- return summary;
684
+ const llm = async ({ llm, prompt, variables }) => {
685
+ const chain = new LLMChain({ llm, prompt });
686
+ const res = await chain.call(variables);
687
+ if (res.error)
688
+ throw new Error(res.error);
689
+ return res.text.trim();
727
690
  };
728
691
 
729
- const getStatus = (patch) => {
692
+ const getStatus = (file, location = 'index') => {
693
+ const statusCode = file[location] ? file[location] : file.index;
730
694
  let status;
731
- if (patch.isAdded()) {
732
- status = 'added';
733
- }
734
- else if (patch.isDeleted()) {
735
- status = 'deleted';
736
- }
737
- else if (patch.isModified()) {
738
- status = 'modified';
739
- }
740
- else if (patch.isRenamed()) {
741
- status = 'renamed';
742
- }
743
- else if (patch.isUntracked()) {
744
- status = 'untracked';
745
- }
746
- else if (patch.newFile()) {
747
- status = 'new file';
748
- }
749
- else {
750
- status = 'unknown';
695
+ switch (statusCode) {
696
+ case 'A':
697
+ status = 'added';
698
+ break;
699
+ case 'D':
700
+ status = 'deleted';
701
+ break;
702
+ case 'M':
703
+ status = 'modified';
704
+ break;
705
+ case 'R':
706
+ status = 'renamed';
707
+ break;
708
+ case '?':
709
+ status = 'untracked';
710
+ break;
711
+ default:
712
+ status = 'unknown';
713
+ break;
751
714
  }
752
715
  return status;
753
716
  };
754
717
 
755
- const DEFAULT_IGNORED_FILES$1 = [
756
- ...(config?.ignoredFiles?.length && config?.ignoredFiles?.length > 0 ? config.ignoredFiles : []),
757
- ];
758
- const DEFAULT_IGNORED_EXTENSIONS$1 = [
759
- ...(config?.ignoredExtensions?.length && config?.ignoredExtensions?.length > 0
760
- ? config.ignoredExtensions
761
- : []),
762
- ];
763
- /**
764
- * Parse patches from a git diff.
765
- *
766
- * @param {ConvenientPatch[]} patches - An array of git patches.
767
- * @param {string[]} [options.ignoredFiles] - An optional array of file patterns to ignore.
768
- * If not provided, it defaults to the `ignoredFiles` configuration value from the app's config.
769
- * @param {string[]} [options.ignoredExtensions] - An optional array of file extensions to ignore.
770
- * If not provided, it defaults to the `ignoredExtensions` configuration value from the app's config.
771
- * @returns {Promise<FileChange[]>} A Promise that resolves to an array of file changes.
772
- **/
773
- const parsePatches = async (patches, { ignoredFiles = DEFAULT_IGNORED_FILES$1, ignoredExtensions = DEFAULT_IGNORED_EXTENSIONS$1, }) => patches
774
- .map((patch) => {
775
- const summary = getSummaryText(patch);
776
- const status = getStatus(patch);
777
- return {
778
- filepath: patch.newFile().path(),
779
- oldFilepath: status === 'renamed' ? patch.oldFile().path() : undefined,
780
- summary,
781
- status,
782
- };
783
- })
784
- .filter(Boolean)
785
- // Filter out ignored files & extensions...
786
- .filter(({ filepath }) => {
787
- if (!filepath)
788
- return false;
789
- const extension = filepath.split('.').pop();
790
- // Remove ignored extensions
791
- if (extension && ignoredExtensions.includes(extension))
792
- return false;
793
- // Remove ignored files
794
- if (ignoredFiles.some((pattern) => minimatch(filepath, pattern)))
795
- return false;
796
- return true;
797
- });
798
-
799
- const DEFAULT_IGNORED_FILES = [
800
- ...(config?.ignoredFiles?.length && config?.ignoredFiles?.length > 0 ? config.ignoredFiles : []),
801
- ];
802
- const DEFAULT_IGNORED_EXTENSIONS = [
803
- ...(config?.ignoredExtensions?.length && config?.ignoredExtensions?.length > 0
804
- ? config.ignoredExtensions
805
- : []),
806
- ];
807
- /**
808
- * The 'git status' for coco
809
- *
810
- * Get paths of changed files in the Git repository, excluding ignored files and extensions.
811
- *
812
- * @param {string[]} [options.ignoredFiles] - An optional array of file patterns to ignore.
813
- * If not provided, it defaults to the `ignoredFiles` configuration value from the app's config.
814
- * @param {string[]} [options.ignoredExtensions] - An optional array of file extensions to ignore.
815
- * If not provided, it defaults to the `ignoredExtensions` configuration value from the app's config.
816
- * @returns {Promise<GetChangesResult>} A Promise that resolves to an array of changed file paths.
817
- *
818
- * @example
819
- * const changes = await getStagedChanges()
820
- * console.log(changes)
821
- * // {
822
- * // staged: [
823
- * // {
824
- * // filepath: 'src/index.ts',
825
- * // action: 'modified'
826
- * // },
827
- * // ],
828
- * // unstaged: [
829
- * // {
830
- * // filepath: 'src/index.test.ts',
831
- * // action: 'added'
832
- * // }
833
- * // ]
834
- * // }
835
- */
836
- async function getChanges(repo, options = {}) {
837
- const { ignoredFiles = DEFAULT_IGNORED_FILES, ignoredExtensions = DEFAULT_IGNORED_EXTENSIONS, ignoreUnstaged, ignoreUntracked, } = options;
838
- const head = await repo.getHeadCommit();
839
- const index = await repo.refreshIndex();
840
- const tree = await (head ? await head.getTree() : Tree.lookup(repo, EMPTY_GIT_TREE_HASH));
841
- let unstaged = [];
842
- let untracked = [];
843
- if (!ignoreUnstaged) {
844
- const unstagedDiff = await Diff.indexToWorkdir(repo, index, {
845
- flags: 16 /* Diff.OPTION.RECURSE_UNTRACKED_DIRS */,
846
- });
847
- const unstagedPatches = await unstagedDiff.patches();
848
- unstaged = await parsePatches(unstagedPatches, { ignoredFiles, ignoredExtensions });
849
- }
850
- if (!ignoreUntracked) {
851
- const untrackedDiff = await Diff.treeToWorkdirWithIndex(repo, tree, {
852
- flags: 33554432 /* Diff.OPTION.SHOW_UNTRACKED_CONTENT */,
853
- });
854
- const untrackedPatches = await untrackedDiff.patches();
855
- untracked = (await parsePatches(untrackedPatches, { ignoredFiles, ignoredExtensions })).filter(({ status }) => status === 'untracked');
718
+ const getSummaryText = (file, change) => {
719
+ const status = change.status || getStatus(file);
720
+ if (change.oldFilepath) {
721
+ return `${status}: ${change.oldFilepath} -> ${file.path}`;
856
722
  }
857
- const diff = await Diff.treeToIndex(repo, tree, index);
858
- await diff.findSimilar({
859
- flags: 1 /* Diff.FIND.RENAMES */,
723
+ return `${status}: ${file.path}`;
724
+ };
725
+
726
+ const DEFAULT_IGNORED_FILES = config?.ignoredFiles?.length ? config.ignoredFiles : [];
727
+ const DEFAULT_IGNORED_EXTENSIONS = config?.ignoredExtensions?.length ? config.ignoredExtensions : [];
728
+ async function getChanges(git, options = {}) {
729
+ const { ignoredFiles = DEFAULT_IGNORED_FILES, ignoredExtensions = DEFAULT_IGNORED_EXTENSIONS } = options;
730
+ const staged = [];
731
+ const unstaged = [];
732
+ const untracked = [];
733
+ const status = await git.status();
734
+ status.files.forEach((file) => {
735
+ const fileChange = {
736
+ filepath: file.path,
737
+ oldFilepath: status.renamed.filter((renamed) => renamed.to === file.path)[0]?.from,
738
+ };
739
+ // Unstaged files
740
+ if (file.working_dir !== '?' && file.working_dir !== ' ') {
741
+ fileChange.status = getStatus(file, 'working_dir');
742
+ fileChange.summary = getSummaryText(file, fileChange);
743
+ unstaged.push(fileChange);
744
+ }
745
+ // Staged files
746
+ if (file.index !== ' ' && file.index !== '?') {
747
+ fileChange.status = getStatus(file);
748
+ fileChange.summary = getSummaryText(file, fileChange);
749
+ staged.push(fileChange);
750
+ }
751
+ // Untracked files
752
+ if (file.working_dir === '?' && file.index === '?') {
753
+ fileChange.status = 'added';
754
+ fileChange.summary = getSummaryText(file, fileChange);
755
+ untracked.push(fileChange);
756
+ }
757
+ });
758
+ const ignoredExtensionsSet = new Set(ignoredExtensions.map((extension) => extension.toLowerCase()));
759
+ const filteredStaged = staged.filter((file) => {
760
+ const extension = path__default.extname(file.filepath).toLowerCase();
761
+ return !ignoredExtensionsSet.has(extension) && !ignoredFiles.some(ignoredPattern => minimatch(file.filepath, ignoredPattern));
762
+ });
763
+ const filteredUnstaged = unstaged.filter((file) => {
764
+ const extension = path__default.extname(file.filepath).toLowerCase();
765
+ return !ignoredExtensionsSet.has(extension) && !ignoredFiles.some(ignoredPattern => minimatch(file.filepath, ignoredPattern));
766
+ });
767
+ const filteredUntracked = untracked.filter((file) => {
768
+ const extension = path__default.extname(file.filepath).toLowerCase();
769
+ return !ignoredExtensionsSet.has(extension) && !ignoredFiles.some(ignoredPattern => minimatch(file.filepath, ignoredPattern));
860
770
  });
861
- const patches = await diff.patches();
862
771
  return {
863
- staged: await parsePatches(patches, { ignoredFiles, ignoredExtensions }),
864
- unstaged,
865
- untracked,
772
+ staged: filteredStaged,
773
+ unstaged: filteredUnstaged,
774
+ untracked: filteredUntracked,
866
775
  };
867
776
  }
868
777
 
869
- async function createCommit(commitMsg, repo) {
870
- const author = await nodegit.Signature.default(repo);
871
- const index = await repo.refreshIndex();
872
- await index.addAll();
873
- await index.write();
874
- const oid = await index.writeTree();
875
- const head = await nodegit.Reference.nameToId(repo, "HEAD");
876
- const parent = await repo.getCommit(head);
877
- return await repo.createCommit("HEAD", author, author, commitMsg, oid, [parent]);
878
- }
879
-
880
- const llm = async ({ llm, prompt, variables }) => {
881
- const chain = new LLMChain({ llm, prompt });
882
- const res = await chain.call(variables);
883
- if (res.error)
884
- throw new Error(res.error);
885
- return res.text.trim();
886
- };
887
-
888
- const noResult = async ({ repo, logger }) => {
889
- const { staged, unstaged, untracked } = await getChanges(repo, {
890
- ignoreUnstaged: false,
891
- ignoreUntracked: false,
892
- });
778
+ const noResult = async ({ git, logger }) => {
779
+ const { staged, unstaged, untracked } = await getChanges(git);
893
780
  if (staged.length > 0) {
894
781
  logger.log(`Staged files detected, but no summary generated...`, { color: 'red' });
895
782
  logger.log(`Files are likely either:\n • changed files are ignored\n • file diff is too large.`, { color: 'yellow' });
@@ -912,25 +799,26 @@ const noResult = async ({ repo, logger }) => {
912
799
  process.exit(0);
913
800
  };
914
801
 
802
+ async function createCommit(commitMsg, git) {
803
+ return await git.commit(commitMsg);
804
+ }
805
+
915
806
  const argv = loadArgv();
916
807
  const tokenizer = getTokenizer();
808
+ const git = simpleGit();
917
809
  async function main(options) {
918
810
  const logger = new Logger(config);
919
811
  if (!config.openAIApiKey) {
920
812
  logger.log(`No API Key found. 🗝️🚪`, { color: 'red' });
921
813
  process.exit(1);
922
814
  }
923
- const repo = await Repository.open('.');
924
815
  const model = getModel({
925
816
  temperature: 0.4,
926
817
  maxConcurrency: 10,
927
818
  openAIApiKey: config.openAIApiKey,
928
819
  });
929
820
  const INTERACTIVE = config?.mode === 'interactive' || options.interactive;
930
- const { staged: changes } = await getChanges(repo, {
931
- ignoreUnstaged: true,
932
- ignoreUntracked: true,
933
- });
821
+ const { staged: changes } = await getChanges(git);
934
822
  let summary = '';
935
823
  let commitMsg = '';
936
824
  let promptTemplate = config?.prompt || '';
@@ -940,11 +828,11 @@ async function main(options) {
940
828
  logger.verbose(`\nChanged Files: \n ${changes.map(({ summary }) => summary).join('\n ')}`, {
941
829
  color: 'blue',
942
830
  });
943
- summary = await fileChangeParser(changes, { tokenizer, repo, model });
831
+ summary = await fileChangeParser(changes, { tokenizer, git, model });
944
832
  }
945
833
  // Handle empty summary
946
834
  if (!summary.length) {
947
- noResult({ repo, logger });
835
+ noResult({ git, logger });
948
836
  }
949
837
  // Prompt user for commit template prompt, if necessary
950
838
  if (modifyPrompt) {
@@ -1059,7 +947,7 @@ async function main(options) {
1059
947
  // Handle resulting commit message
1060
948
  switch (MODE) {
1061
949
  case 'interactive':
1062
- await createCommit(commitMsg, repo);
950
+ await createCommit(commitMsg, git);
1063
951
  logSuccess();
1064
952
  break;
1065
953
  case 'stdout':