gennady 0.1.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/.nvmrc ADDED
@@ -0,0 +1 @@
1
+ 22
package/README.md ADDED
@@ -0,0 +1,35 @@
1
+ 🤖 Gennadyᵇᵉᵗᵃ 🗯️
2
+ -----------------
3
+ **Gen**erate **N**ext-level **A**utomated **D**escription **Y**ntelligence.
4
+
5
+ ```bash
6
+ npx gennady
7
+ ```
8
+
9
+ ---
10
+
11
+ ### Setup Local LLM
12
+
13
+ ```sh
14
+ brew install ollama
15
+ ollama pull llama3:8b
16
+ ollama serve
17
+ ```
18
+
19
+ ---
20
+
21
+ ### Usage
22
+
23
+ ```bash
24
+ # Basic usage
25
+ npx gennady
26
+
27
+ # Generate oneline commit message
28
+ npx gennady --mode=oneline
29
+
30
+ # Generate detailed commit message
31
+ npx gennady --mode=detailed
32
+
33
+ # Generate detailed commit message relative to the target branch
34
+ npx gennady --branch=develop
35
+ ```
package/cli/gennady.js ADDED
@@ -0,0 +1,33 @@
1
+ import { readFileSync } from 'fs';
2
+ import { fileURLToPath } from 'url';
3
+ import { dirname, join } from 'path';
4
+ import { CommitGen } from '../src/commit-gen/commit-gen.js';
5
+ import { parseArgs } from '../src/utils/parse-args.js';
6
+ import { style } from '../src/utils/style.js';
7
+
8
+ const PROMPTS_DIR = join(
9
+ typeof __dirname !== 'string' ? dirname(fileURLToPath(import.meta.url)) : __dirname,
10
+ '../src/prompts',
11
+ );
12
+
13
+ const params = parseArgs(process.argv, {
14
+ mode: ['mode', 'm'],
15
+ reviewerModel: ['model'],
16
+ targetBranch: ['branch', 'b'],
17
+ });
18
+
19
+ const commit = new CommitGen({
20
+ ...params,
21
+ onelinePromptTemplate: readFileSync(join(PROMPTS_DIR, 'oneline-prompt.md')).toString(),
22
+ detailedPromptTemplate: readFileSync(join(PROMPTS_DIR, 'detailed-prompt.md')).toString(),
23
+ composerPromptTemplate: readFileSync(join(PROMPTS_DIR, 'composer-prompt.md')).toString(),
24
+ });
25
+
26
+ console.info(`🤖`, style.whiteBright.bold(`GENNADY`), `(${style.cyan(commit.reviewerModel)} → ${style.yellow(commit.mode)})`, `🗯️`);
27
+ console.info(style.gray(`-`.repeat(30)));
28
+
29
+ const msg = await commit.generate();
30
+
31
+ console.info(`-`.repeat(40), '\n');
32
+ console.info(style.whiteBright(msg), '\n');
33
+ console.info(`^`.repeat(40), '\n');
package/index.js ADDED
@@ -0,0 +1 @@
1
+ export * from './src/commit-gen/commit-gen.js';
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "gennady",
3
+ "version": "0.1.0",
4
+ "author": "Konstantin Lebedev <ibnrubaxa@gmail.com>",
5
+ "description": "Gennady — Generate Next-level Automated Description Intelligence",
6
+ "keywords": [
7
+ "git",
8
+ "ai",
9
+ "llm",
10
+ "commit",
11
+ "generator"
12
+ ],
13
+ "license": "MIT",
14
+ "repository": "https://github.com/rubaxa/commit-gen",
15
+ "type": "module",
16
+ "bin": {
17
+ "gennady": "./cli/gennady.js",
18
+ "gennadi": "./cli/gennady.js"
19
+ },
20
+ "main": "index.js",
21
+ "scripts": {
22
+ "test": "echo \"Error: no test specified\" && exit 1"
23
+ }
24
+ }
@@ -0,0 +1,220 @@
1
+ import { execSync as nodeExecSync } from 'node:child_process';
2
+ import { style } from '../utils/style.js';
3
+
4
+ export class CommitGen {
5
+ constructor(init) {
6
+ this.init = {
7
+ mode: 'auto',
8
+ reviewerModel: 'llama3:8b',
9
+ targetBranch: undefined,
10
+ onelinePromptTemplate: 'Oneline commit: {diff}',
11
+ detailedPromptTemplate: 'Detailed commit: {diff}',
12
+ composerPromptTemplate: 'Compose final commit message from parts: {messages}',
13
+
14
+ maxInputTokens: init.maxInputTokens || 5000,
15
+ ollamaUrl: init.ollamaUrl || 'http://127.0.0.1:11434/api/generate',
16
+ logger: console,
17
+
18
+ ...init,
19
+ };
20
+ }
21
+
22
+ get logger() {
23
+ return this.init.logger;
24
+ }
25
+
26
+ get mode() {
27
+ return this.init.mode;
28
+ }
29
+
30
+ get reviewerModel() {
31
+ return this.init.reviewerModel;
32
+ }
33
+
34
+ get targetBranch() {
35
+ return this.init.targetBranch;
36
+ }
37
+
38
+ execSync(cmd) {
39
+ try {
40
+ return nodeExecSync(cmd, { encoding: 'utf-8'});
41
+ } catch (e) {
42
+ return '';
43
+ }
44
+ }
45
+
46
+ getCommitCount() {
47
+ const output = this.execSync('git rev-list --count HEAD 2>/dev/null');
48
+ return parseInt(output, 10) || 0;
49
+ }
50
+
51
+ getDiff() {
52
+ if (this.init.targetBranch) {
53
+ return this.execSync(`git diff ${this.init.targetBranch}`);
54
+ }
55
+
56
+ return this.execSync('git diff --cached');
57
+ }
58
+
59
+ parseDiff(diffStr) {
60
+ const lines = diffStr.split('\n');
61
+ const files = [];
62
+ let currentFile = null;
63
+
64
+ lines.forEach((line) => {
65
+ if (line.startsWith('diff --git')) {
66
+ if (currentFile) files.push(currentFile);
67
+ const parts = line.split(' ');
68
+ const bFile = parts[3] ? parts[3].replace('b/', '') : parts[2].replace('a/', '');
69
+ currentFile = { filename: bFile, diff: [line] };
70
+ } else if (currentFile) {
71
+ currentFile.diff.push(line);
72
+ }
73
+ });
74
+
75
+ if (currentFile) files.push(currentFile);
76
+
77
+ return files;
78
+ }
79
+
80
+ categorizeFiles(files) {
81
+ const lockFiles = [];
82
+ const dotFiles = [];
83
+ const docFiles = [];
84
+ const renamedFiles = [];
85
+ const deletedFiles = [];
86
+ const changedFiles = [];
87
+
88
+ files.forEach((file) => {
89
+ const isRenamed = file.diff.some(
90
+ (line) => line.includes('rename from') || line.includes('rename to')
91
+ );
92
+ const isDeleted = file.diff.some((line) => line.includes('deleted file mode'));
93
+
94
+ if (/\.md$/.test(file.filename)) {
95
+ docFiles.push(file);
96
+ } else if (/^\./.test(file.filename)) {
97
+ dotFiles.push(file);
98
+ } else if (/(\.lock|-lock\.json)$/.test(file.filename)) {
99
+ lockFiles.push(file);
100
+ } else if (isRenamed) {
101
+ renamedFiles.push(file);
102
+ } else if (isDeleted) {
103
+ deletedFiles.push(file);
104
+ } else {
105
+ changedFiles.push(file);
106
+ }
107
+ });
108
+
109
+ return { dotFiles, docFiles, lockFiles, renamedFiles, deletedFiles, changedFiles };
110
+ }
111
+
112
+ generateDiffText(files) {
113
+ return files
114
+ .map((file) => `File: ${file.filename}\n${file.diff.join('\n')}`)
115
+ .join('\n\n');
116
+ }
117
+
118
+ tokenCount(text) {
119
+ return text.split(/\s+/).length;
120
+ }
121
+
122
+ splitDiffByTokens(diffText, template, maxTokens) {
123
+ const templateTokens = this.tokenCount(template.replace('{diff}', ''));
124
+ const availableTokens = maxTokens - templateTokens;
125
+ const words = diffText.split(/\s+/);
126
+ const chunks = [];
127
+
128
+ for (let i = 0; i < words.length; i += availableTokens) {
129
+ chunks.push(words.slice(i, i + availableTokens).join(' '));
130
+ }
131
+
132
+ return chunks;
133
+ }
134
+
135
+ async generateFromOllama(prompt) {
136
+ try {
137
+ const req = await fetch(this.init.ollamaUrl, {
138
+ method: 'POST',
139
+ headers: {'Content-Type': 'application/json'},
140
+ body: JSON.stringify({
141
+ prompt,
142
+ model: this.init.reviewerModel,
143
+ stream: false,
144
+ }),
145
+ });
146
+ const data = await req.json();
147
+ return data.response || '';
148
+ } catch (e) {
149
+ this.logger.error(`Failed to generate ollama response:`, e);
150
+ return '';
151
+ }
152
+ }
153
+
154
+ async generateMessage(prompt) {
155
+ const msg = await this.generateFromOllama(prompt);
156
+ return msg.replace(/(^[\s\S]*<message>|<\/message>[\s\S]*)/g, '').replace(/\n\s*- /, '\n\n- ')
157
+ }
158
+
159
+ async generate() {
160
+ const commitCount = this.getCommitCount();
161
+ const diffStr = this.getDiff();
162
+ const parsedFiles = this.parseDiff(diffStr);
163
+ const categories = this.categorizeFiles(parsedFiles);
164
+ const mode =
165
+ this.init.mode === 'auto'
166
+ ? this.init.targetBranch
167
+ ? 'detailed'
168
+ : commitCount > 1 && categories.changedFiles.length < 5
169
+ ? 'oneline'
170
+ : 'detailed'
171
+ : this.init.mode;
172
+
173
+ this.logger.info(`Generating commit message (mode: ${mode}, commits: ${commitCount}):`);
174
+
175
+ Object.entries(categories).forEach(([key, value]) => {
176
+ this.logger.info(`- ${style.bold(key)}:`, style.cyan(value.length + ''));
177
+ });
178
+
179
+ const diffText = this.generateDiffText(categories.changedFiles);
180
+
181
+ // this.logger.info(`Diff:`, diffText);
182
+
183
+ const promptTemplate = (mode === 'oneline'
184
+ ? this.init.onelinePromptTemplate
185
+ : this.init.detailedPromptTemplate).trim();
186
+
187
+ const fullPrompt = promptTemplate.replace('{diff}', diffText);
188
+ const totalTokens = this.tokenCount(fullPrompt);
189
+ let finalMessage = '';
190
+
191
+ this.logger.info(`Total tokens: ${totalTokens}`);
192
+
193
+ if (totalTokens < this.init.maxInputTokens) {
194
+ finalMessage = await this.generateMessage(fullPrompt);
195
+ } else {
196
+ const diffChunks = this.splitDiffByTokens(
197
+ diffText,
198
+ promptTemplate,
199
+ this.init.maxInputTokens,
200
+ );
201
+ const partialMessages = [];
202
+
203
+ for (const chunk of diffChunks) {
204
+ const partPrompt = promptTemplate.replace('{diff}', chunk);
205
+ const msg = await this.generateMessage(partPrompt);
206
+
207
+ partialMessages.push(msg);
208
+ }
209
+
210
+ const composerPrompt = this.init.composerPromptTemplate.replace(
211
+ '{messages}',
212
+ partialMessages.join('\n'),
213
+ );
214
+
215
+ finalMessage = await this.generateMessage(composerPrompt);
216
+ }
217
+
218
+ return finalMessage;
219
+ }
220
+ }
@@ -0,0 +1,7 @@
1
+ You are a IT expert tasked with writing a commit message for git based on the provided code changes. Follow these rules:
2
+
3
+ - Combine and summarize all messages into one preserving the structure
4
+ - Messages are separated by "---"
5
+
6
+ **Input:**
7
+ {messages}
@@ -0,0 +1,32 @@
1
+ You are a JavaScript/TypeScript expert tasked with writing a commit message for git based on the provided code changes. Follow these rules carefully:
2
+
3
+ **Subject:**
4
+ - Clearly summarize the key changes in one concise yet informative sentence.
5
+ - Use **present tense** and **imperative mood** (e.g., "Fix bug", "Add feature").
6
+ - Do **not** describe changes for each file.
7
+ - Do **not** mention stylistic changes or fixed typos.
8
+ - The subject **must** provide enough context to understand the commit at a glance.
9
+ - End with an **emoji** instead of a period.
10
+
11
+ **Description:**
12
+ - Expand on the changes by listing key modifications as an unordered list.
13
+ - Do **not** describe changes for each file.
14
+ - Do **not** mention stylistic changes or fixed typos.
15
+ - Group related changes into single points.
16
+ - Each list item **must** be less than 140 characters and should not end with a period.
17
+
18
+ **Output Format:**
19
+ <message>subject emoji
20
+ description item 1
21
+ description item 2</message>
22
+
23
+
24
+ **Important:**
25
+ - The description **must** be an unordered list.
26
+ - Do **not** include introductory phrases like "Here's the commit message".
27
+ - The output must contain **only** the `<message>` tags and the commit message inside.
28
+ - The output **must** be wrapped inside `<message>` and `</message>` tags.
29
+ - Any deviation from this format is incorrect.
30
+
31
+ **Input:**
32
+ {diff}
@@ -0,0 +1,22 @@
1
+ You are a JavaScript/TypeScript expert tasked with writing a commit message for git based on the provided code changes. Follow these rules:
2
+
3
+ **Online message:**
4
+ - Summarize changes as one sentence of key changes (e.g., broken code, new features).
5
+ - Use present tense, imperative mood.
6
+ - Do not describe changes for each file.
7
+ - Do not mention stylistic changes or fixed typos.
8
+ - Group related changes into a single point.
9
+
10
+ **Output Format:**
11
+ <message>your commit message here</message>
12
+
13
+ **Input:**
14
+ {diff}
15
+
16
+ **Important:**
17
+ - The commit message must be wrapped inside `<message>` and `</message>` tags.
18
+ - The message must be a **single line** with no line breaks, lists, bullet points, or additional formatting.
19
+ - Do not include any additional text, explanations, or commentary.
20
+ - Do not include introductory phrases like "Here's the commit message".
21
+ - The output must contain **only** the `<message>` tags and the commit message inside.
22
+ - **Lists, multiple lines, or extra formatting are strictly forbidden.** Only a single sentence inside `<message>`.
@@ -0,0 +1,20 @@
1
+ export const parseArgs = (argv, schema) => {
2
+ const params = {};
3
+ const argsList = argv.slice(2);
4
+
5
+ argsList.forEach(arg => {
6
+ if (arg.startsWith('-')) {
7
+ const cleanArg = arg.replace(/^-+/, '');
8
+ const [key, value] = cleanArg.split('=');
9
+
10
+ for (const [optionKey, aliases] of Object.entries(schema)) {
11
+ if (aliases.includes(key)) {
12
+ params[optionKey] = value || true;
13
+ break;
14
+ }
15
+ }
16
+ }
17
+ });
18
+
19
+ return params;
20
+ }
@@ -0,0 +1,71 @@
1
+ const ansiCodes = {
2
+ // Modifiers
3
+ reset: '\x1b[0m',
4
+ bold: '\x1b[1m',
5
+ dim: '\x1b[2m',
6
+ italic: '\x1b[3m',
7
+ underline: '\x1b[4m',
8
+ overline: '\x1b[53m',
9
+ inverse: '\x1b[7m',
10
+ hidden: '\x1b[8m',
11
+ strikethrough: '\x1b[9m',
12
+
13
+ // Colors (foreground)
14
+ black: '\x1b[30m',
15
+ red: '\x1b[31m',
16
+ green: '\x1b[32m',
17
+ yellow: '\x1b[33m',
18
+ blue: '\x1b[34m',
19
+ magenta: '\x1b[35m',
20
+ cyan: '\x1b[36m',
21
+ white: '\x1b[37m',
22
+ gray: '\x1b[90m',
23
+ redBright: '\x1b[91m',
24
+ greenBright: '\x1b[92m',
25
+ yellowBright: '\x1b[93m',
26
+ blueBright: '\x1b[94m',
27
+ magentaBright: '\x1b[95m',
28
+ cyanBright: '\x1b[96m',
29
+ whiteBright: '\x1b[97m',
30
+
31
+ // Background colors
32
+ bgBlack: '\x1b[40m',
33
+ bgRed: '\x1b[41m',
34
+ bgGreen: '\x1b[42m',
35
+ bgYellow: '\x1b[43m',
36
+ bgBlue: '\x1b[44m',
37
+ bgMagenta: '\x1b[45m',
38
+ bgCyan: '\x1b[46m',
39
+ bgWhite: '\x1b[47m',
40
+ bgGray: '\x1b[100m',
41
+ bgRedBright: '\x1b[101m',
42
+ bgGreenBright: '\x1b[102m',
43
+ bgYellowBright: '\x1b[103m',
44
+ bgBlueBright: '\x1b[104m',
45
+ bgMagentaBright: '\x1b[105m',
46
+ bgCyanBright: '\x1b[106m',
47
+ bgWhiteBright: '\x1b[107m'
48
+ };
49
+
50
+ function createStyler(appliedStyles = []) {
51
+ return new Proxy(() => {}, {
52
+ get(_target, prop) {
53
+ if (prop === 'toString') {
54
+ return () => appliedStyles.join('') + '%s' + ansiCodes.reset;
55
+ }
56
+
57
+ if (prop in ansiCodes) {
58
+ return createStyler([...appliedStyles, ansiCodes[prop]]);
59
+ }
60
+
61
+ return undefined;
62
+ },
63
+
64
+ apply(_target, _thisArg, args) {
65
+ const text = args[0] || '';
66
+ return appliedStyles.join('') + text + ansiCodes.reset;
67
+ }
68
+ });
69
+ }
70
+
71
+ export const style = createStyler();