neuro-commit 0.1.2 → 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 +54 -18
- package/bin/neuro-commit.js +79 -125
- package/package.json +4 -2
- package/src/ai.js +234 -0
- package/src/aiCommit.js +311 -0
- package/src/commit.js +51 -166
- package/src/config.js +68 -0
- package/src/git.js +98 -18
- package/src/settings.js +119 -0
- package/src/ui.js +164 -0
package/src/git.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
const { execSync } = require("child_process");
|
|
1
|
+
const { execSync, execFileSync } = require("child_process");
|
|
2
|
+
const fs = require("fs");
|
|
3
|
+
const path = require("path");
|
|
4
|
+
const os = require("os");
|
|
2
5
|
|
|
3
6
|
/**
|
|
4
7
|
* Lock file patterns — diffs for these are too large and noisy.
|
|
@@ -60,22 +63,9 @@ function getStagedDiff() {
|
|
|
60
63
|
if (nonLockFiles.length === 0) return "";
|
|
61
64
|
|
|
62
65
|
try {
|
|
63
|
-
return
|
|
64
|
-
`git diff --staged -- ${nonLockFiles.map((f) => `"${f}"`).join(" ")}`,
|
|
65
|
-
{ encoding: "utf-8", maxBuffer: 10 * 1024 * 1024 },
|
|
66
|
-
).trim();
|
|
67
|
-
} catch {
|
|
68
|
-
return "";
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Returns diffstat summary for staged changes.
|
|
74
|
-
*/
|
|
75
|
-
function getStagedStats() {
|
|
76
|
-
try {
|
|
77
|
-
return execSync("git diff --staged --stat", {
|
|
66
|
+
return execFileSync("git", ["diff", "--staged", "--", ...nonLockFiles], {
|
|
78
67
|
encoding: "utf-8",
|
|
68
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
79
69
|
}).trim();
|
|
80
70
|
} catch {
|
|
81
71
|
return "";
|
|
@@ -137,13 +127,103 @@ function getStagedNumstat() {
|
|
|
137
127
|
}
|
|
138
128
|
}
|
|
139
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Get the current branch name.
|
|
132
|
+
*/
|
|
133
|
+
function getCurrentBranch() {
|
|
134
|
+
try {
|
|
135
|
+
return execSync("git rev-parse --abbrev-ref HEAD", {
|
|
136
|
+
encoding: "utf-8",
|
|
137
|
+
stdio: "pipe",
|
|
138
|
+
}).trim();
|
|
139
|
+
} catch {
|
|
140
|
+
return "unknown";
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Get recent commit messages for context.
|
|
146
|
+
* @param {number} count - Number of recent commits to retrieve.
|
|
147
|
+
*/
|
|
148
|
+
function getRecentCommits(count = 5) {
|
|
149
|
+
try {
|
|
150
|
+
const raw = execSync(
|
|
151
|
+
`git log --oneline -${count} --no-merges --format="%s"`,
|
|
152
|
+
{
|
|
153
|
+
encoding: "utf-8",
|
|
154
|
+
stdio: "pipe",
|
|
155
|
+
},
|
|
156
|
+
).trim();
|
|
157
|
+
|
|
158
|
+
if (!raw) return [];
|
|
159
|
+
return raw.split("\n").filter(Boolean);
|
|
160
|
+
} catch {
|
|
161
|
+
return [];
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Execute a git commit with the given message.
|
|
167
|
+
* @param {string} message - The commit message.
|
|
168
|
+
* @returns {{ success: boolean, hash?: string, error?: string }}
|
|
169
|
+
*/
|
|
170
|
+
function gitCommit(message) {
|
|
171
|
+
try {
|
|
172
|
+
const tmpFile = path.join(
|
|
173
|
+
os.tmpdir(),
|
|
174
|
+
`neuro-commit-msg-${Date.now()}.txt`,
|
|
175
|
+
);
|
|
176
|
+
fs.writeFileSync(tmpFile, message, "utf-8");
|
|
177
|
+
|
|
178
|
+
execSync(`git commit -F "${tmpFile}"`, {
|
|
179
|
+
encoding: "utf-8",
|
|
180
|
+
stdio: "pipe",
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// Get the short hash of the new commit
|
|
184
|
+
const hash = execSync("git rev-parse --short HEAD", {
|
|
185
|
+
encoding: "utf-8",
|
|
186
|
+
stdio: "pipe",
|
|
187
|
+
}).trim();
|
|
188
|
+
|
|
189
|
+
// Clean up temp file
|
|
190
|
+
try {
|
|
191
|
+
fs.unlinkSync(tmpFile);
|
|
192
|
+
} catch {
|
|
193
|
+
// ignore cleanup errors
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return { success: true, hash };
|
|
197
|
+
} catch (err) {
|
|
198
|
+
return { success: false, error: err.message };
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Push the current branch to origin.
|
|
204
|
+
* @returns {{ success: boolean, error?: string }}
|
|
205
|
+
*/
|
|
206
|
+
function gitPush() {
|
|
207
|
+
try {
|
|
208
|
+
execSync("git push", {
|
|
209
|
+
encoding: "utf-8",
|
|
210
|
+
stdio: "pipe",
|
|
211
|
+
});
|
|
212
|
+
return { success: true };
|
|
213
|
+
} catch (err) {
|
|
214
|
+
return { success: false, error: err.message };
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
140
218
|
module.exports = {
|
|
141
219
|
getStagedFiles,
|
|
142
220
|
getStagedDiff,
|
|
143
|
-
getStagedStats,
|
|
144
221
|
getStagedNumstat,
|
|
145
222
|
isGitRepo,
|
|
146
223
|
isLockFile,
|
|
147
224
|
statusLabel,
|
|
148
|
-
|
|
225
|
+
getCurrentBranch,
|
|
226
|
+
getRecentCommits,
|
|
227
|
+
gitCommit,
|
|
228
|
+
gitPush,
|
|
149
229
|
};
|
package/src/settings.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
const { loadConfig, updateConfig } = require("./config");
|
|
2
|
+
const { RESET, BOLD, DIM, GREEN, showSelectMenu } = require("./ui");
|
|
3
|
+
|
|
4
|
+
const LANGUAGES = [
|
|
5
|
+
{ label: "English", value: "en" },
|
|
6
|
+
{ label: "Ukrainian", value: "uk" },
|
|
7
|
+
{ label: "Russian", value: "ru" },
|
|
8
|
+
{ label: "German", value: "de" },
|
|
9
|
+
{ label: "French", value: "fr" },
|
|
10
|
+
{ label: "Spanish", value: "es" },
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
async function runSettingsMenu() {
|
|
14
|
+
let config = loadConfig();
|
|
15
|
+
|
|
16
|
+
while (true) {
|
|
17
|
+
console.clear();
|
|
18
|
+
console.log(`\n${BOLD}Settings${RESET}\n`);
|
|
19
|
+
|
|
20
|
+
const langLabel =
|
|
21
|
+
LANGUAGES.find((l) => l.value === config.language)?.label ||
|
|
22
|
+
config.language;
|
|
23
|
+
const histLabel =
|
|
24
|
+
config.commitHistory === 0 ? "Off" : `${config.commitHistory}`;
|
|
25
|
+
|
|
26
|
+
const choice = await showSelectMenu("Setting:", [
|
|
27
|
+
{ label: `Language: ${langLabel}` },
|
|
28
|
+
{ label: `Max length: ${config.maxLength}` },
|
|
29
|
+
{ label: `Auto-commit: ${config.autoCommit ? "ON" : "OFF"}` },
|
|
30
|
+
{ label: `Auto-push: ${config.autoPush ? "ON" : "OFF"}` },
|
|
31
|
+
{ label: `Commit history: ${histLabel}` },
|
|
32
|
+
{
|
|
33
|
+
label: `Dev mode: ${config.devMode ? "ON" : "OFF"}`,
|
|
34
|
+
description: "store responses",
|
|
35
|
+
},
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
switch (choice) {
|
|
39
|
+
case -1:
|
|
40
|
+
return;
|
|
41
|
+
|
|
42
|
+
case 0: {
|
|
43
|
+
console.clear();
|
|
44
|
+
console.log("");
|
|
45
|
+
const langChoice = await showSelectMenu(
|
|
46
|
+
"Language:",
|
|
47
|
+
LANGUAGES.map((l) => ({ label: l.label })),
|
|
48
|
+
);
|
|
49
|
+
if (langChoice >= 0 && langChoice < LANGUAGES.length) {
|
|
50
|
+
config = updateConfig("language", LANGUAGES[langChoice].value);
|
|
51
|
+
console.log(
|
|
52
|
+
`${GREEN}✓${RESET} ${BOLD}${LANGUAGES[langChoice].label}${RESET}`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
case 1: {
|
|
59
|
+
console.clear();
|
|
60
|
+
console.log("");
|
|
61
|
+
const lengths = [50, 72, 100];
|
|
62
|
+
const lenChoice = await showSelectMenu("Max title length:", [
|
|
63
|
+
{ label: "50", description: "Git recommended" },
|
|
64
|
+
{ label: "72", description: "Default" },
|
|
65
|
+
{ label: "100", description: "Extended" },
|
|
66
|
+
]);
|
|
67
|
+
if (lenChoice >= 0 && lenChoice < lengths.length) {
|
|
68
|
+
config = updateConfig("maxLength", lengths[lenChoice]);
|
|
69
|
+
console.log(
|
|
70
|
+
`${GREEN}✓${RESET} ${BOLD}${lengths[lenChoice]}${RESET} chars`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
case 2:
|
|
77
|
+
config = updateConfig("autoCommit", !config.autoCommit);
|
|
78
|
+
console.log(
|
|
79
|
+
`${GREEN}✓${RESET} Auto-commit ${config.autoCommit ? `${GREEN}ON${RESET}` : `${DIM}OFF${RESET}`}`,
|
|
80
|
+
);
|
|
81
|
+
break;
|
|
82
|
+
|
|
83
|
+
case 3:
|
|
84
|
+
config = updateConfig("autoPush", !config.autoPush);
|
|
85
|
+
console.log(
|
|
86
|
+
`${GREEN}✓${RESET} Auto-push ${config.autoPush ? `${GREEN}ON${RESET}` : `${DIM}OFF${RESET}`}`,
|
|
87
|
+
);
|
|
88
|
+
break;
|
|
89
|
+
|
|
90
|
+
case 4: {
|
|
91
|
+
console.clear();
|
|
92
|
+
console.log("");
|
|
93
|
+
const histOptions = [0, 3, 5, 10];
|
|
94
|
+
const histChoice = await showSelectMenu("Recent commits for context:", [
|
|
95
|
+
{ label: "Off" },
|
|
96
|
+
{ label: "3" },
|
|
97
|
+
{ label: "5" },
|
|
98
|
+
{ label: "10" },
|
|
99
|
+
]);
|
|
100
|
+
if (histChoice >= 0 && histChoice < histOptions.length) {
|
|
101
|
+
config = updateConfig("commitHistory", histOptions[histChoice]);
|
|
102
|
+
console.log(
|
|
103
|
+
`${GREEN}✓${RESET} ${BOLD}${histOptions[histChoice] || "Off"}${RESET}`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
case 5:
|
|
110
|
+
config = updateConfig("devMode", !config.devMode);
|
|
111
|
+
console.log(
|
|
112
|
+
`${GREEN}✓${RESET} Dev mode ${config.devMode ? `${GREEN}ON${RESET}` : `${DIM}OFF${RESET}`}`,
|
|
113
|
+
);
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
module.exports = { runSettingsMenu };
|
package/src/ui.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
const readline = require("readline");
|
|
2
|
+
|
|
3
|
+
const RESET = "\x1b[0m";
|
|
4
|
+
const BOLD = "\x1b[1m";
|
|
5
|
+
const DIM = "\x1b[2m";
|
|
6
|
+
const GREEN = "\x1b[32m";
|
|
7
|
+
const YELLOW = "\x1b[33m";
|
|
8
|
+
const RED = "\x1b[31m";
|
|
9
|
+
const CYAN = "\x1b[36m";
|
|
10
|
+
const HIDE_CURSOR = "\x1b[?25l";
|
|
11
|
+
const SHOW_CURSOR = "\x1b[?25h";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Interactive select menu. Returns selected index, or -1 on Escape.
|
|
15
|
+
*/
|
|
16
|
+
function showSelectMenu(prompt, items) {
|
|
17
|
+
return new Promise((resolve) => {
|
|
18
|
+
let selected = 0;
|
|
19
|
+
|
|
20
|
+
if (!process.stdin.isTTY) {
|
|
21
|
+
console.error("Error: interactive mode requires a TTY terminal.");
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
process.stdout.write(HIDE_CURSOR);
|
|
26
|
+
process.stdout.write("\n".repeat(items.length + 2));
|
|
27
|
+
render();
|
|
28
|
+
|
|
29
|
+
readline.emitKeypressEvents(process.stdin);
|
|
30
|
+
if (!process.stdin.isRaw) process.stdin.setRawMode(true);
|
|
31
|
+
process.stdin.resume();
|
|
32
|
+
|
|
33
|
+
function render() {
|
|
34
|
+
process.stdout.write(`\x1b[${items.length + 2}A`);
|
|
35
|
+
process.stdout.write(`${BOLD}${prompt}${RESET}\x1b[K\n\x1b[K\n`);
|
|
36
|
+
|
|
37
|
+
for (let i = 0; i < items.length; i++) {
|
|
38
|
+
const { label, description } = items[i];
|
|
39
|
+
const desc = description ? ` ${DIM}— ${description}${RESET}` : "";
|
|
40
|
+
if (i === selected) {
|
|
41
|
+
process.stdout.write(
|
|
42
|
+
` ${GREEN}❯${RESET} ${BOLD}${label}${RESET}${desc}\x1b[K\n`,
|
|
43
|
+
);
|
|
44
|
+
} else {
|
|
45
|
+
process.stdout.write(` ${label}${desc}\x1b[K\n`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
process.stdout.write("\x1b[J");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function onKeyPress(_str, key) {
|
|
52
|
+
if (!key) return;
|
|
53
|
+
|
|
54
|
+
if ((key.ctrl && key.name === "c") || key.name === "q") {
|
|
55
|
+
cleanup();
|
|
56
|
+
process.exit(0);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (key.name === "escape") {
|
|
60
|
+
cleanup();
|
|
61
|
+
resolve(-1);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (key.name === "up") {
|
|
66
|
+
selected = (selected - 1 + items.length) % items.length;
|
|
67
|
+
render();
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (key.name === "down") {
|
|
72
|
+
selected = (selected + 1) % items.length;
|
|
73
|
+
render();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (key.name === "return") {
|
|
78
|
+
cleanup();
|
|
79
|
+
resolve(selected);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function cleanup() {
|
|
84
|
+
process.stdin.removeListener("keypress", onKeyPress);
|
|
85
|
+
if (process.stdin.isRaw) process.stdin.setRawMode(false);
|
|
86
|
+
process.stdin.pause();
|
|
87
|
+
process.stdout.write(SHOW_CURSOR);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
process.stdin.on("keypress", onKeyPress);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Yes/no confirmation prompt.
|
|
96
|
+
*/
|
|
97
|
+
function confirm(prompt, defaultYes = true) {
|
|
98
|
+
return new Promise((resolve) => {
|
|
99
|
+
const rl = readline.createInterface({
|
|
100
|
+
input: process.stdin,
|
|
101
|
+
output: process.stdout,
|
|
102
|
+
});
|
|
103
|
+
const hint = defaultYes ? "Y/n" : "y/N";
|
|
104
|
+
rl.question(`${prompt} ${DIM}(${hint})${RESET} `, (answer) => {
|
|
105
|
+
rl.close();
|
|
106
|
+
const t = answer.trim().toLowerCase();
|
|
107
|
+
resolve(t === "" ? defaultYes : t === "y" || t === "yes");
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Free-form text input.
|
|
114
|
+
*/
|
|
115
|
+
/**
|
|
116
|
+
* Format number with space separators.
|
|
117
|
+
*/
|
|
118
|
+
function formatNumber(num) {
|
|
119
|
+
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, " ");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Simple spinner for async operations.
|
|
124
|
+
*/
|
|
125
|
+
function createSpinner(text) {
|
|
126
|
+
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
127
|
+
let i = 0;
|
|
128
|
+
let interval = null;
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
start() {
|
|
132
|
+
process.stdout.write(HIDE_CURSOR);
|
|
133
|
+
interval = setInterval(() => {
|
|
134
|
+
process.stdout.write(`\r${CYAN}${frames[i]}${RESET} ${text}\x1b[K`);
|
|
135
|
+
i = (i + 1) % frames.length;
|
|
136
|
+
}, 80);
|
|
137
|
+
},
|
|
138
|
+
stop(finalText) {
|
|
139
|
+
if (interval) {
|
|
140
|
+
clearInterval(interval);
|
|
141
|
+
interval = null;
|
|
142
|
+
}
|
|
143
|
+
process.stdout.write("\r\x1b[K");
|
|
144
|
+
if (finalText) process.stdout.write(`${finalText}\n`);
|
|
145
|
+
process.stdout.write(SHOW_CURSOR);
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
module.exports = {
|
|
151
|
+
RESET,
|
|
152
|
+
BOLD,
|
|
153
|
+
DIM,
|
|
154
|
+
GREEN,
|
|
155
|
+
YELLOW,
|
|
156
|
+
RED,
|
|
157
|
+
CYAN,
|
|
158
|
+
HIDE_CURSOR,
|
|
159
|
+
SHOW_CURSOR,
|
|
160
|
+
showSelectMenu,
|
|
161
|
+
confirm,
|
|
162
|
+
formatNumber,
|
|
163
|
+
createSpinner,
|
|
164
|
+
};
|