git-coco 0.1.1 β†’ 0.2.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.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # `coco` πŸ€–πŸ¦
1
+ # `coco` πŸ€– 🦍
2
2
 
3
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
4
 
@@ -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,12 @@ 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
- import { minimatch } from 'minimatch';
22
+ import { simpleGit } from 'simple-git';
25
23
 
26
24
  /**
27
25
  * Returns a new object with all undefined keys removed
@@ -419,7 +417,7 @@ const defaultOutputCallback = (group) => {
419
417
  let output = `
420
418
  -------\n* changes in "/${group.path}"\n\n`;
421
419
  if (group.summary) {
422
- output += `${group.diffs.map((diff) => ` β€’ ${diff.summary}`).join('\n')}\n\nSummary:${group.summary}\n\n`;
420
+ output += `${group.diffs.map((diff) => ` β€’ ${diff.summary}`).join('\n')}\n\nSummary:\n\n${group.summary}\n\n`;
423
421
  }
424
422
  else {
425
423
  output += `${group.diffs.map((diff) => ` β€’ ${diff.summary}\n\n${diff.diff}`).join('\n\n')}\n\n`;
@@ -543,61 +541,6 @@ async function collectDiffs(node, getFileDiff, tokenizer, logger = new Logger(co
543
541
  };
544
542
  }
545
543
 
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
544
  // TODO: Extend this to support other models! πŸŽ‰
602
545
  function getModel(fields, configuration) {
603
546
  return new OpenAI(fields, configuration);
@@ -638,12 +581,54 @@ function validatePromptTemplate(text, inputVariables) {
638
581
  return true;
639
582
  }
640
583
 
584
+ const parseDefaultFileDiff = async (nodeFile, git) => {
585
+ return await git.diff(['--staged', nodeFile.filepath]);
586
+ };
587
+ const parseRenamedFileDiff = async (nodeFile, git, logger) => {
588
+ let result = '';
589
+ const oldFilepath = nodeFile?.oldFilepath || nodeFile.filepath;
590
+ try {
591
+ const [headContent, indexContent] = await Promise.all([
592
+ // git.diff(['HEAD', '-M', '--', oldFilepath]),
593
+ // git.diff(['-z', '-M', '--staged', nodeFile.filepath]),
594
+ git.show([`HEAD:${oldFilepath}`]),
595
+ git.show([`:${nodeFile.filepath}`]),
596
+ // readFile(nodeFile.filepath),
597
+ ]);
598
+ if (headContent !== indexContent) {
599
+ result = createTwoFilesPatch(oldFilepath, nodeFile.filepath, headContent, indexContent.toString(), '', '', {
600
+ context: 3,
601
+ });
602
+ // remove the first 4 lines of the patch (they contain the old and new file names)
603
+ result = result.split('\n').slice(4).join('\n');
604
+ }
605
+ else {
606
+ result = 'File contents are unchanged.';
607
+ }
608
+ }
609
+ catch (err) {
610
+ logger.verbose(`Error comparing file contents for ${nodeFile.filepath}`, { color: 'red' });
611
+ console.log(err);
612
+ result = 'Error comparing file contents.';
613
+ }
614
+ return result;
615
+ };
616
+ const getDiff = async (nodeFile, { git, logger, }) => {
617
+ if (nodeFile.status === 'deleted') {
618
+ return 'This file has been deleted.';
619
+ }
620
+ if (nodeFile.status === 'renamed' && nodeFile.oldFilepath) {
621
+ const renamedDiff = await parseRenamedFileDiff(nodeFile, git, logger);
622
+ return renamedDiff;
623
+ }
624
+ // If not deleted or renamed, get the diff from the index
625
+ const defaultDiff = await parseDefaultFileDiff(nodeFile, git);
626
+ return defaultDiff;
627
+ };
628
+
641
629
  const MAX_TOKENS_PER_SUMMARY = 2048;
642
- const fileChangeParser = async (changes, { tokenizer, repo, model }) => {
630
+ const fileChangeParser = async (changes, { tokenizer, git, model }) => {
643
631
  const logger = new Logger(config);
644
- const head = await repo.getHeadCommit();
645
- const headTree = await head.getTree();
646
- const index = await repo.refreshIndex();
647
632
  const textSplitter = getTextSplitter({ chunkSize: 2000, chunkOverlap: 125, });
648
633
  const summarizationChain = getChain(model, {
649
634
  type: 'map_reduce',
@@ -655,7 +640,7 @@ const fileChangeParser = async (changes, { tokenizer, repo, model }) => {
655
640
  logger.stopTimer('Created file hierarchy');
656
641
  // Collect diffs
657
642
  logger.startTimer().startSpinner(`Collecting Diffs...\n`, { color: 'blue' });
658
- const diffs = await collectDiffs(rootTreeNode, (path) => parseFileDiff(path, repo, headTree, index, logger), tokenizer, logger);
643
+ const diffs = await collectDiffs(rootTreeNode, (path) => getDiff(path, { git, logger }), tokenizer, logger);
659
644
  logger.stopSpinner('Diffs Collected').stopTimer();
660
645
  // Summarize diffs
661
646
  logger.startTimer();
@@ -666,6 +651,7 @@ const fileChangeParser = async (changes, { tokenizer, repo, model }) => {
666
651
  chain: summarizationChain,
667
652
  });
668
653
  logger.stopTimer(`\nSummary generated for ${changes.length} staged files`, { color: 'green' });
654
+ logger.verbose(`\nSummary:\n${summary}`, { color: 'blue' });
669
655
  return summary;
670
656
  };
671
657
 
@@ -699,102 +685,47 @@ const getTokenizer = () => {
699
685
  return tokenizer;
700
686
  };
701
687
 
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;
688
+ const llm = async ({ llm, prompt, variables }) => {
689
+ const chain = new LLMChain({ llm, prompt });
690
+ const res = await chain.call(variables);
691
+ if (res.error)
692
+ throw new Error(res.error);
693
+ return res.text.trim();
727
694
  };
728
695
 
729
- const getStatus = (patch) => {
696
+ const getStatus = (file, location = 'index') => {
697
+ const statusCode = file[location] ? file[location] : file.index;
730
698
  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';
699
+ switch (statusCode) {
700
+ case 'A':
701
+ status = 'added';
702
+ break;
703
+ case 'D':
704
+ status = 'deleted';
705
+ break;
706
+ case 'M':
707
+ status = 'modified';
708
+ break;
709
+ case 'R':
710
+ status = 'renamed';
711
+ break;
712
+ case '?':
713
+ status = 'untracked';
714
+ break;
715
+ default:
716
+ status = 'unknown';
717
+ break;
751
718
  }
752
719
  return status;
753
720
  };
754
721
 
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
- });
722
+ const getSummaryText = (file, change) => {
723
+ const status = change.status || getStatus(file);
724
+ if (change.oldFilepath) {
725
+ return `${status}: ${change.oldFilepath} -> ${file.path}`;
726
+ }
727
+ return `${status}: ${file.path}`;
728
+ };
798
729
 
799
730
  const DEFAULT_IGNORED_FILES = [
800
731
  ...(config?.ignoredFiles?.length && config?.ignoredFiles?.length > 0 ? config.ignoredFiles : []),
@@ -804,92 +735,55 @@ const DEFAULT_IGNORED_EXTENSIONS = [
804
735
  ? config.ignoredExtensions
805
736
  : []),
806
737
  ];
807
- /**
808
- * The 'git status' for coco
809
- *
810
- * Get paths of changed files in the Git repository, excluding ignored files and extensions.
811
- *
812
- * @param {string[]} [options.ignoredFiles] - An optional array of file patterns to ignore.
813
- * If not provided, it defaults to the `ignoredFiles` configuration value from the app's config.
814
- * @param {string[]} [options.ignoredExtensions] - An optional array of file extensions to ignore.
815
- * If not provided, it defaults to the `ignoredExtensions` configuration value from the app's config.
816
- * @returns {Promise<GetChangesResult>} A Promise that resolves to an array of changed file paths.
817
- *
818
- * @example
819
- * const changes = await getStagedChanges()
820
- * console.log(changes)
821
- * // {
822
- * // staged: [
823
- * // {
824
- * // filepath: 'src/index.ts',
825
- * // action: 'modified'
826
- * // },
827
- * // ],
828
- * // unstaged: [
829
- * // {
830
- * // filepath: 'src/index.test.ts',
831
- * // action: 'added'
832
- * // }
833
- * // ]
834
- * // }
835
- */
836
- async function getChanges(repo, options = {}) {
837
- const { ignoredFiles = DEFAULT_IGNORED_FILES, ignoredExtensions = DEFAULT_IGNORED_EXTENSIONS, ignoreUnstaged, ignoreUntracked, } = options;
838
- const head = await repo.getHeadCommit();
839
- const index = await repo.refreshIndex();
840
- const tree = await (head ? await head.getTree() : Tree.lookup(repo, EMPTY_GIT_TREE_HASH));
841
- let unstaged = [];
842
- let untracked = [];
843
- if (!ignoreUnstaged) {
844
- const unstagedDiff = await Diff.indexToWorkdir(repo, index, {
845
- flags: 16 /* Diff.OPTION.RECURSE_UNTRACKED_DIRS */,
846
- });
847
- const unstagedPatches = await unstagedDiff.patches();
848
- unstaged = await parsePatches(unstagedPatches, { ignoredFiles, ignoredExtensions });
849
- }
850
- if (!ignoreUntracked) {
851
- const untrackedDiff = await Diff.treeToWorkdirWithIndex(repo, tree, {
852
- flags: 33554432 /* Diff.OPTION.SHOW_UNTRACKED_CONTENT */,
853
- });
854
- const untrackedPatches = await untrackedDiff.patches();
855
- untracked = (await parsePatches(untrackedPatches, { ignoredFiles, ignoredExtensions })).filter(({ status }) => status === 'untracked');
856
- }
857
- const diff = await Diff.treeToIndex(repo, tree, index);
858
- await diff.findSimilar({
859
- flags: 1 /* Diff.FIND.RENAMES */,
738
+ async function getChanges(git, options = {}) {
739
+ const { ignoredFiles = DEFAULT_IGNORED_FILES, ignoredExtensions = DEFAULT_IGNORED_EXTENSIONS } = options;
740
+ const staged = [];
741
+ const unstaged = [];
742
+ const untracked = [];
743
+ const status = await git.status();
744
+ status.files.forEach((file) => {
745
+ // console.log({ file })
746
+ const fileChange = {
747
+ filepath: file.path,
748
+ oldFilepath: status.renamed.filter((renamed) => renamed.to === file.path)[0]?.from,
749
+ };
750
+ // Unstaged files
751
+ if (file.working_dir !== '?' && file.working_dir !== ' ') {
752
+ fileChange.status = getStatus(file, 'working_dir');
753
+ fileChange.summary = getSummaryText(file, fileChange);
754
+ unstaged.push(fileChange);
755
+ }
756
+ // Staged files
757
+ if (file.index !== ' ' && file.index !== '?') {
758
+ fileChange.status = getStatus(file);
759
+ fileChange.summary = getSummaryText(file, fileChange);
760
+ staged.push(fileChange);
761
+ }
762
+ // Untracked files
763
+ if (file.working_dir === '?' && file.index === '?') {
764
+ fileChange.status = 'added';
765
+ fileChange.summary = getSummaryText(file, fileChange);
766
+ untracked.push(fileChange);
767
+ }
768
+ });
769
+ const ignoredExtensionsSet = new Set(ignoredExtensions.map((extension) => extension.toLowerCase()));
770
+ const filteredStaged = staged.filter((file) => {
771
+ const extension = path__default.extname(file.filepath).toLowerCase();
772
+ return !ignoredExtensionsSet.has(extension) && !ignoredFiles.includes(file.filepath);
773
+ });
774
+ const filteredUnstaged = unstaged.filter((file) => {
775
+ const extension = path__default.extname(file.filepath).toLowerCase();
776
+ return !ignoredExtensionsSet.has(extension) && !ignoredFiles.includes(file.filepath);
860
777
  });
861
- const patches = await diff.patches();
862
778
  return {
863
- staged: await parsePatches(patches, { ignoredFiles, ignoredExtensions }),
864
- unstaged,
779
+ staged: filteredStaged,
780
+ unstaged: filteredUnstaged,
865
781
  untracked,
866
782
  };
867
783
  }
868
784
 
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
- });
785
+ const noResult = async ({ git, logger }) => {
786
+ const { staged, unstaged, untracked } = await getChanges(git);
893
787
  if (staged.length > 0) {
894
788
  logger.log(`Staged files detected, but no summary generated...`, { color: 'red' });
895
789
  logger.log(`Files are likely either:\n β€’ changed files are ignored\n β€’ file diff is too large.`, { color: 'yellow' });
@@ -912,25 +806,26 @@ const noResult = async ({ repo, logger }) => {
912
806
  process.exit(0);
913
807
  };
914
808
 
809
+ async function createCommit(commitMsg, git) {
810
+ return await git.commit(commitMsg);
811
+ }
812
+
915
813
  const argv = loadArgv();
916
814
  const tokenizer = getTokenizer();
815
+ const git = simpleGit();
917
816
  async function main(options) {
918
817
  const logger = new Logger(config);
919
818
  if (!config.openAIApiKey) {
920
819
  logger.log(`No API Key found. πŸ—οΈπŸšͺ`, { color: 'red' });
921
820
  process.exit(1);
922
821
  }
923
- const repo = await Repository.open('.');
924
822
  const model = getModel({
925
823
  temperature: 0.4,
926
824
  maxConcurrency: 10,
927
825
  openAIApiKey: config.openAIApiKey,
928
826
  });
929
827
  const INTERACTIVE = config?.mode === 'interactive' || options.interactive;
930
- const { staged: changes } = await getChanges(repo, {
931
- ignoreUnstaged: true,
932
- ignoreUntracked: true,
933
- });
828
+ const { staged: changes } = await getChanges(git);
934
829
  let summary = '';
935
830
  let commitMsg = '';
936
831
  let promptTemplate = config?.prompt || '';
@@ -940,11 +835,11 @@ async function main(options) {
940
835
  logger.verbose(`\nChanged Files: \n ${changes.map(({ summary }) => summary).join('\n ')}`, {
941
836
  color: 'blue',
942
837
  });
943
- summary = await fileChangeParser(changes, { tokenizer, repo, model });
838
+ summary = await fileChangeParser(changes, { tokenizer, git, model });
944
839
  }
945
840
  // Handle empty summary
946
841
  if (!summary.length) {
947
- noResult({ repo, logger });
842
+ noResult({ git, logger });
948
843
  }
949
844
  // Prompt user for commit template prompt, if necessary
950
845
  if (modifyPrompt) {
@@ -1059,7 +954,7 @@ async function main(options) {
1059
954
  // Handle resulting commit message
1060
955
  switch (MODE) {
1061
956
  case 'interactive':
1062
- await createCommit(commitMsg, repo);
957
+ await createCommit(commitMsg, git);
1063
958
  logSuccess();
1064
959
  break;
1065
960
  case 'stdout':