giso
Version:
AI-powered commit message suggestions for your Git workflow. `giso` analyzes your changes and offers intelligent commit message suggestions using cutting-edge AI providers
308 lines (302 loc) • 21.9 kB
JavaScript
import e from "fs";
import t, { dirname as o, resolve as s } from "path";
import { fileURLToPath as n } from "url";
import r from "chalk";
import { execSync as i } from "child_process";
import a from "inquirer";
import c from "os";
import l from "crypto";
import m from "node-fetch";
function g() {
const e2 = c.hostname();
return l.createHash("sha256").update(e2).digest("hex").substring(0, 32);
}
function p(e2) {
const t2 = l.createCipheriv("aes-256-cbc", g(), Buffer.alloc(16, 0));
let o2 = t2.update(e2, "utf8", "hex");
return o2 += t2.final("hex"), o2;
}
function u(e2) {
try {
const t2 = l.createDecipheriv("aes-256-cbc", g(), Buffer.alloc(16, 0));
let o2 = t2.update(e2, "hex", "utf8");
return o2 += t2.final("utf8"), o2;
} catch (e3) {
return console.error("Decryption failed - possibly wrong machine?"), null;
}
}
const d = [{ name: "OpenRouter", key: "openRouter", apiUrl: "https://openrouter.ai/api/v1/chat/completions", modelName: "deepseek/deepseek-r1:free" }, { name: "OpenAI", key: "openai", apiUrl: "https://api.openai.com/v1/chat/completions", modelName: "gpt-4o" }, { name: "Gemini", key: "gemini", apiUrl: "https://generativelanguage.googleapis.com/v1beta/models", modelName: "gemini-2.0-flash" }, { name: "Claude Sonnet", key: "claude", apiUrl: "https://api.anthropic.com/v1/messages", modelName: "claude-3-sonnet-20240229" }, { name: "DeepSeek", key: "deepseek", apiUrl: "https://api.deepseek.com/v1/chat/completions", modelName: "deepseek-chat" }, { name: "locahost (LM Studio)", key: "lmstudio", apiUrl: "http://127.0.0.1:1234/v1/chat/completions", modelName: "meta-llama-3.1-8b-instruct" }], h = t.join(c.homedir(), ".gisoconfig.json");
async function f(e2, t2, o2, s2) {
for (; ; ) {
const { numberInput: n2 } = await a.prompt([{ type: "input", name: "numberInput", message: e2, default: t2, validate: (e3) => {
if (null == e3 || !String(e3).trim()) return true;
const t3 = parseFloat(e3);
return !isNaN(t3) && t3 >= o2 && t3 <= s2 || `Please enter a number between ${o2} and ${s2}`;
} }]);
return null != n2 && String(n2).trim() ? parseFloat(n2) : t2;
}
}
async function y(e2, t2, o2, s2) {
const n2 = (t2 == null ? void 0 : t2.maxSuggestions) || 10, i2 = `You are an expert creative and skilled git commit message generator. Analyze the following git status and file content changes to produce EXACTLY ${n2} distinct commit message suggestions in EXACTLY the following JSON format:
{
"suggestions": [
{
"type": "feat|fix|chore|docs|style|refactor|test",
"message": "concise description",
"description": "optional longer explanation"
}
]
}
Your suggestions must adhere to these enhanced guidelines:
- **Strictly Adhere to Semantic Commit Messages:** Generate at least ${Math.min(n2, 3)} commit messages that **literally** use the Semantic Commit Messages format (e.g., "feat: Implement user authentication"). Ensure the 'type' field is one of the allowed semantic types.
- **Avoid Duplicate Semantic Messages:** Ensure that within the semantic commit message suggestions, each combination of 'type' and 'message' is unique. **Furthermore, ensure that there are no suggestions where the 'type' and 'message' are identical (e.g., avoid both "fix: Resolve issue" and "fix: Resolve issue", or "chore: Update dependencies" and "chore: Update dependencies").**
- **Granular and Specific Fixes:** When suggesting fixes, aim for maximum specificity. If a fix addresses a particular area or component, include it in the scope (e.g., "fix(payment): Resolve issue with double charging users"). Use a general "fix: ..." only for truly broad fixes.
- **Clear Feature Introductions:** Clearly denote new features using the "feat" type (e.g., "feat: Introduce multi-factor authentication").
- **Categorize Maintenance Tasks Accurately:** Utilize the "chore" type for maintenance tasks such as dependency updates, build process modifications, or tooling changes (e.g., "chore: Bump react-router-dom to v6").
- **Provide Plain and Informative Alternatives:** Include plain descriptive commit messages that are still informative and to the point, without strictly following the semantic format (e.g., "Improved logging for API requests").
- ${(t2 == null ? void 0 : t2.funnyCommitMsg) ? "**Include Funny Slang and Emoji Commit Messages:** Add **at least 1** commit messages that are lighthearted, use funny slang, and include **relevant** emojis. The other funny commit message should be distinct in its humor and slang.**" : ""}
- **Order by Relevance:** Sort the suggestions based on their perceived relevance to the changes. The most significant changes (new features, critical fixes) should appear earlier in the list.
- **Maintain Conciseness:** The "message" field should be brief and easy to understand.
- **Use Optional Descriptions for Context:** Employ the "description" field to provide additional context, reasoning, or implementation details when necessary.
- **Strict JSON Output Only:** Return **only** the JSON object as specified. Do not include any surrounding text, code blocks, or explanations.
Git Status:
${o2}
Changed Files Content:
${s2}`;
switch (e2) {
case "openai":
return await async function(e3, t3) {
var _a, _b, _c;
const o3 = u(e3.apiKey);
if (!o3) throw new Error("Failed to decrypt API key");
try {
const s3 = await fetch(e3.apiUrl, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${o3}` }, body: JSON.stringify({ model: e3.modelName, messages: [{ role: "user", content: t3 }], temperature: (e3 == null ? void 0 : e3.temperature) || 0.4, max_tokens: (e3 == null ? void 0 : e3.maxTokens) || 2e3, response_format: { type: "json_object" } }) }), n3 = await s3.json();
if (!s3.ok) throw new Error(`API error: ${((_a = n3.error) == null ? void 0 : _a.message) || s3.statusText}`);
if (!n3.choices || !Array.isArray(n3.choices)) throw new Error("Invalid response format: missing choices array");
const r2 = (_c = (_b = n3.choices[0]) == null ? void 0 : _b.message) == null ? void 0 : _c.content;
if (!r2) throw new Error("No message content in response");
try {
const e4 = JSON.parse(r2);
if (!e4.suggestions) throw new Error("Response JSON missing suggestions property");
return e4.suggestions;
} catch (e4) {
return console.warn("Failed to parse JSON response, returning raw content"), r2;
}
} catch (e4) {
throw console.error("OpenAI API Error:", e4.message), new Error(`Failed to generate suggestions: ${e4.message}`);
}
}(t2, i2);
case "gemini":
return await async function(e3, t3) {
var _a, _b, _c, _d, _e;
const o3 = u(e3.apiKey);
if (!o3) throw new Error("Failed to decrypt Gemini API key");
const s3 = `${e3.apiUrl}/${e3.modelName}:generateContent?key=${o3}`;
try {
const o4 = { contents: [{ parts: [{ text: t3 }] }], generationConfig: { response_mime_type: "application/json", temperature: (e3 == null ? void 0 : e3.temperature) || 0.4, maxOutputTokens: (e3 == null ? void 0 : e3.maxTokens) || 2e3 }, safetySettings: [{ category: "HARM_CATEGORY_DANGEROUS_CONTENT", threshold: "BLOCK_ONLY_HIGH" }] }, n3 = await fetch(s3, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(o4), timeout: 1e4 });
if (!n3.ok) {
const e4 = await n3.json(), t4 = ((_a = e4.error) == null ? void 0 : _a.message) || `HTTP ${n3.status}`;
throw new Error(`Gemini API error: ${t4}`);
}
const r2 = await n3.json();
if (!r2.candidates || !Array.isArray(r2.candidates) || !((_e = (_d = (_c = (_b = r2.candidates[0]) == null ? void 0 : _b.content) == null ? void 0 : _c.parts) == null ? void 0 : _d[0]) == null ? void 0 : _e.text)) throw console.error("Invalid Gemini response structure:", r2), new Error("Received malformed response from Gemini API");
const i3 = r2.candidates[0].content.parts[0].text;
try {
const e4 = JSON.parse(i3);
if (!Array.isArray(e4 == null ? void 0 : e4.suggestions)) throw new Error("Suggestions array missing in response");
return e4.suggestions.map((e5) => ({ type: e5.type || "", message: e5.message || "No message provided", description: e5.description || "" }));
} catch (e4) {
return [{ type: "text", message: i3, description: "Raw Gemini response" }];
}
} catch (e4) {
throw console.error(r.red.bold("Gemini API Failed:"), e4.message), new Error(`Gemini: ${e4.message}`);
}
}(t2, i2);
case "claude":
return await async function(e3, t3) {
var _a, _b;
const o3 = u(e3.apiKey);
if (!o3) throw new Error("Failed to decrypt API key");
const s3 = await fetch(e3.apiUrl, { method: "POST", headers: { "Content-Type": "application/json", "x-api-key": o3, "anthropic-version": "2023-06-01" }, body: JSON.stringify({ model: e3.modelName, temperature: (e3 == null ? void 0 : e3.temperature) || 0.4, max_tokens: (e3 == null ? void 0 : e3.maxTokens) || 2e3, messages: [{ role: "user", content: t3 }], response_format: { type: "json_object" } }) }), n3 = await s3.json();
try {
return JSON.parse((_a = n3.content[0]) == null ? void 0 : _a.text).suggestions;
} catch {
return ((_b = n3.content[0]) == null ? void 0 : _b.text) || "No response from Claude";
}
}(t2, i2);
case "deepseek":
return await async function(e3, t3) {
var _a, _b, _c;
const o3 = u(e3.apiKey);
if (!o3) throw new Error("Failed to decrypt DeepSeek API key");
try {
const s3 = await fetch(e3.apiUrl, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${o3}` }, body: JSON.stringify({ model: e3.modelName, messages: [{ role: "user", content: t3 }], temperature: (e3 == null ? void 0 : e3.temperature) || 0.4, max_tokens: (e3 == null ? void 0 : e3.maxTokens) || 2e3, response_format: { type: "json_object" } }), timeout: 1e4 });
if (!s3.ok) {
const e4 = await s3.json();
throw new Error(((_a = e4.error) == null ? void 0 : _a.message) || `HTTP ${s3.status}`);
}
const n3 = await s3.json();
if (!n3.choices || !((_c = (_b = n3.choices[0]) == null ? void 0 : _b.message) == null ? void 0 : _c.content)) throw console.error("Invalid DeepSeek response:", n3), new Error("Invalid response structure from DeepSeek API");
const r2 = n3.choices[0].message.content;
try {
const e4 = JSON.parse(r2);
if (!Array.isArray(e4.suggestions)) throw new Error("Expected suggestions array in response");
return e4.suggestions;
} catch (e4) {
return [{ type: "text", message: r2, description: "Raw DeepSeek response" }];
}
} catch (e4) {
throw console.error(r.red("DeepSeek API Error:"), e4.message), new Error(`DeepSeek: ${e4.message}`);
}
}(t2, i2);
case "lmstudio":
return await async function(e3, t3) {
var _a, _b, _c;
const o3 = u(e3.apiKey);
if (!o3) throw new Error("Failed to decrypt API key");
try {
const s3 = await m(e3.apiUrl, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${o3}` }, body: JSON.stringify({ model: e3.modelName, messages: [{ role: "user", content: t3 }], temperature: (e3 == null ? void 0 : e3.temperature) || 0.4, max_tokens: (e3 == null ? void 0 : e3.maxTokens) || 2e3, stream: false }) }), n3 = await s3.json();
if (!s3.ok) throw new Error(`API error: ${((_a = n3.error) == null ? void 0 : _a.message) || s3.statusText}`);
if (!n3.choices || !Array.isArray(n3.choices)) throw new Error("Invalid response format: missing choices array");
const r2 = (_c = (_b = n3.choices[0]) == null ? void 0 : _b.message) == null ? void 0 : _c.content;
if (!r2) throw new Error("No message content in response");
try {
const e4 = JSON.parse(r2);
if (!e4.suggestions) throw new Error("Response JSON missing suggestions property");
return e4.suggestions;
} catch (e4) {
return console.warn("Failed to parse JSON response, returning raw content"), r2;
}
} catch (e4) {
throw console.error("OpenAI API Error:", e4.message), new Error(`Failed to generate suggestions: ${e4.message}`);
}
}(t2, i2);
case "openRouter":
return await async function(e3, t3) {
var _a, _b, _c;
const o3 = u(e3.apiKey);
if (!o3) throw new Error("Failed to decrypt API key");
try {
const s3 = await m(e3.apiUrl, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${o3}` }, body: JSON.stringify({ model: e3.modelName, messages: [{ role: "user", content: t3 }], temperature: (e3 == null ? void 0 : e3.temperature) || 0.4, max_tokens: (e3 == null ? void 0 : e3.maxTokens) || 2e3 }) }), n3 = await s3.json();
if (!s3.ok) throw new Error(`API error: ${((_a = n3.error) == null ? void 0 : _a.message) || s3.statusText}`);
if (!n3.choices || !Array.isArray(n3.choices)) throw new Error("Invalid response format: missing choices array");
const r2 = (_c = (_b = n3.choices[0]) == null ? void 0 : _b.message) == null ? void 0 : _c.content;
if (!r2) throw new Error("No message content in response");
try {
const e4 = JSON.parse(r2);
if (!e4.suggestions) throw new Error("Response JSON missing suggestions property");
return e4.suggestions;
} catch (e4) {
return console.warn("Failed to parse JSON response, returning raw content"), r2;
}
} catch (e4) {
throw console.error("OpenAI API Error:", e4.message), new Error(`Failed to generate suggestions: ${e4.message}`);
}
}(t2, i2);
default:
throw new Error(`Unsupported provider: ${e2}`);
}
}
const w = s(o(n(import.meta.url)), "..", "package.json"), v = JSON.parse(e.readFileSync(w, "utf8")), { version: S } = v, x = process.argv.slice(2);
if (["--version", "-v"].includes(x[0])) console.log(`giso version ${S}`), process.exit(0);
else if (["--init", "-i"].includes(x[0])) !async function() {
try {
console.log("Select an AI provider:");
const t2 = d.map((e2, t3) => ({ name: `${t3 + 1}. ${e2.name}`, value: t3 })), { selectedProviderIndex: o2 } = await a.prompt([{ type: "list", name: "selectedProviderIndex", message: "Choose a provider:", choices: t2, pageSize: 10 }]), { userChoiceModelName: s2 } = await a.prompt([{ type: "text", name: "userChoiceModelName", default: d[o2].modelName, message: `Enter the model name (Default ${d[o2].modelName}):` }]);
void 0 === o2 && (console.error("Error: No provider selected."), console.error("Run giso --init again."), process.exit(1));
const n2 = { providers: {} }, r2 = d[o2], { apiKey: i2 } = await a.prompt([{ type: "password", name: "apiKey", message: `Enter your ${r2.name} API key:`, mask: "*", validate: (e2) => !(null == e2 || !String(e2).trim()) || "API key cannot be empty" }]), c2 = await f(`Enter temperature for ${r2.name} (0.0-2.0, default 0.4):`, 0.4, 0, 2), l2 = await f(`Enter max tokens for ${r2.name} (1-10000, default 2000):`, 2e3, 1, 1e4), m2 = await f(`Enter max suggestions for ${r2.name} (1-100, default 10):`, 10, 1, 100), { funnyCommitMsg: g2 } = await a.prompt([{ type: "boolean", name: "funnyCommitMsg", default: true, message: "Do you want funny commit messages? Default: true" }]);
n2.providers[r2.key] = { apiKey: p(i2), apiUrl: r2.apiUrl, modelName: s2 || r2.modelName, maxSuggestions: m2, funnyCommitMsg: g2, temperature: c2, maxTokens: l2 }, e.writeFileSync(h, JSON.stringify(n2, null, 2)), console.log(`
Configuration encrypted and saved to ${h}`), process.exit(0);
} catch (e2) {
console.error("\nInitialization failed:", e2.message), process.exit(1);
}
}();
else if (["--offer", "-o"].includes(x[0])) !async function() {
var _a;
try {
e.existsSync(h) || (console.error("Error: Configuration file not found. Run giso --init first."), process.exit(1));
const o2 = JSON.parse(e.readFileSync(h, "utf8"));
let s2, n2;
o2.providers && ((_a = Object.keys(o2.providers)) == null ? void 0 : _a.length) || (console.error("Error: No providers configured. Run giso --init first."), process.exit(1));
try {
s2 = i("git status --porcelain").toString().trim(), s2 || (console.log("No changes detected in git working tree."), process.exit(0)), n2 = i("git diff --cached").toString().trim(), n2 || (n2 = i("git diff").toString().trim(), n2 && console.log(r.yellow("Using unstaged changes for commit message generation.")));
} catch (e2) {
console.error("Error getting git info:", e2.message), process.exit(1);
}
console.log("\nGenerating commit messages...\n");
let l2 = [];
for (const [e2, t2] of Object.entries(o2.providers)) {
const o3 = d.find((t3) => t3.key === e2);
if (o3) try {
const i2 = await y(e2, t2, s2, n2);
console.log(r.bold.blue(`
=== ${o3.name} Suggestions ===`)), Array.isArray(i2) ? i2.forEach((e3) => {
const t3 = e3.type ? `${e3.type}: ${e3.message}` : e3.message;
console.log(r.bold(`• ${t3}`)), e3.description && console.log(` ${e3.description}`), console.log(), l2.push({ name: `${o3.name}: ${t3}`, value: e3, provider: o3.name });
}) : l2.push({ name: `${o3.name}: ${i2}`, value: { message: i2 }, provider: o3.name });
} catch (e3) {
console.error(r.red(`Error with ${o3.name}:`), e3.message);
}
}
(l2 == null ? void 0 : l2.length) || (console.log(r.yellow("No suggestions were generated.")), process.exit(0)), l2.push({ name: r.gray("Exit without committing"), value: null });
const { selectedMessages: m2 } = await a.prompt([{ type: "checkbox", name: "selectedMessages", message: "Select commit message(s):", choices: l2, pageSize: 15, loop: false }]);
m2 && (m2 == null ? void 0 : m2.length) || (console.log(r.gray("\nNo commit messages selected. Exiting.")), process.exit(0));
let g2 = m2.filter(Boolean).map((e2) => `${e2.type ? `${e2.type}: ` : ""}${e2.message}`.trim()).join("\n").trim();
const { commitAction: p2 } = await a.prompt([{ type: "list", name: "commitAction", message: "Commit action:", choices: [{ name: "Stage all changes and commit", value: "commit" }, { name: "Edit message before committing", value: "edit" }, { name: "Cancel", value: "cancel" }], default: "commit" }]);
if ("cancel" === p2 && (console.log(r.gray("\nCommit cancelled.")), process.exit(0)), "edit" === p2) {
const o3 = t.join(c.tmpdir(), `giso-commit-${Date.now()}.txt`);
e.writeFileSync(o3, g2);
const s3 = process.env.GIT_EDITOR || process.env.VISUAL || process.env.EDITOR || "vi";
try {
i(`${s3} "${o3}"`, { stdio: "inherit" }), g2 = e.readFileSync(o3, "utf8").trim();
} finally {
try {
e.unlinkSync(o3);
} catch {
}
}
g2 || (console.log(r.gray("\nEmpty commit message. Aborting.")), process.exit(0));
}
try {
console.log(r.gray("\nStaging all changes...")), i("git add .", { stdio: "inherit" });
const e2 = g2.replace(/"/g, '\\"');
i(`git commit -m "${e2}"`, { stdio: "inherit" }), console.log(r.green.bold("\n✓ Commit successful:")), console.log(r.cyan(g2));
try {
const e3 = i("git rev-parse --short HEAD").toString().trim();
console.log(r.gray(`Commit hash: ${e3}`));
} catch {
}
} catch (e2) {
console.error(r.red.bold("\n✗ Commit failed:"), e2.message), process.exit(1);
}
} catch (e2) {
console.error(r.red.bold("\nError:"), e2.message), process.exit(1);
}
}();
else if (["--config", "-c"].includes(x[0])) {
e.existsSync(h) || (console.log("Error: Configuration file not found. Run giso --init first."), process.exit(1));
const t2 = JSON.parse(e.readFileSync(h, "utf8"));
console.log("Configuration:"), console.log(JSON.stringify(t2, null, 2));
} else ["--update", "-u"].includes(x[0]) ? async function(e2) {
var _a;
const t2 = "giso", o2 = `https://registry.npmjs.org/${t2}`;
try {
console.log(`Current installed version: ${e2}`), console.log("Checking for updates on npm...");
const s2 = await m(o2);
if (!s2.ok) return console.error(`Failed to fetch package info from npm: ${s2.status}`), 1;
const n2 = await s2.json(), r2 = (_a = n2["dist-tags"]) == null ? void 0 : _a.latest;
if (!r2) return console.error("Could not determine the latest version from npm."), 1;
if (console.log(`Latest version on npm: ${r2}`), r2 === e2) return console.log("You are already using the latest version."), 0;
console.log("A new version is available. Updating...");
try {
return i(`npm install -g ${t2}@latest`, { stdio: "inherit" }), console.log(`Successfully updated to version ${r2}`), 0;
} catch (e3) {
return console.error("Update failed:", e3.message), 1;
}
} catch (e3) {
return console.error("Error during update check:", e3.message), 1;
}
}(S).then((e2) => {
process.exit(e2);
}) : (console.log("Usage:"), console.log(" giso --init (-i) Initialize configuration"), console.log(" giso --offer (-o) Generate commit message suggestions"), console.log(" giso --version (-v) Show version"), console.log(" giso --config (-c) Show configuration"), console.log(" giso --update (-u) Update giso to the latest version"), process.exit(1));