-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcommitor.js
More file actions
365 lines (319 loc) · 11.6 KB
/
Copy pathcommitor.js
File metadata and controls
365 lines (319 loc) · 11.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
#!/usr/bin/env node
/**
* Commitor — Wizard-of-Oz prototype
*
* Reads your git diff, sends it to Gemini, and prints a proposed set of
* logical, atomic commit groupings with conventional commit messages.
*
* This is a PROTOTYPE. It never runs `git add` or `git commit` for you —
* it only prints suggestions so you can react to the output quality.
*
* Usage:
* export GEMINI_API_KEY=AIza...
* node commitor.js # analyzes unstaged + staged changes
* node commitor.js --staged # only staged changes
* node commitor.js --file diff.txt # analyze a diff from a file instead of git
*/
const { execSync } = require("child_process");
const fs = require("fs");
const path = require("path");
const https = require("https");
const readline = require("readline");
const os = require("os");
const MODEL = "gemini-3.7-flash";
const CONFIG_DIR = path.join(os.homedir(), ".commitor");
const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
let GEMINI_API_KEY = process.env.GEMINI_API_KEY || loadApiKey();
function loadApiKey() {
try {
if (fs.existsSync(CONFIG_FILE)) {
const config = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8"));
return config.apiKey || null;
}
} catch (err) {
// Silently ignore config read errors
}
return null;
}
function saveApiKey(apiKey) {
try {
if (!fs.existsSync(CONFIG_DIR)) {
fs.mkdirSync(CONFIG_DIR, { recursive: true });
}
fs.writeFileSync(CONFIG_FILE, JSON.stringify({ apiKey }, null, 2));
} catch (err) {
throw new Error(`Failed to save API key: ${err.message}`);
}
}
function getGitDiff({ stagedOnly, filePath }) {
if (filePath) {
return fs.readFileSync(filePath, "utf8");
}
try {
if (stagedOnly) {
return execSync("git diff --staged", { maxBuffer: 1024 * 1024 * 20 }).toString();
}
// Combine staged + unstaged so the tool sees everything not yet committed
const staged = execSync("git diff --staged", { maxBuffer: 1024 * 1024 * 20 }).toString();
const unstaged = execSync("git diff", { maxBuffer: 1024 * 1024 * 20 }).toString();
return staged + "\n" + unstaged;
} catch (err) {
console.error("Failed to read git diff. Are you inside a git repo?");
console.error(err.message);
process.exit(1);
}
}
function parseFilesFromDiff(diffText) {
// Extract file paths from diff header lines (e.g., "diff --git a/path b/path")
const files = [];
const lines = diffText.split("\n");
for (const line of lines) {
if (line.startsWith("diff --git")) {
// Extract b/path from "diff --git a/path b/path"
const match = line.match(/b\/(.+)$/);
if (match) {
files.push(match[1]);
}
}
}
return [...new Set(files)]; // Remove duplicates
}
function getFullFileContents(files) {
// Try to read each file; skip if it doesn't exist or can't be read
const contents = {};
for (const file of files) {
try {
contents[file] = fs.readFileSync(file, "utf8");
} catch (err) {
// File doesn't exist (probably deleted) — skip it
}
}
return contents;
}
function buildPrompt(diffText, fullFileContents = null, options = {}) {
const isSquishedMode = options.squashMerge || false;
let instruction = isSquishedMode
? "Group all distinct changes into EXACTLY ONE commit. Combine all concerns (unless truly incompatible) under a single message."
: "Group the changed hunks/files into separate commits by concern. Prefer smaller, atomic, single-purpose commits over one giant commit.";
let contentSection = "";
if (fullFileContents && Object.keys(fullFileContents).length > 0) {
contentSection = "\n\n## Full file contents (for context):\n\n";
for (const [file, content] of Object.entries(fullFileContents)) {
contentSection += `### File: ${file}\n\`\`\`\n${content}\n\`\`\`\n\n`;
}
}
return `You are Commitor, a tool that groups a messy git diff into clean, logical, atomic commits.
Given the diff below, do the following:
1. Identify distinct logical concerns in the diff (e.g. a bug fix, a new feature, a refactor, a style/formatting change, unrelated tweaks).
2. ${instruction} If two changes are genuinely unrelated (e.g. an auth fix and a CSS tweak), they MUST be in separate commits, even if they touch the same file.
3. For each proposed commit, write a Conventional Commits style message (feat:, fix:, chore:, refactor:, style:, docs:, test:) with a short imperative summary line, and 1-2 bullet points describing what changed if useful.
4. Explicitly flag any case where unrelated changes appear bundled together in a way that could confuse a future reader.
Respond ONLY in this exact JSON format, no preamble, no markdown fences:
{
"commits": [
{
"message": "fix: correct token refresh race condition",
"files": ["src/auth/token.ts"],
"reasoning": "One-sentence reasoning for why these changes belong together.",
"bundling_warning": null
}
],
"summary": "One or two sentence overview of what this diff does as a whole."
}
If you detect unrelated changes that a naive tool might bundle together, set "bundling_warning" to a short string explaining the risk, otherwise set it to null.
## Diff:
\`\`\`diff
${diffText}
\`\`\`${contentSection}`;
}
function groupFilesLocally(diffText) {
// Parse files from diff and group by top-level directory
const files = parseFilesFromDiff(diffText);
const groups = {};
for (const file of files) {
const topDir = file.split("/")[0];
if (!groups[topDir]) {
groups[topDir] = [];
}
groups[topDir].push(file);
}
// Generate commits for each group
const commits = [];
for (const [dir, fileList] of Object.entries(groups)) {
commits.push({
message: `chore: update ${fileList.length} file${fileList.length > 1 ? "s" : ""} in ${dir}`,
files: fileList,
reasoning: `Grouped ${fileList.length} file${fileList.length > 1 ? "s" : ""} from the ${dir}/ directory.`,
bundling_warning: null,
});
}
return {
commits,
summary: `Local grouping: ${commits.length} logical group${commits.length > 1 ? "s" : ""} by top-level directory.`,
};
}
function callGemini(promptText) {
return new Promise((resolve, reject) => {
const body = JSON.stringify({
contents: [{ role: "user", parts: [{ text: promptText }] }],
generationConfig: {
maxOutputTokens: 4000,
temperature: 0.2,
},
});
const options = {
hostname: "generativelanguage.googleapis.com",
path: `/v1beta/models/${MODEL}:generateContent`,
method: "POST",
headers: {
"Content-Type": "application/json",
"x-goog-api-key": GEMINI_API_KEY,
"Content-Length": Buffer.byteLength(body),
},
};
const req = https.request(options, (res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => {
if (res.statusCode !== 200) {
reject(new Error(`API error ${res.statusCode}: ${data}`));
return;
}
try {
const parsed = JSON.parse(data);
const candidate = parsed.candidates && parsed.candidates[0];
if (!candidate) {
reject(new Error(`No candidates returned: ${data}`));
return;
}
const text = candidate.content.parts.map((p) => p.text || "").join("\n");
resolve(text);
} catch (e) {
reject(e);
}
});
});
req.on("error", reject);
req.write(body);
req.end();
});
}
function extractJson(text) {
// Strip markdown fences if the model added them anyway
const cleaned = text.replace(/```json|```/g, "").trim();
return JSON.parse(cleaned);
}
function printReport(result, diffStats) {
const divider = "─".repeat(60);
console.log("\n" + divider);
console.log(" COMMITOR — Proposed Commit Grouping");
console.log(divider);
console.log(`\n${result.summary}\n`);
console.log(`Detected ${result.commits.length} logical commit(s) from your diff.\n`);
result.commits.forEach((commit, i) => {
console.log(divider);
console.log(`Commit ${i + 1}/${result.commits.length}`);
console.log(divider);
console.log(` Message : ${commit.message}`);
console.log(` Files : ${commit.files.join(", ")}`);
console.log(` Why : ${commit.reasoning}`);
if (commit.bundling_warning) {
console.log(` ⚠ Warning: ${commit.bundling_warning}`);
}
console.log("");
});
console.log(divider);
console.log("This is a prototype — no git commands were run.");
console.log("Review the groupings above and tell Commitor what's wrong or right.");
console.log(divider + "\n");
}
function connectCommand() {
// Prompt for API key
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
rl.question("Enter your Gemini API key: ", (apiKey) => {
rl.close();
if (!apiKey || !apiKey.trim()) {
console.error("API key cannot be empty.");
process.exit(1);
}
try {
saveApiKey(apiKey.trim());
console.log("\n✓ API key saved to ~/.commitor/config.json");
console.log("You can now run 'commitor commit' in any git repository.");
} catch (err) {
console.error(`Failed to save API key: ${err.message}`);
process.exit(1);
}
});
}
async function commitCommand(args) {
// Parse commit-specific flags
const stagedOnly = args.includes("--staged");
const fileIdx = args.indexOf("--file");
const filePath = fileIdx !== -1 ? args[fileIdx + 1] : null;
const isFast = args.includes("--fast");
const isNoAI = args.includes("--no-ai");
const isSquashMerge = args.includes("--squash-merge");
if (!isNoAI && !GEMINI_API_KEY) {
console.error("Missing Gemini API key.");
console.error("Run: commitor connect");
console.error("(Or set GEMINI_API_KEY environment variable)");
process.exit(1);
}
const diffText = getGitDiff({ stagedOnly, filePath });
if (!diffText || !diffText.trim()) {
console.log("No changes detected. Nothing to group.");
process.exit(0);
}
// --no-ai mode: group files locally, no API call
if (isNoAI) {
console.log("Grouping files locally (no API call)...\n");
const result = groupFilesLocally(diffText);
printReport(result, diffText);
return;
}
// Default mode (full context) or --fast mode
let result;
if (isFast) {
console.log("Analyzing your diff with Gemini (fast mode, diff hunks only)...\n");
const prompt = buildPrompt(diffText, null, { squashMerge: isSquashMerge });
const raw = await callGemini(prompt);
result = extractJson(raw);
} else {
console.log("Analyzing your diff with Gemini (full context mode, reading file contents)...\n");
const files = parseFilesFromDiff(diffText);
const fullContents = getFullFileContents(files);
const prompt = buildPrompt(diffText, fullContents, { squashMerge: isSquashMerge });
const raw = await callGemini(prompt);
result = extractJson(raw);
}
printReport(result, diffText);
}
async function main() {
const args = process.argv.slice(2);
let subcommand = "commit"; // default subcommand
// Check if first arg is a subcommand (doesn't start with --)
if (args.length > 0 && !args[0].startsWith("--")) {
subcommand = args[0];
args.shift(); // remove subcommand from args
}
try {
if (subcommand === "commit") {
await commitCommand(args);
} else if (subcommand === "connect") {
connectCommand();
} else {
console.error(`Unknown subcommand: ${subcommand}`);
console.error("Available subcommands: commit, connect");
process.exit(1);
}
} catch (err) {
console.error("Something went wrong:");
console.error(err.message);
process.exit(1);
}
}
main();