diff --git a/out/cli.cjs b/out/cli.cjs index 9df26e1d..3a6c7d07 100755 --- a/out/cli.cjs +++ b/out/cli.cjs @@ -67306,6 +67306,42 @@ function getI18nLocal(value) { return false; } +// src/utils/provider.ts +var OCO_AI_PROVIDER_ENUM = /* @__PURE__ */ ((OCO_AI_PROVIDER_ENUM2) => { + OCO_AI_PROVIDER_ENUM2["OLLAMA"] = "ollama"; + OCO_AI_PROVIDER_ENUM2["LLAMACPP"] = "llamacpp"; + OCO_AI_PROVIDER_ENUM2["OPENAI"] = "openai"; + OCO_AI_PROVIDER_ENUM2["ANTHROPIC"] = "anthropic"; + OCO_AI_PROVIDER_ENUM2["GEMINI"] = "gemini"; + OCO_AI_PROVIDER_ENUM2["AZURE"] = "azure"; + OCO_AI_PROVIDER_ENUM2["TEST"] = "test"; + OCO_AI_PROVIDER_ENUM2["FLOWISE"] = "flowise"; + OCO_AI_PROVIDER_ENUM2["GROQ"] = "groq"; + OCO_AI_PROVIDER_ENUM2["MISTRAL"] = "mistral"; + OCO_AI_PROVIDER_ENUM2["MLX"] = "mlx"; + OCO_AI_PROVIDER_ENUM2["DEEPSEEK"] = "deepseek"; + OCO_AI_PROVIDER_ENUM2["AIMLAPI"] = "aimlapi"; + OCO_AI_PROVIDER_ENUM2["OPENROUTER"] = "openrouter"; + return OCO_AI_PROVIDER_ENUM2; +})(OCO_AI_PROVIDER_ENUM || {}); +var PROVIDER_CONFIG_REQUIREMENTS = { + ["openai" /* OPENAI */]: "apiKey", + ["anthropic" /* ANTHROPIC */]: "apiKey", + ["ollama" /* OLLAMA */]: "model", + ["llamacpp" /* LLAMACPP */]: "model", + ["gemini" /* GEMINI */]: "apiKey", + ["groq" /* GROQ */]: "apiKey", + ["mistral" /* MISTRAL */]: "apiKey", + ["deepseek" /* DEEPSEEK */]: "apiKey", + ["openrouter" /* OPENROUTER */]: "apiKey", + ["aimlapi" /* AIMLAPI */]: "apiKey", + ["azure" /* AZURE */]: "apiKey", + ["mlx" /* MLX */]: "model", + ["flowise" /* FLOWISE */]: "apiKey", + ["test" /* TEST */]: "none" +}; +var getProviderConfigRequirement = (provider = "openai" /* OPENAI */) => PROVIDER_CONFIG_REQUIREMENTS[provider] || "apiKey"; + // src/commands/config.ts var CONFIG_KEYS = /* @__PURE__ */ ((CONFIG_KEYS3) => { CONFIG_KEYS3["OCO_API_KEY"] = "OCO_API_KEY"; @@ -67429,7 +67465,7 @@ var MODEL_LIST = { "mistral-moderation-2411", "mistral-moderation-latest" ], - deepseek: ["deepseek-chat", "deepseek-reasoner"], + deepseek: ["deepseek-v4-flash", "deepseek-v4-pro"], // AI/ML API available chat-completion models // https://api.aimlapi.com/v1/models aimlapi: [ @@ -68099,23 +68135,6 @@ var configValidators = { ); } }; -var OCO_AI_PROVIDER_ENUM = /* @__PURE__ */ ((OCO_AI_PROVIDER_ENUM2) => { - OCO_AI_PROVIDER_ENUM2["OLLAMA"] = "ollama"; - OCO_AI_PROVIDER_ENUM2["LLAMACPP"] = "llamacpp"; - OCO_AI_PROVIDER_ENUM2["OPENAI"] = "openai"; - OCO_AI_PROVIDER_ENUM2["ANTHROPIC"] = "anthropic"; - OCO_AI_PROVIDER_ENUM2["GEMINI"] = "gemini"; - OCO_AI_PROVIDER_ENUM2["AZURE"] = "azure"; - OCO_AI_PROVIDER_ENUM2["TEST"] = "test"; - OCO_AI_PROVIDER_ENUM2["FLOWISE"] = "flowise"; - OCO_AI_PROVIDER_ENUM2["GROQ"] = "groq"; - OCO_AI_PROVIDER_ENUM2["MISTRAL"] = "mistral"; - OCO_AI_PROVIDER_ENUM2["MLX"] = "mlx"; - OCO_AI_PROVIDER_ENUM2["DEEPSEEK"] = "deepseek"; - OCO_AI_PROVIDER_ENUM2["AIMLAPI"] = "aimlapi"; - OCO_AI_PROVIDER_ENUM2["OPENROUTER"] = "openrouter"; - return OCO_AI_PROVIDER_ENUM2; -})(OCO_AI_PROVIDER_ENUM || {}); var PROVIDER_API_KEY_URLS = { ["openai" /* OPENAI */]: "https://platform.openai.com/api-keys", ["anthropic" /* ANTHROPIC */]: "https://console.anthropic.com/settings/keys", @@ -68138,7 +68157,7 @@ var RECOMMENDED_MODELS = { ["gemini" /* GEMINI */]: "gemini-1.5-flash", ["groq" /* GROQ */]: "llama3-70b-8192", ["mistral" /* MISTRAL */]: "mistral-small-latest", - ["deepseek" /* DEEPSEEK */]: "deepseek-chat", + ["deepseek" /* DEEPSEEK */]: "deepseek-v4-flash", ["openrouter" /* OPENROUTER */]: "openai/gpt-4o-mini", ["aimlapi" /* AIMLAPI */]: "gpt-4o-mini" }; @@ -74782,6 +74801,7 @@ var cl100k_base_default = { pat_str: "(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\\r\\n\\p{L // src/utils/tokenCount.ts var import_lite = __toESM(require_tiktoken(), 1); var TOKENIZER_CHUNK_LENGTH = 8e3; +var TOKEN_BOUNDARY_RESERVE = 128; var encoding; var getEncoding = () => { encoding ??= new import_lite.Tiktoken( @@ -74794,7 +74814,7 @@ var getEncoding = () => { var getSafeSliceEnd = (content, start, length) => { let end = Math.min(start + length, content.length); if (end < content.length && end > start && /[\uD800-\uDBFF]/.test(content[end - 1]) && /[\uDC00-\uDFFF]/.test(content[end])) { - end -= 1; + end = end - 1 === start ? end + 1 : end - 1; } return end; }; @@ -74810,15 +74830,16 @@ var getTextChunks = (content) => { var countTextChunk = (content) => getEncoding().encode(content).length; var yieldToEventLoop = () => new Promise((resolve) => setImmediate(resolve)); function tokenCount(content) { - return getTextChunks(content).reduce( - (total, chunk) => total + countTextChunk(chunk), - 0 - ); + return getTextChunks(content).reduce((total, chunk, index) => { + const boundaryReserve = index === 0 ? 0 : TOKEN_BOUNDARY_RESERVE; + return total + boundaryReserve + countTextChunk(chunk); + }, 0); } async function tokenCountAsync(content) { let total = 0; - for (const chunk of getTextChunks(content)) { - total += countTextChunk(chunk); + for (const [index, chunk] of getTextChunks(content).entries()) { + const boundaryReserve = index === 0 ? 0 : TOKEN_BOUNDARY_RESERVE; + total += boundaryReserve + countTextChunk(chunk); await yieldToEventLoop(); } return total; @@ -74845,13 +74866,15 @@ async function splitByTokenLimit(content, maxTokens) { let currentContent = ""; let currentTokens = 0; for (const chunk of countedChunks) { - if (currentContent && currentTokens + chunk.tokens > maxTokens) { + const boundaryReserve = currentContent ? TOKEN_BOUNDARY_RESERVE : 0; + if (currentContent && currentTokens + boundaryReserve + chunk.tokens > maxTokens) { mergedChunks.push(currentContent); currentContent = ""; currentTokens = 0; } + const appliedBoundaryReserve = currentContent ? TOKEN_BOUNDARY_RESERVE : 0; currentContent += chunk.content; - currentTokens += chunk.tokens; + currentTokens += appliedBoundaryReserve + chunk.tokens; } if (currentContent) mergedChunks.push(currentContent); return mergedChunks; @@ -74881,7 +74904,8 @@ var AnthropicEngine = class { throw new Error("TOO_MUCH_TOKENS" /* tooMuchTokens */); } const data = await this.client.messages.create(params); - const message = data?.content[0].text; + const textBlock = data?.content?.find((b7) => b7.type === "text"); + const message = textBlock && "text" in textBlock ? textBlock.text : void 0; let content = message; return removeContentTags(content, "think"); } catch (error) { @@ -74890,6 +74914,9 @@ var AnthropicEngine = class { }; this.config = config8; const clientOptions = { apiKey: this.config.apiKey }; + if (this.config.baseURL) { + clientOptions.baseURL = this.config.baseURL; + } const proxy = config8.proxy; if (proxy) { clientOptions.httpAgent = new HttpsProxyAgent(proxy); @@ -84799,7 +84826,12 @@ var DeepseekEngine = class extends OpenAiEngine { messages, temperature: 0, top_p: 0.1, - max_tokens: this.config.maxTokensOutput + max_tokens: this.config.maxTokensOutput, + // DeepSeek V4: disable thinking mode (enabled by default with effort=high). + // Thinking mode returns reasoning in `reasoning_content` and can leave + // `content` empty when max_tokens is low, causing EMPTY_MESSAGE errors. + // Commit message generation doesn't need chain-of-thought. + thinking: { type: "disabled" } }; try { const REQUEST_TOKENS = messages.map((msg) => tokenCount(msg.content) + 4).reduce((a4, b7) => a4 + b7, 0); @@ -84983,7 +85015,6 @@ var computeHash = async (content, algorithm = "sha256") => { init_dist2(); var import_types = __toESM(require_lib5(), 1); var config2 = getConfig(); -var translation = i18n[config2.OCO_LANGUAGE || "en"]; var getTypeRuleExtraDescription = (type2, prompt) => prompt?.questions?.type?.enum?.[type2]?.description; var llmReadableRules = { blankline: (key, applicable) => `There should ${applicable} be a blank line at the beginning of the ${key}.`, @@ -85063,7 +85094,7 @@ var STRUCTURE_OF_COMMIT = config2.OCO_OMIT_SCOPE ? ` - Header of commit is composed of type, scope, subject: (): - Description of commit is composed of body and footer (optional): `; -var GEN_COMMITLINT_CONSISTENCY_PROMPT = (prompts) => [ +var GEN_COMMITLINT_CONSISTENCY_PROMPT = (prompts, translation3) => [ { role: "system", content: `${IDENTITY} Your mission is to create clean and comprehensive commit messages for two different changes in a single codebase and output them in the provided JSON format: one for a bug fix and another for a new feature. @@ -85077,15 +85108,15 @@ Commit Message Conventions: - ${prompts.join("\n- ")} JSON Output Format: -- The JSON output should contain the commit messages for a bug fix and a new feature in the following format: +- You MUST write all commit messages and descriptions in ${translation3.localLanguage} and output them in the following JSON format. Use the exact language and writing style as shown in the example: \`\`\`json { - "localLanguage": "${translation.localLanguage}", - "commitFix": "
", - "commitFeat": "
", - "commitFixOmitScope": "
", - "commitFeatOmitScope": "
", - "commitDescription": "" + "localLanguage": ${JSON.stringify(translation3.localLanguage)}, + "commitFix": ${JSON.stringify(translation3.commitFix)}, + "commitFeat": ${JSON.stringify(translation3.commitFeat)}, + "commitFixOmitScope": ${JSON.stringify(translation3.commitFixOmitScope)}, + "commitFeatOmitScope": ${JSON.stringify(translation3.commitFeatOmitScope)}, + "commitDescription": ${JSON.stringify(translation3.commitDescription)} } \`\`\` - The "commitDescription" should not include the commit message's header, only the description. @@ -85208,7 +85239,7 @@ var getCommitlintLLMConfig = async () => { // src/modules/commitlint/config.ts var config3 = getConfig(); -var translation2 = i18n[config3.OCO_LANGUAGE || "en"]; +var translation = i18n[config3.OCO_LANGUAGE || "en"]; var configureCommitlintIntegration = async (force = false) => { const spin = le(); spin.start("Loading @commitlint configuration"); @@ -85236,7 +85267,7 @@ var configureCommitlintIntegration = async (force = false) => { } spin.start("Generating consistency with given @commitlint rules"); const prompts = inferPromptsFromCommitlintConfig(commitLintConfig); - const consistencyPrompts = commitlintPrompts.GEN_COMMITLINT_CONSISTENCY_PROMPT(prompts); + const consistencyPrompts = commitlintPrompts.GEN_COMMITLINT_CONSISTENCY_PROMPT(prompts, translation); const engine = getEngine(); let consistency = await engine.generateCommitMessage(consistencyPrompts) || "{}"; prompts.forEach((prompt) => consistency = consistency.replace(prompt, "")); @@ -85246,7 +85277,7 @@ var configureCommitlintIntegration = async (force = false) => { hash, prompts, consistency: { - [translation2.localLanguage]: { + [translation.localLanguage]: { ...JSON.parse(consistency) } } @@ -85262,7 +85293,7 @@ function removeConventionalCommitWord(message) { // src/prompts.ts var config4 = getConfig(); -var translation3 = i18n[config4.OCO_LANGUAGE || "en"]; +var translation2 = i18n[config4.OCO_LANGUAGE || "en"]; var IDENTITY = "You are to act as an author of a commit message in git."; var GITMOJI_HELP = `Use GitMoji convention to preface the commit. Here are some help to choose the right emoji (emoji, description): \u{1F41B}, Fix a bug; @@ -85407,17 +85438,17 @@ var generateCommitString = (type2, message) => { const cleanMessage = removeConventionalCommitWord(message); return config4.OCO_EMOJI ? `${COMMIT_TYPES[type2]} ${cleanMessage}` : message; }; -var getConsistencyContent = (translation4) => { - const fixMessage = config4.OCO_OMIT_SCOPE && translation4.commitFixOmitScope ? translation4.commitFixOmitScope : translation4.commitFix; - const featMessage = config4.OCO_OMIT_SCOPE && translation4.commitFeatOmitScope ? translation4.commitFeatOmitScope : translation4.commitFeat; +var getConsistencyContent = (translation3) => { + const fixMessage = config4.OCO_OMIT_SCOPE && translation3.commitFixOmitScope ? translation3.commitFixOmitScope : translation3.commitFix; + const featMessage = config4.OCO_OMIT_SCOPE && translation3.commitFeatOmitScope ? translation3.commitFeatOmitScope : translation3.commitFeat; const fix = generateCommitString("fix", fixMessage); const feat = config4.OCO_ONE_LINE_COMMIT ? "" : generateCommitString("feat", featMessage); - const description = config4.OCO_DESCRIPTION ? translation4.commitDescription : ""; + const description = config4.OCO_DESCRIPTION ? translation3.commitDescription : ""; return [fix, feat, description].filter(Boolean).join("\n"); }; -var INIT_CONSISTENCY_PROMPT = (translation4) => ({ +var INIT_CONSISTENCY_PROMPT = (translation3) => ({ role: "assistant", - content: getConsistencyContent(translation4) + content: getConsistencyContent(translation3) }); var getMainCommitPrompt = async (fullGitMojiSpec, context) => { switch (config4.OCO_PROMPT_MODULE) { @@ -85428,22 +85459,27 @@ var getMainCommitPrompt = async (fullGitMojiSpec, context) => { ); await configureCommitlintIntegration(); } - const commitLintConfig = await getCommitlintLLMConfig(); + let commitLintConfig = await getCommitlintLLMConfig(); + if (!Array.isArray(commitLintConfig.prompts)) { + ie("Commitlint LLM config is missing prompts, regenerating..."); + await configureCommitlintIntegration(true); + commitLintConfig = await getCommitlintLLMConfig(); + } return [ commitlintPrompts.INIT_MAIN_PROMPT( - translation3.localLanguage, + translation2.localLanguage, commitLintConfig.prompts ), INIT_DIFF_PROMPT, INIT_CONSISTENCY_PROMPT( - commitLintConfig.consistency[translation3.localLanguage] + commitLintConfig.consistency[translation2.localLanguage] ) ]; default: return [ - INIT_MAIN_PROMPT2(translation3.localLanguage, fullGitMojiSpec, context), + INIT_MAIN_PROMPT2(translation2.localLanguage, fullGitMojiSpec, context), INIT_DIFF_PROMPT, - INIT_CONSISTENCY_PROMPT(translation3) + INIT_CONSISTENCY_PROMPT(translation2) ]; } }; @@ -85454,21 +85490,62 @@ async function mergeDiffs(arr, maxStringLength) { const mergedArr = []; let currentItem = arr[0]; let currentItemTokens = await tokenCountAsync(currentItem); + let unverifiedBoundaryTokens = 0; for (const item of arr.slice(1)) { const itemTokens = await tokenCountAsync(item); - if (currentItemTokens + itemTokens <= maxStringLength) { + const independentlyCountedTokens = currentItemTokens + itemTokens; + const conservativeTokens = independentlyCountedTokens + unverifiedBoundaryTokens + TOKEN_BOUNDARY_RESERVE; + if (conservativeTokens <= maxStringLength) { currentItem += item; - currentItemTokens += itemTokens; - } else { - mergedArr.push(currentItem); - currentItem = item; - currentItemTokens = itemTokens; + currentItemTokens = independentlyCountedTokens; + unverifiedBoundaryTokens += TOKEN_BOUNDARY_RESERVE; + continue; + } + const combinedItem = currentItem + item; + const combinedItemTokens = await tokenCountAsync(combinedItem); + if (combinedItemTokens <= maxStringLength) { + currentItem = combinedItem; + currentItemTokens = combinedItemTokens; + unverifiedBoundaryTokens = 0; + continue; } + mergedArr.push(currentItem); + currentItem = item; + currentItemTokens = itemTokens; + unverifiedBoundaryTokens = 0; } mergedArr.push(currentItem); return mergedArr; } +// src/utils/runTasksWithConcurrency.ts +async function runTasksWithConcurrency(tasks, concurrency) { + if (!Number.isInteger(concurrency) || concurrency < 1) { + throw new Error("concurrency must be a positive integer"); + } + const results = new Array(tasks.length); + let nextTaskIndex = 0; + let hasFailed = false; + let firstError; + const runWorker = async () => { + while (!hasFailed) { + const taskIndex = nextTaskIndex; + nextTaskIndex += 1; + if (taskIndex >= tasks.length) return; + try { + results[taskIndex] = await tasks[taskIndex](); + } catch (error) { + if (!hasFailed) firstError = error; + hasFailed = true; + } + } + }; + const workerCount = Math.min(concurrency, tasks.length); + await Promise.all(Array.from({ length: workerCount }, () => runWorker())); + if (hasFailed) throw firstError; + return results; +} + // src/generateCommitMessageFromGitDiff.ts var config5 = getConfig(); var MAX_TOKENS_INPUT = config5.OCO_TOKENS_MAX_INPUT; @@ -85550,6 +85627,7 @@ async function handleModelNotFoundError(error, provider, currentModel) { return newModel; } var ADJUSTMENT_FACTOR = 20; +var MAX_CONCURRENT_GENERATIONS = 3; var generateCommitMessageByDiff = async (diff, fullGitMojiSpec = false, context = "", retryWithModel) => { const currentConfig = getConfig(); const provider = currentConfig.OCO_AI_PROVIDER || "openai"; @@ -85564,13 +85642,19 @@ var generateCommitMessageByDiff = async (diff, fullGitMojiSpec = false, context ).reduce((a4, b7) => a4 + b7, 0); const MAX_REQUEST_TOKENS = MAX_TOKENS_INPUT - ADJUSTMENT_FACTOR - INIT_MESSAGES_PROMPT_LENGTH - MAX_TOKENS_OUTPUT; if (await tokenCountAsync(diff) >= MAX_REQUEST_TOKENS) { - const commitMessagePromises = await getCommitMsgsPromisesFromFileDiffs( + const commitMessageTasks = await getCommitMessageTasksFromFileDiffs( diff, MAX_REQUEST_TOKENS, fullGitMojiSpec, context ); - const commitMessages = await Promise.all(commitMessagePromises); + const commitMessages = await runTasksWithConcurrency( + commitMessageTasks, + MAX_CONCURRENT_GENERATIONS + ); + if (config5.OCO_ONE_LINE_COMMIT) { + return commitMessages.filter(Boolean).map((msg) => msg.split("\n")[0].trim()).join("; "); + } return commitMessages.join("\n\n"); } const messages = await generateCommitMessageChatCompletionPrompt( @@ -85609,7 +85693,7 @@ var generateCommitMessageByDiff = async (diff, fullGitMojiSpec = false, context throw error; } }; -async function getMessagesPromisesByChangesInFile(fileDiff, separator, maxChangeLength, fullGitMojiSpec, context) { +async function getMessageTasksByChangesInFile(fileDiff, separator, maxChangeLength, fullGitMojiSpec, context) { const hunkHeaderSeparator = "@@ "; const [fileHeader, ...fileDiffByLines] = fileDiff.split(hunkHeaderSeparator); const mergedChanges = await mergeDiffs( @@ -85618,65 +85702,87 @@ async function getMessagesPromisesByChangesInFile(fileDiff, separator, maxChange ); const lineDiffsWithHeader = []; for (const change of mergedChanges) { - const totalChange = fileHeader + change; + const diffPrefix = separator + fileHeader; + const totalChange = diffPrefix + change; if (await tokenCountAsync(totalChange) > maxChangeLength) { - const splitChanges = await splitDiff(totalChange, maxChangeLength); + const splitChanges = await splitDiff(change, diffPrefix, maxChangeLength); lineDiffsWithHeader.push(...splitChanges); } else { lineDiffsWithHeader.push(totalChange); } } - const engine = getEngine(); - const commitMsgsFromFileLineDiffs = lineDiffsWithHeader.map( - async (lineDiff) => { + return lineDiffsWithHeader.map( + (lineDiff) => async () => { const messages = await generateCommitMessageChatCompletionPrompt( - separator + lineDiff, + lineDiff, fullGitMojiSpec, context ); + const engine = getEngine(); return engine.generateCommitMessage(messages); } ); - return commitMsgsFromFileLineDiffs; } -async function splitDiff(diff, maxChangeLength) { +var getLinesWithEndings = (content) => content.match(/[^\n]*\n|[^\n]+$/g) ?? []; +async function splitDiff(diff, prefix, maxChangeLength) { if (maxChangeLength <= 0) { throw new Error(GenerateCommitMessageErrorEnum.outputTokensTooHigh); } - return splitByTokenLimit(diff, maxChangeLength); + const prefixTokens = await tokenCountAsync(prefix); + const maxDiffTokens = maxChangeLength - prefixTokens - TOKEN_BOUNDARY_RESERVE; + if (maxDiffTokens <= 0) { + throw new Error(GenerateCommitMessageErrorEnum.outputTokensTooHigh); + } + const lineChunks = await mergeDiffs(getLinesWithEndings(diff), maxDiffTokens); + const splitDiffs = []; + for (const lineChunk of lineChunks) { + if (await tokenCountAsync(lineChunk) <= maxDiffTokens) { + splitDiffs.push(prefix + lineChunk); + continue; + } + const oversizedLineChunks = await splitByTokenLimit( + lineChunk, + maxDiffTokens + ); + splitDiffs.push(...oversizedLineChunks.map((chunk) => prefix + chunk)); + } + return splitDiffs; } -var getCommitMsgsPromisesFromFileDiffs = async (diff, maxDiffLength, fullGitMojiSpec, context) => { +var getCommitMessageTasksFromFileDiffs = async (diff, maxDiffLength, fullGitMojiSpec, context) => { const separator = "diff --git "; - const diffByFiles = diff.split(separator).slice(1); + const diffByFiles = diff.split(separator).slice(1).map((fileDiff) => separator + fileDiff); const mergedFilesDiffs = await mergeDiffs(diffByFiles, maxDiffLength); - const commitMessagePromises = []; + const commitMessageTasks = []; for (const fileDiff of mergedFilesDiffs) { - if (await tokenCountAsync(fileDiff) >= maxDiffLength) { - const messagesPromises = await getMessagesPromisesByChangesInFile( - fileDiff, + if (await tokenCountAsync(fileDiff) > maxDiffLength) { + const messageTasks = await getMessageTasksByChangesInFile( + fileDiff.slice(separator.length), separator, maxDiffLength, fullGitMojiSpec, context ); - commitMessagePromises.push(...messagesPromises); + commitMessageTasks.push(...messageTasks); } else { - const messages = await generateCommitMessageChatCompletionPrompt( - separator + fileDiff, - fullGitMojiSpec, - context - ); - const engine = getEngine(); - commitMessagePromises.push(engine.generateCommitMessage(messages)); + commitMessageTasks.push(async () => { + const messages = await generateCommitMessageChatCompletionPrompt( + fileDiff, + fullGitMojiSpec, + context + ); + const engine = getEngine(); + return engine.generateCommitMessage(messages); + }); } } - return commitMessagePromises; + return commitMessageTasks; }; // src/utils/git.ts var import_fs3 = require("fs"); var import_ignore = __toESM(require_ignore(), 1); var import_path4 = require("path"); +var import_os2 = require("os"); init_dist2(); var assertGitRepo = async () => { try { @@ -85688,20 +85794,30 @@ var assertGitRepo = async () => { var getOpenCommitIgnore = async () => { const gitDir = await getGitDir(); const ig = (0, import_ignore.default)(); - try { - ig.add( - (0, import_fs3.readFileSync)((0, import_path4.join)(gitDir, ".opencommitignore")).toString().split("\n") - ); - } catch (e3) { + const globalIgnorePath = (0, import_path4.join)((0, import_os2.homedir)(), ".opencommitignore"); + if ((0, import_fs3.existsSync)(globalIgnorePath)) { + try { + const globalIgnoreContent = (0, import_fs3.readFileSync)(globalIgnorePath, "utf8"); + ig.add(globalIgnoreContent.split("\n")); + } catch (e3) { + } + } + const localIgnorePath = (0, import_path4.join)(gitDir, ".opencommitignore"); + if ((0, import_fs3.existsSync)(localIgnorePath)) { + try { + const localIgnoreContent = (0, import_fs3.readFileSync)(localIgnorePath, "utf8"); + ig.add(localIgnoreContent.split("\n")); + } catch (e3) { + } } return ig; }; -var getCoreHooksPath = async () => { +var getGitHooksPath = async () => { const gitDir = await getGitDir(); - const { stdout } = await execa("git", ["config", "core.hooksPath"], { + const { stdout } = await execa("git", ["rev-parse", "--git-path", "hooks"], { cwd: gitDir }); - return stdout; + return (0, import_path4.resolve)(gitDir, stdout); }; var getStagedFiles = async () => { const gitDir = await getGitDir(); @@ -86085,23 +86201,24 @@ var import_fs4 = require("fs"); var import_promises3 = __toESM(require("fs/promises"), 1); var import_path5 = __toESM(require("path"), 1); var HOOK_NAME = "prepare-commit-msg"; -var DEFAULT_SYMLINK_URL = import_path5.default.join(".git", "hooks", HOOK_NAME); var getHooksPath = async () => { - try { - const hooksPath = await getCoreHooksPath(); - return import_path5.default.join(hooksPath, HOOK_NAME); - } catch (error) { - return DEFAULT_SYMLINK_URL; - } + return import_path5.default.join(await getGitHooksPath(), HOOK_NAME); }; -var isHookCalled = async () => { - const hooksPath = await getHooksPath(); - return process.argv[1].endsWith(hooksPath); +var normalizeHookPath = async (hookPath) => { + const absolutePath = import_path5.default.resolve(hookPath); + const realDirectory = await import_promises3.default.realpath(import_path5.default.dirname(absolutePath)); + return import_path5.default.join(realDirectory, import_path5.default.basename(absolutePath)); }; -var isHookExists = async () => { - const hooksPath = await getHooksPath(); - return (0, import_fs4.existsSync)(hooksPath); +var isHookCalled = async () => { + try { + const invokedPath = process.argv[1]; + if (!invokedPath) return false; + return await normalizeHookPath(invokedPath) === await normalizeHookPath(await getHooksPath()); + } catch { + return false; + } }; +var isHookExists = (hooksPath) => (0, import_fs4.existsSync)(hooksPath); var hookCommand = G3( { name: "hook" /* hook */, @@ -86109,13 +86226,13 @@ var hookCommand = G3( }, async (argv) => { const HOOK_URL = __filename; - const SYMLINK_URL = await getHooksPath(); try { await assertGitRepo(); + const SYMLINK_URL = await getHooksPath(); const { setUnset: mode } = argv._; if (mode === "set") { ae(`setting opencommit as '${HOOK_NAME}' hook at ${SYMLINK_URL}`); - if (await isHookExists()) { + if (isHookExists(SYMLINK_URL)) { let realPath; try { realPath = await import_promises3.default.realpath(SYMLINK_URL); @@ -86138,7 +86255,7 @@ var hookCommand = G3( ae( `unsetting opencommit as '${HOOK_NAME}' hook from ${SYMLINK_URL}` ); - if (!await isHookExists()) { + if (!isHookExists(SYMLINK_URL)) { return ce( `OpenCommit wasn't previously set as '${HOOK_NAME}' hook, nothing to remove` ); @@ -86186,7 +86303,7 @@ var prepareCommitMessageHook = async (isStageAllFlag = false) => { if (!staged) return; ae("opencommit"); const config8 = getConfig(); - if (!config8.OCO_API_KEY) { + if (getProviderConfigRequirement(config8.OCO_AI_PROVIDER) === "apiKey" && !config8.OCO_API_KEY) { ce( "No OCO_API_KEY is set. Set your key via `oco config set OCO_API_KEY=. For more info see https://github.com/di-sukharev/opencommit" ); @@ -86244,9 +86361,9 @@ init_dist2(); // src/utils/modelCache.ts var import_fs5 = require("fs"); -var import_os2 = require("os"); +var import_os3 = require("os"); var import_path6 = require("path"); -var MODEL_CACHE_PATH = (0, import_path6.join)((0, import_os2.homedir)(), ".opencommit-models.json"); +var MODEL_CACHE_PATH = (0, import_path6.join)((0, import_os3.homedir)(), ".opencommit-models.json"); var CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1e3; function readCache() { try { @@ -86457,86 +86574,72 @@ var SETUP_PROVIDERS = [ { provider: "openai" /* OPENAI */, displayName: "OpenAI (GPT)", - selectionGroup: "primary", - firstRunRequirement: "apiKey" + selectionGroup: "primary" }, { provider: "anthropic" /* ANTHROPIC */, displayName: "Anthropic (Claude Sonnet, Opus)", - selectionGroup: "primary", - firstRunRequirement: "apiKey" + selectionGroup: "primary" }, { provider: "ollama" /* OLLAMA */, displayName: "Ollama (Free, runs locally)", - selectionGroup: "primary", - firstRunRequirement: "model" + selectionGroup: "primary" }, { provider: "llamacpp" /* LLAMACPP */, displayName: "llama.cpp (Free, runs locally)", - selectionGroup: "primary", - firstRunRequirement: "model" + selectionGroup: "primary" }, { provider: "gemini" /* GEMINI */, displayName: "Google Gemini", - selectionGroup: "other", - firstRunRequirement: "apiKey" + selectionGroup: "other" }, { provider: "groq" /* GROQ */, displayName: "Groq (Fast inference, free tier)", - selectionGroup: "other", - firstRunRequirement: "apiKey" + selectionGroup: "other" }, { provider: "mistral" /* MISTRAL */, displayName: "Mistral AI", - selectionGroup: "other", - firstRunRequirement: "apiKey" + selectionGroup: "other" }, { provider: "deepseek" /* DEEPSEEK */, displayName: "DeepSeek", - selectionGroup: "other", - firstRunRequirement: "apiKey" + selectionGroup: "other" }, { provider: "openrouter" /* OPENROUTER */, displayName: "OpenRouter (Multiple providers)", - selectionGroup: "other", - firstRunRequirement: "apiKey" + selectionGroup: "other" }, { provider: "aimlapi" /* AIMLAPI */, displayName: "AI/ML API", - selectionGroup: "other", - firstRunRequirement: "apiKey" + selectionGroup: "other" }, { provider: "azure" /* AZURE */, displayName: "Azure OpenAI", - selectionGroup: "other", - firstRunRequirement: "apiKey" + selectionGroup: "other" }, { provider: "mlx" /* MLX */, displayName: "MLX (Apple Silicon, local)", - selectionGroup: "other", - firstRunRequirement: "model" + selectionGroup: "other" }, { provider: "flowise" /* FLOWISE */, displayName: "flowise" /* FLOWISE */, - selectionGroup: "hidden", - firstRunRequirement: "apiKey" + selectionGroup: "hidden" }, { provider: "test" /* TEST */, displayName: "test" /* TEST */, - selectionGroup: "hidden", - firstRunRequirement: "none" + selectionGroup: "hidden" } ]; function getProviderDefinition(provider) { @@ -86553,9 +86656,6 @@ function getProviderOptions(group) { function getProviderDisplayName(provider) { return getProviderDefinition(provider)?.displayName || provider; } -function getFirstRunRequirement(provider) { - return getProviderDefinition(provider)?.firstRunRequirement || "apiKey"; -} async function selectProvider() { const primaryOptions = getProviderOptions("primary"); primaryOptions.push({ @@ -86630,7 +86730,7 @@ async function selectModel(provider, apiKey) { loadingSpinner.stop("Models loaded"); } if (models.length === 0) { - if (getFirstRunRequirement(provider) !== "apiKey") { + if (getProviderConfigRequirement(provider) !== "apiKey") { return await J4({ message: "Enter model name (e.g., llama3:8b, mistral):", placeholder: "llama3:8b", @@ -86872,14 +86972,14 @@ function isFirstRun() { const hasGlobalConfig = getIsGlobalConfigFileExist(); const config8 = getConfig(); const provider = config8.OCO_AI_PROVIDER || "openai" /* OPENAI */; - const requirement = getFirstRunRequirement(provider); + const requirement = getProviderConfigRequirement(provider); const hasRequiredConfig = requirement === "model" ? Boolean(config8.OCO_MODEL) : requirement === "apiKey" ? Boolean(config8.OCO_API_KEY) : true; return !hasGlobalConfig && !hasRequiredConfig; } async function promptForMissingApiKey() { const config8 = getConfig(); const provider = config8.OCO_AI_PROVIDER || "openai" /* OPENAI */; - if (getFirstRunRequirement(provider) !== "apiKey") { + if (getProviderConfigRequirement(provider) !== "apiKey") { return true; } if (config8.OCO_API_KEY) { @@ -87039,13 +87139,17 @@ var modelsCommand = G3( init_dist2(); // src/version.ts -init_dist2(); +var NPM_REGISTRY = "https://registry.npmjs.org"; +var PACKAGE_NAME = "opencommit"; var getOpenCommitLatestVersion = async () => { try { - const { stdout } = await execa("npm", ["view", "opencommit", "version"]); - return stdout; - } catch (_7) { - ce("Error while getting the latest version of opencommit"); + const response = await fetch(`${NPM_REGISTRY}/${PACKAGE_NAME}/latest`); + if (!response.ok) { + return void 0; + } + const data = await response.json(); + return data.version; + } catch { return void 0; } }; @@ -87074,7 +87178,7 @@ Current version: ${currentVersion}. Latest version: ${latestVersion}. // src/migrations/_run.ts var import_fs6 = __toESM(require("fs"), 1); -var import_os3 = require("os"); +var import_os4 = require("os"); var import_path7 = require("path"); // src/migrations/00_use_single_api_key_and_url.ts @@ -87167,7 +87271,7 @@ var migrations = [ // src/migrations/_run.ts init_dist2(); -var migrationsFile = (0, import_path7.join)((0, import_os3.homedir)(), ".opencommit_migrations"); +var migrationsFile = (0, import_path7.join)((0, import_os4.homedir)(), ".opencommit_migrations"); var getCompletedMigrations = () => { if (!import_fs6.default.existsSync(migrationsFile)) { return []; diff --git a/src/commands/config.ts b/src/commands/config.ts index f2c1d533..a8d6d2bd 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -9,6 +9,9 @@ import { join as pathJoin, resolve as pathResolve } from 'path'; import { COMMANDS } from './ENUMS'; import { TEST_MOCK_TYPES } from '../engine/testAi'; import { getI18nLocal, i18n } from '../i18n'; +import { OCO_AI_PROVIDER_ENUM } from '../utils/provider'; + +export { OCO_AI_PROVIDER_ENUM } from '../utils/provider'; export enum CONFIG_KEYS { OCO_API_KEY = 'OCO_API_KEY', @@ -855,23 +858,6 @@ export const configValidators = { } }; -export enum OCO_AI_PROVIDER_ENUM { - OLLAMA = 'ollama', - LLAMACPP = 'llamacpp', - OPENAI = 'openai', - ANTHROPIC = 'anthropic', - GEMINI = 'gemini', - AZURE = 'azure', - TEST = 'test', - FLOWISE = 'flowise', - GROQ = 'groq', - MISTRAL = 'mistral', - MLX = 'mlx', - DEEPSEEK = 'deepseek', - AIMLAPI = 'aimlapi', - OPENROUTER = 'openrouter' -} - export const PROVIDER_API_KEY_URLS: Record = { [OCO_AI_PROVIDER_ENUM.OPENAI]: 'https://platform.openai.com/api-keys', [OCO_AI_PROVIDER_ENUM.ANTHROPIC]: diff --git a/src/commands/githook.ts b/src/commands/githook.ts index 4baa3bed..6b729c96 100755 --- a/src/commands/githook.ts +++ b/src/commands/githook.ts @@ -4,31 +4,37 @@ import { command } from 'cleye'; import { existsSync } from 'fs'; import fs from 'fs/promises'; import path from 'path'; -import { assertGitRepo, getCoreHooksPath } from '../utils/git.js'; +import { assertGitRepo, getGitHooksPath } from '../utils/git.js'; import { COMMANDS } from './ENUMS'; const HOOK_NAME = 'prepare-commit-msg'; -const DEFAULT_SYMLINK_URL = path.join('.git', 'hooks', HOOK_NAME); const getHooksPath = async (): Promise => { - try { - const hooksPath = await getCoreHooksPath(); - return path.join(hooksPath, HOOK_NAME); - } catch (error) { - return DEFAULT_SYMLINK_URL; - } + return path.join(await getGitHooksPath(), HOOK_NAME); }; -export const isHookCalled = async (): Promise => { - const hooksPath = await getHooksPath(); - return process.argv[1].endsWith(hooksPath); +const normalizeHookPath = async (hookPath: string): Promise => { + const absolutePath = path.resolve(hookPath); + const realDirectory = await fs.realpath(path.dirname(absolutePath)); + return path.join(realDirectory, path.basename(absolutePath)); }; -const isHookExists = async (): Promise => { - const hooksPath = await getHooksPath(); - return existsSync(hooksPath); +export const isHookCalled = async (): Promise => { + try { + const invokedPath = process.argv[1]; + if (!invokedPath) return false; + + return ( + (await normalizeHookPath(invokedPath)) === + (await normalizeHookPath(await getHooksPath())) + ); + } catch { + return false; + } }; +const isHookExists = (hooksPath: string): boolean => existsSync(hooksPath); + export const hookCommand = command( { name: COMMANDS.hook, @@ -36,16 +42,16 @@ export const hookCommand = command( }, async (argv) => { const HOOK_URL = __filename; - const SYMLINK_URL = await getHooksPath(); try { await assertGitRepo(); + const SYMLINK_URL = await getHooksPath(); const { setUnset: mode } = argv._; if (mode === 'set') { intro(`setting opencommit as '${HOOK_NAME}' hook at ${SYMLINK_URL}`); - if (await isHookExists()) { + if (isHookExists(SYMLINK_URL)) { let realPath; try { realPath = await fs.realpath(SYMLINK_URL); @@ -74,7 +80,7 @@ export const hookCommand = command( `unsetting opencommit as '${HOOK_NAME}' hook from ${SYMLINK_URL}` ); - if (!(await isHookExists())) { + if (!isHookExists(SYMLINK_URL)) { return outro( `OpenCommit wasn't previously set as '${HOOK_NAME}' hook, nothing to remove` ); diff --git a/src/commands/prepare-commit-msg-hook.ts b/src/commands/prepare-commit-msg-hook.ts index 561427d6..6ce93c0c 100644 --- a/src/commands/prepare-commit-msg-hook.ts +++ b/src/commands/prepare-commit-msg-hook.ts @@ -5,6 +5,7 @@ import { intro, outro, spinner } from '@clack/prompts'; import { generateCommitMessageByDiff } from '../generateCommitMessageFromGitDiff'; import { getChangedFiles, getDiff, getStagedFiles, gitAdd } from '../utils/git'; +import { getProviderConfigRequirement } from '../utils/provider'; import { getConfig } from './config'; const [messageFilePath, commitSource] = process.argv.slice(2); @@ -39,7 +40,10 @@ export const prepareCommitMessageHook = async ( const config = getConfig(); - if (!config.OCO_API_KEY) { + if ( + getProviderConfigRequirement(config.OCO_AI_PROVIDER) === 'apiKey' && + !config.OCO_API_KEY + ) { outro( 'No OCO_API_KEY is set. Set your key via `oco config set OCO_API_KEY=. For more info see https://github.com/di-sukharev/opencommit' ); diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 7e17deb9..1c56a815 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -19,101 +19,86 @@ import { fetchOllamaModels, getCacheInfo } from '../utils/modelCache'; +import { getProviderConfigRequirement } from '../utils/provider'; type ProviderSelectionGroup = 'primary' | 'other' | 'hidden'; -type FirstRunRequirement = 'apiKey' | 'model' | 'none'; interface SetupProviderDefinition { provider: OCO_AI_PROVIDER_ENUM; displayName: string; selectionGroup: ProviderSelectionGroup; - firstRunRequirement: FirstRunRequirement; } const SETUP_PROVIDERS: SetupProviderDefinition[] = [ { provider: OCO_AI_PROVIDER_ENUM.OPENAI, displayName: 'OpenAI (GPT)', - selectionGroup: 'primary', - firstRunRequirement: 'apiKey' + selectionGroup: 'primary' }, { provider: OCO_AI_PROVIDER_ENUM.ANTHROPIC, displayName: 'Anthropic (Claude Sonnet, Opus)', - selectionGroup: 'primary', - firstRunRequirement: 'apiKey' + selectionGroup: 'primary' }, { provider: OCO_AI_PROVIDER_ENUM.OLLAMA, displayName: 'Ollama (Free, runs locally)', - selectionGroup: 'primary', - firstRunRequirement: 'model' + selectionGroup: 'primary' }, { provider: OCO_AI_PROVIDER_ENUM.LLAMACPP, displayName: 'llama.cpp (Free, runs locally)', - selectionGroup: 'primary', - firstRunRequirement: 'model' + selectionGroup: 'primary' }, { provider: OCO_AI_PROVIDER_ENUM.GEMINI, displayName: 'Google Gemini', - selectionGroup: 'other', - firstRunRequirement: 'apiKey' + selectionGroup: 'other' }, { provider: OCO_AI_PROVIDER_ENUM.GROQ, displayName: 'Groq (Fast inference, free tier)', - selectionGroup: 'other', - firstRunRequirement: 'apiKey' + selectionGroup: 'other' }, { provider: OCO_AI_PROVIDER_ENUM.MISTRAL, displayName: 'Mistral AI', - selectionGroup: 'other', - firstRunRequirement: 'apiKey' + selectionGroup: 'other' }, { provider: OCO_AI_PROVIDER_ENUM.DEEPSEEK, displayName: 'DeepSeek', - selectionGroup: 'other', - firstRunRequirement: 'apiKey' + selectionGroup: 'other' }, { provider: OCO_AI_PROVIDER_ENUM.OPENROUTER, displayName: 'OpenRouter (Multiple providers)', - selectionGroup: 'other', - firstRunRequirement: 'apiKey' + selectionGroup: 'other' }, { provider: OCO_AI_PROVIDER_ENUM.AIMLAPI, displayName: 'AI/ML API', - selectionGroup: 'other', - firstRunRequirement: 'apiKey' + selectionGroup: 'other' }, { provider: OCO_AI_PROVIDER_ENUM.AZURE, displayName: 'Azure OpenAI', - selectionGroup: 'other', - firstRunRequirement: 'apiKey' + selectionGroup: 'other' }, { provider: OCO_AI_PROVIDER_ENUM.MLX, displayName: 'MLX (Apple Silicon, local)', - selectionGroup: 'other', - firstRunRequirement: 'model' + selectionGroup: 'other' }, { provider: OCO_AI_PROVIDER_ENUM.FLOWISE, displayName: OCO_AI_PROVIDER_ENUM.FLOWISE, - selectionGroup: 'hidden', - firstRunRequirement: 'apiKey' + selectionGroup: 'hidden' }, { provider: OCO_AI_PROVIDER_ENUM.TEST, displayName: OCO_AI_PROVIDER_ENUM.TEST, - selectionGroup: 'hidden', - firstRunRequirement: 'none' + selectionGroup: 'hidden' } ]; @@ -138,10 +123,6 @@ function getProviderDisplayName(provider: string): string { return getProviderDefinition(provider)?.displayName || provider; } -function getFirstRunRequirement(provider: string): FirstRunRequirement { - return getProviderDefinition(provider)?.firstRunRequirement || 'apiKey'; -} - async function selectProvider(): Promise { const primaryOptions = getProviderOptions('primary'); @@ -239,7 +220,7 @@ async function selectModel( if (models.length === 0) { // Providers without API keys can accept a local model name directly. - if (getFirstRunRequirement(provider) !== 'apiKey') { + if (getProviderConfigRequirement(provider) !== 'apiKey') { return await text({ message: 'Enter model name (e.g., llama3:8b, mistral):', placeholder: 'llama3:8b', @@ -548,7 +529,7 @@ export function isFirstRun(): boolean { const provider = config.OCO_AI_PROVIDER || OCO_AI_PROVIDER_ENUM.OPENAI; - const requirement = getFirstRunRequirement(provider); + const requirement = getProviderConfigRequirement(provider); const hasRequiredConfig = requirement === 'model' ? Boolean(config.OCO_MODEL) @@ -564,7 +545,7 @@ export async function promptForMissingApiKey(): Promise { const config = getConfig(); const provider = config.OCO_AI_PROVIDER || OCO_AI_PROVIDER_ENUM.OPENAI; - if (getFirstRunRequirement(provider) !== 'apiKey') { + if (getProviderConfigRequirement(provider) !== 'apiKey') { return true; // No API key needed } diff --git a/src/utils/git.ts b/src/utils/git.ts index 0fd78e9f..b9619cde 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -1,7 +1,7 @@ import { execa } from 'execa'; import { readFileSync, existsSync } from 'fs'; import ignore, { Ignore } from 'ignore'; -import { join } from 'path'; +import { join, resolve as pathResolve } from 'path'; import { homedir } from 'os'; import { outro, spinner } from '@clack/prompts'; @@ -44,14 +44,14 @@ export const getOpenCommitIgnore = async (): Promise => { return ig; }; -export const getCoreHooksPath = async (): Promise => { +export const getGitHooksPath = async (): Promise => { const gitDir = await getGitDir(); - const { stdout } = await execa('git', ['config', 'core.hooksPath'], { + const { stdout } = await execa('git', ['rev-parse', '--git-path', 'hooks'], { cwd: gitDir }); - return stdout; + return pathResolve(gitDir, stdout); }; export const getStagedFiles = async (): Promise => { diff --git a/src/utils/provider.ts b/src/utils/provider.ts new file mode 100644 index 00000000..b76cffa2 --- /dev/null +++ b/src/utils/provider.ts @@ -0,0 +1,43 @@ +export enum OCO_AI_PROVIDER_ENUM { + OLLAMA = 'ollama', + LLAMACPP = 'llamacpp', + OPENAI = 'openai', + ANTHROPIC = 'anthropic', + GEMINI = 'gemini', + AZURE = 'azure', + TEST = 'test', + FLOWISE = 'flowise', + GROQ = 'groq', + MISTRAL = 'mistral', + MLX = 'mlx', + DEEPSEEK = 'deepseek', + AIMLAPI = 'aimlapi', + OPENROUTER = 'openrouter' +} + +export type ProviderConfigRequirement = 'apiKey' | 'model' | 'none'; + +const PROVIDER_CONFIG_REQUIREMENTS: Record< + OCO_AI_PROVIDER_ENUM, + ProviderConfigRequirement +> = { + [OCO_AI_PROVIDER_ENUM.OPENAI]: 'apiKey', + [OCO_AI_PROVIDER_ENUM.ANTHROPIC]: 'apiKey', + [OCO_AI_PROVIDER_ENUM.OLLAMA]: 'model', + [OCO_AI_PROVIDER_ENUM.LLAMACPP]: 'model', + [OCO_AI_PROVIDER_ENUM.GEMINI]: 'apiKey', + [OCO_AI_PROVIDER_ENUM.GROQ]: 'apiKey', + [OCO_AI_PROVIDER_ENUM.MISTRAL]: 'apiKey', + [OCO_AI_PROVIDER_ENUM.DEEPSEEK]: 'apiKey', + [OCO_AI_PROVIDER_ENUM.OPENROUTER]: 'apiKey', + [OCO_AI_PROVIDER_ENUM.AIMLAPI]: 'apiKey', + [OCO_AI_PROVIDER_ENUM.AZURE]: 'apiKey', + [OCO_AI_PROVIDER_ENUM.MLX]: 'model', + [OCO_AI_PROVIDER_ENUM.FLOWISE]: 'apiKey', + [OCO_AI_PROVIDER_ENUM.TEST]: 'none' +}; + +export const getProviderConfigRequirement = ( + provider: string = OCO_AI_PROVIDER_ENUM.OPENAI +): ProviderConfigRequirement => + PROVIDER_CONFIG_REQUIREMENTS[provider as OCO_AI_PROVIDER_ENUM] || 'apiKey'; diff --git a/test/e2e/cliBehavior.test.ts b/test/e2e/cliBehavior.test.ts index 8b7349cd..5488fa74 100644 --- a/test/e2e/cliBehavior.test.ts +++ b/test/e2e/cliBehavior.test.ts @@ -15,6 +15,7 @@ import { getMockOpenAiEnv, prepareEnvironment, prepareRepo, + prepareSubmoduleEnvironment, prepareTempDir, runCli, runGit, @@ -479,29 +480,94 @@ it('cli applies the documented message template placeholder from extra args', as } }); -it('hook command sets and unsets the prepare-commit-msg symlink', async () => { +const expectHookSetAndUnset = async ({ + cwd, + hookPath +}: { + cwd: string; + hookPath: string; +}): Promise => { + const cliPath = realpathSync(resolve('./out/cli.cjs')); + + const setHook = await runCli(['hook', 'set'], { cwd }); + + expect(await setHook.findByText('Hook set')).toBeInTheConsole(); + expect(await waitForExit(setHook)).toBe(0); + expect(lstatSync(hookPath).isSymbolicLink()).toBe(true); + expect(realpathSync(hookPath)).toBe(cliPath); + + const unsetHook = await runCli(['hook', 'unset'], { cwd }); + + expect(await unsetHook.findByText('Hook is removed')).toBeInTheConsole(); + expect(await waitForExit(unsetHook)).toBe(0); + expect(existsSync(hookPath)).toBe(false); +}; + +it('hook command respects a relative core.hooksPath from a nested directory', async () => { const { gitDir, cleanup } = await prepareEnvironment(); - const hookPath = resolve(gitDir, '.git/hooks/prepare-commit-msg'); - const cliPath = resolve('./out/cli.cjs'); + const nestedDir = resolve(gitDir, 'packages/app'); + const hookPath = resolve(gitDir, 'custom-hooks/prepare-commit-msg'); try { - const setHook = await runCli(['hook', 'set'], { - cwd: gitDir + writeRepoFile(gitDir, 'packages/app/.gitkeep', ''); + await runGit(['config', 'core.hooksPath', 'custom-hooks'], gitDir); + + await expectHookSetAndUnset({ + cwd: nestedDir, + hookPath }); + } finally { + await cleanup(); + } +}); - expect(await setHook.findByText('Hook set')).toBeInTheConsole(); - expect(await waitForExit(setHook)).toBe(0); - expect(existsSync(hookPath)).toBe(true); - expect(lstatSync(hookPath).isSymbolicLink()).toBe(true); - expect(realpathSync(hookPath)).toBe(cliPath); +it('hook command sets and unsets the prepare-commit-msg symlink in a submodule', async () => { + const { submoduleDir, cleanup } = await prepareSubmoduleEnvironment(); + const { stdout: absoluteGitDir } = await runGit( + ['rev-parse', '--absolute-git-dir'], + submoduleDir + ); + const hookPath = resolve(absoluteGitDir.trim(), 'hooks/prepare-commit-msg'); - const unsetHook = await runCli(['hook', 'unset'], { - cwd: gitDir + try { + await expectHookSetAndUnset({ + cwd: submoduleDir, + hookPath }); + } finally { + await cleanup(); + } +}); + +it('hook command uses the shared hooks directory from a linked worktree', async () => { + const { tempDir, gitDir, cleanup } = await prepareEnvironment(); + const worktreeDir = resolve(tempDir, 'worktree'); + + try { + await prepareRepo( + gitDir, + { 'README.md': '# fixture\n' }, + { commitMessage: 'test: initialize repository' } + ); + await runGit( + ['worktree', 'add', '-b', 'hook-worktree', worktreeDir], + gitDir + ); - expect(await unsetHook.findByText('Hook is removed')).toBeInTheConsole(); - expect(await waitForExit(unsetHook)).toBe(0); - expect(existsSync(hookPath)).toBe(false); + const { stdout: commonGitDir } = await runGit( + ['rev-parse', '--git-common-dir'], + worktreeDir + ); + const hookPath = resolve( + worktreeDir, + commonGitDir.trim(), + 'hooks/prepare-commit-msg' + ); + + await expectHookSetAndUnset({ + cwd: worktreeDir, + hookPath + }); } finally { await cleanup(); } @@ -552,6 +618,95 @@ it('prepare-commit-msg hook writes the generated message into the commit message } }); +it('prepare-commit-msg hook supports llama.cpp without an API key', async () => { + const { gitDir, cleanup } = await prepareEnvironment(); + const homeDir = await prepareTempDir(); + const server = await startMockOpenAiServer( + 'fix(hook): support local providers without keys' + ); + const hookPath = resolve(gitDir, '.git/hooks/prepare-commit-msg'); + const messageFile = resolve(gitDir, '.git/COMMIT_EDITMSG'); + + try { + await prepareRepo( + gitDir, + { + 'index.ts': 'console.log("Hello World");\n' + }, + { stage: true } + ); + + const setHook = await runCli(['hook', 'set'], { cwd: gitDir }); + expect(await waitForExit(setHook)).toBe(0); + + writeFileSync(messageFile, '# existing\n'); + + const hookRun = await runProcess(hookPath, [messageFile], { + cwd: gitDir, + env: { + HOME: homeDir, + OCO_AI_PROVIDER: 'llamacpp', + OCO_API_KEY: '', + OCO_API_URL: new URL(server.baseUrl).origin, + OCO_MODEL: 'local-test-model', + OCO_GITPUSH: 'false' + } + }); + + expect(await hookRun.findByText('Done')).toBeInTheConsole(); + expect(await waitForExit(hookRun)).toBe(0); + expect(readFileSync(messageFile, 'utf8')).toContain( + '# fix(hook): support local providers without keys' + ); + expect(server.requestBodies).toHaveLength(1); + expect(server.authHeaders).toHaveLength(0); + } finally { + await server.cleanup(); + await cleanup(); + rmSync(homeDir, { force: true, recursive: true }); + } +}); + +it('prepare-commit-msg hook preserves the message when OpenAI has no API key', async () => { + const { gitDir, cleanup } = await prepareEnvironment(); + const homeDir = await prepareTempDir(); + const hookPath = resolve(gitDir, '.git/hooks/prepare-commit-msg'); + const messageFile = resolve(gitDir, '.git/COMMIT_EDITMSG'); + const originalMessage = '# existing\n'; + + try { + await prepareRepo( + gitDir, + { + 'index.ts': 'console.log("Hello World");\n' + }, + { stage: true } + ); + + const setHook = await runCli(['hook', 'set'], { cwd: gitDir }); + expect(await waitForExit(setHook)).toBe(0); + + writeFileSync(messageFile, originalMessage); + + const hookRun = await runProcess(hookPath, [messageFile], { + cwd: gitDir, + env: getMockOpenAiEnv('http://127.0.0.1:1/v1', { + HOME: homeDir, + OCO_API_KEY: '' + }) + }); + + expect( + await hookRun.findByText('No OCO_API_KEY is set') + ).toBeInTheConsole(); + expect(await waitForExit(hookRun)).toBe(0); + expect(readFileSync(messageFile, 'utf8')).toBe(originalMessage); + } finally { + await cleanup(); + rmSync(homeDir, { force: true, recursive: true }); + } +}); + it('cli flow prompts for a missing API key, saves it, and completes the commit', async () => { const { gitDir, cleanup } = await prepareEnvironment(); const homeDir = await prepareTempDir(); diff --git a/test/e2e/utils.ts b/test/e2e/utils.ts index 4b29df2e..6580de25 100644 --- a/test/e2e/utils.ts +++ b/test/e2e/utils.ts @@ -21,7 +21,12 @@ const fsRemove = promisify(rm); const CLI_PATH = path.resolve(process.cwd(), 'out/cli.cjs'); const DEFAULT_TEST_ENV = { - OCO_TEST_SKIP_VERSION_CHECK: 'true' + OCO_TEST_SKIP_VERSION_CHECK: 'true', + GIT_CONFIG_NOSYSTEM: '1', + GIT_CONFIG_GLOBAL: path.resolve( + tmpdir(), + `opencommit-test-empty-global-gitconfig-${process.pid}` + ) }; const COMPLETED_MIGRATIONS = [ '00_use_single_api_key_and_url', @@ -66,9 +71,17 @@ export const runCli = async ( export const runGit = async ( args: string[], - cwd: string + cwd: string, + env: NodeJS.ProcessEnv = {} ): Promise<{ stdout: string; stderr: string }> => { - const { stdout = '', stderr = '' } = await fsExecFile('git', args, { cwd }); + const { stdout = '', stderr = '' } = await fsExecFile('git', args, { + cwd, + env: { + ...process.env, + ...DEFAULT_TEST_ENV, + ...env + } + }); return { stdout, stderr }; }; @@ -92,21 +105,17 @@ export const prepareEnvironment = async ({ let otherRemoteDir: string | undefined; if (remotes === 0) { - await fsExecFile('git', ['init', 'test'], { cwd: tempDir }); + await runGit(['init', 'test'], tempDir); } else { - await fsExecFile('git', ['init', '--bare', 'remote.git'], { - cwd: tempDir - }); + await runGit(['init', '--bare', 'remote.git'], tempDir); remoteDir = path.resolve(tempDir, 'remote.git'); if (remotes === 2) { - await fsExecFile('git', ['init', '--bare', 'other.git'], { - cwd: tempDir - }); + await runGit(['init', '--bare', 'other.git'], tempDir); otherRemoteDir = path.resolve(tempDir, 'other.git'); } - await fsExecFile('git', ['clone', 'remote.git', 'test'], { cwd: tempDir }); + await runGit(['clone', 'remote.git', 'test'], tempDir); if (remotes === 2) { await runGit(['remote', 'add', 'other', '../other.git'], gitDir); @@ -130,6 +139,47 @@ export const prepareEnvironment = async ({ }; }; +export const prepareSubmoduleEnvironment = async (): Promise<{ + tempDir: string; + parentDir: string; + submoduleDir: string; + cleanup: () => Promise; +}> => { + const tempDir = await prepareTempDir(); + const parentDir = path.resolve(tempDir, 'parent'); + const sourceDir = path.resolve(tempDir, 'source'); + const submoduleDir = path.resolve(parentDir, 'nested'); + + await runGit(['init', 'source'], tempDir); + await configureGitUser(sourceDir); + await runGit( + ['-c', 'core.hooksPath=/dev/null', 'commit', '--allow-empty', '-m', 'init'], + sourceDir + ); + + await runGit(['init', 'parent'], tempDir); + await configureGitUser(parentDir); + await runGit( + [ + '-c', + 'protocol.file.allow=always', + 'submodule', + 'add', + sourceDir, + 'nested' + ], + parentDir + ); + + const cleanup = async () => { + if (existsSync(tempDir)) { + await fsRemove(tempDir, { force: true, recursive: true }); + } + }; + + return { tempDir, parentDir, submoduleDir, cleanup }; +}; + export const prepareTempDir = async (): Promise => { return fsMakeTempDir(path.join(tmpdir(), 'opencommit-test-')); }; @@ -308,7 +358,13 @@ export const getRemoteBranchHeadSubject = async ( '--pretty=%s', `refs/heads/${branchName}` ], - { cwd: process.cwd() } + { + cwd: process.cwd(), + env: { + ...process.env, + ...DEFAULT_TEST_ENV + } + } ); return stdout.trim(); @@ -329,7 +385,13 @@ export const remoteBranchExists = async ( '--quiet', `refs/heads/${branchName}` ], - { cwd: process.cwd() } + { + cwd: process.cwd(), + env: { + ...process.env, + ...DEFAULT_TEST_ENV + } + } ); return true; } catch {