gityo 1.0.10 → 1.0.12

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.
Files changed (3) hide show
  1. package/README.md +62 -6
  2. package/dist/index.js +272 -27
  3. package/package.json +12 -7
package/README.md CHANGED
@@ -46,13 +46,14 @@ If you already staged files before running `gityo`, only those are used for the
46
46
  gityo
47
47
  gityo --generate
48
48
  gityo --model fast --generate
49
- gityo --message "fix login redirect bug"
49
+ gityo --style concise --generate
50
+ gityo --input "fix login redirect bug"
50
51
  gityo --yolo
51
52
  ```
52
53
 
53
54
  ## AI setup
54
55
 
55
- A configured model is required — gityo won't run without one, and the API key must be resolvable from your environment even when you pass `--message`. Models live in a `models` map in your config file. Each key is a name you can pick with `--model`; the `default` key is used when you don't pass `--model`:
56
+ A configured model is required — gityo won't run without one, and the API key must be resolvable from your environment even when you pass `--input`. Models live in a `models` map in your config file. Each key is a name you can pick with `--model`; the `default` key is used when you don't pass `--model`:
56
57
 
57
58
  ```json
58
59
  {
@@ -68,7 +69,8 @@ A configured model is required — gityo won't run without one, and the API key
68
69
  "apiKeyEnv": "OPENAI_API_KEY",
69
70
  "model": "gpt-5-mini"
70
71
  }
71
- }
72
+ },
73
+ "style": "concise"
72
74
  }
73
75
  ```
74
76
 
@@ -101,6 +103,7 @@ Then use:
101
103
  ```bash
102
104
  gityo --generate
103
105
  gityo --model fast --generate
106
+ gityo --style concise --generate
104
107
  ```
105
108
 
106
109
  Providers are the official Vercel AI SDK packages: `@ai-sdk/openai`, `@ai-sdk/openai-compatible`, `@ai-sdk/anthropic`, `@ai-sdk/google`, `@ai-sdk/xai`, `@ai-sdk/azure`, `@ai-sdk/amazon-bedrock`, `@ai-sdk/groq`, `@ai-sdk/mistral`, `@ai-sdk/deepseek`, `@ai-sdk/togetherai`, `@ai-sdk/fireworks`, `@ai-sdk/perplexity`, `@ai-sdk/cohere`, `@ai-sdk/cerebras`, `@ai-sdk/luma`, `@ai-sdk/fal`, `@ai-sdk/deepinfra`, `@ai-sdk/google-vertex`, `@openrouter/ai-sdk-provider`, plus `ai-sdk-ollama`, `ollama-ai-provider-v2`, `workers-ai-provider`, `zhipu-ai-provider`, `sambanova-ai-provider`, `vercel-minimax-ai-provider`, `@aihubmix/ai-sdk-provider`, `ai-gateway-provider`, `@friendliai/ai-provider`, `@helicone/ai-sdk-provider`, and `ai-sdk-provider-opencode-sdk`.
@@ -116,7 +119,7 @@ gityo config
116
119
  Config is edited by hand in a JSON file. Project config goes in:
117
120
 
118
121
  ```text
119
- .gityo.config.json
122
+ .gityo.json
120
123
  ```
121
124
 
122
125
  Global config goes in:
@@ -151,13 +154,66 @@ Example:
151
154
  "model": "gpt-5-nano"
152
155
  }
153
156
  },
157
+ "style": "concise",
158
+ "styles": {
159
+ "team": "Use conventional commits. Keep the subject under 72 characters.",
160
+ "release": {
161
+ "path": ".gityo/release-style.md"
162
+ }
163
+ },
154
164
  "autoAcceptMessage": false,
155
165
  "postCommand": "push",
156
166
  "autoRunPostCommand": false,
157
- "instructions": "Write short, clear commit messages."
167
+ "instructions": "Write short, clear commit messages.",
168
+ "maxDiffTokens": 24000,
169
+ "perFileCap": 400
158
170
  }
159
171
  ```
160
172
 
173
+ ### Commit message styles
174
+
175
+ Styles control the base commit-message convention sent to the model. Built-in
176
+ styles are `default`, `concise`, `explanatory`, `plain`, and `gitmoji`.
177
+
178
+ - `default` uses conventional commits and adds a body only for substantial,
179
+ multi-part changes that benefit from more context.
180
+ - `concise` uses conventional commits and adds a body only when it is essential.
181
+ - `explanatory` uses conventional commits and encourages a useful explanatory
182
+ body.
183
+ - `plain` produces a short, non-conventional imperative subject line.
184
+ - `gitmoji` prefixes a conventional subject with a relevant gitmoji.
185
+
186
+ Choose a default with `style`, or select one for a command with `--style`:
187
+
188
+ ```bash
189
+ gityo --style team --generate
190
+ ```
191
+
192
+ Custom `styles` extend the built-ins. A custom style with the same name replaces
193
+ the built-in style. A style can be inline text or an object with a `path` to a
194
+ prompt file. File paths are passed to Node's `path.resolve()`, so relative paths
195
+ resolve from the directory where you run `gityo`.
196
+
197
+ Style selection priority is `--style`, then config `style`, then `default`.
198
+
199
+ `instructions` uses the same format. Set it to a string or `{ "path": "..." }`
200
+ to load additional instructions from a file.
201
+
202
+ ### Large changes
203
+
204
+ When the diff is small, it is sent to the model as-is. When it is too large for one request, gityo minimizes it first:
205
+
206
+ - regenerates the diff with minimal context lines
207
+ - drops lock files and minified/generated files from the payload
208
+ - caps each file's patch and lists every changed file with its line counts so the model still sees the full picture
209
+
210
+ If the minimized diff is still too large, each remaining part is summarized in parallel and a final commit message is generated from those summaries.
211
+
212
+ Two optional config knobs control this:
213
+
214
+ - `maxDiffTokens` — estimated token budget for the diff sent to the model (default `24000`)
215
+ - `perFileCap` — max diff lines kept per file when minimizing (default `400`)
216
+
161
217
  Example instructions file:
162
218
 
163
219
  ```md
@@ -169,5 +225,5 @@ Keep the subject line under 72 characters.
169
225
  Priority is simple:
170
226
 
171
227
  - `.gityo.md` for repo-specific instructions
172
- - `.gityo.config.json` for project config
228
+ - `.gityo.json` for project config
173
229
  - `~/.config/gityo.json` for your defaults
package/dist/index.js CHANGED
@@ -1710,7 +1710,7 @@ function useColor() {
1710
1710
  if (process$1.env.FORCE_COLOR || process$1.env.CLICOLOR_FORCE !== void 0) return true;
1711
1711
  }
1712
1712
  new Command();
1713
- var version$4 = "1.0.10";
1713
+ var version$4 = "1.0.12";
1714
1714
  Object.freeze({ status: "aborted" });
1715
1715
  function $constructor$3(name$16, initializer$6, params) {
1716
1716
  function init$1(inst, def) {
@@ -175933,25 +175933,34 @@ const modelSchema = object$4({
175933
175933
  apiUrl: url$3().optional(),
175934
175934
  options: record$3(string$6(), unknown$3()).optional()
175935
175935
  });
175936
+ const instructionSchema = union$4([string$6().min(1), object$4({ path: string$6().min(1) })]);
175936
175937
  const configSchema = object$4({
175937
175938
  $schema: url$3(),
175938
175939
  models: record$3(string$6().min(1), modelSchema),
175940
+ instructions: instructionSchema,
175941
+ style: string$6().min(1),
175942
+ styles: record$3(string$6().min(1), instructionSchema),
175943
+ maxDiffTokens: number$5().int().min(1e3),
175944
+ perFileCap: number$5().int().min(50),
175939
175945
  autoAcceptMessage: boolean$5(),
175940
- instructions: string$6().min(1),
175941
- postCommand: _enum$4(["push", "push-and-pull"]).nullable(),
175942
- autoRunPostCommand: boolean$5()
175946
+ autoRunPostCommand: boolean$5(),
175947
+ postCommand: _enum$4(["push", "push-and-pull"]).nullable()
175943
175948
  }).partial();
175944
175949
  function resolveConfig(input) {
175945
175950
  const parsed = configSchema.parse(input);
175946
175951
  return {
175947
175952
  models: parsed.models,
175953
+ style: parsed.style,
175954
+ styles: parsed.styles,
175948
175955
  instructions: parsed.instructions,
175956
+ maxDiffTokens: parsed.maxDiffTokens,
175957
+ perFileCap: parsed.perFileCap,
175949
175958
  autoAcceptCommitMessage: parsed.autoAcceptMessage ?? false,
175950
- postCommand: parsed.postCommand === void 0 ? "push" : parsed.postCommand,
175951
- autoRunPostCommand: parsed.autoRunPostCommand ?? false
175959
+ autoRunPostCommand: parsed.autoRunPostCommand ?? false,
175960
+ postCommand: parsed.postCommand === void 0 ? "push" : parsed.postCommand
175952
175961
  };
175953
175962
  }
175954
- const PROJECT_CONFIG_FILE_NAME = ".gityo.config.json";
175963
+ const PROJECT_CONFIG_FILE_NAME = ".gityo.json";
175955
175964
  const GLOBAL_CONFIG_FILE_PATH = path.join(os.homedir(), ".config", "gityo.json");
175956
175965
  async function loadConfig(cwd = process.cwd()) {
175957
175966
  const [globalConfig$3, projectConfig, projectInstructions] = await Promise.all([
@@ -181074,17 +181083,23 @@ async function getChangedFiles(git) {
181074
181083
  ...splitNull(untracked)
181075
181084
  ])).sort((left, right) => left.localeCompare(right));
181076
181085
  }
181077
- async function getCommitDiff(git) {
181086
+ async function getCommitDiff(git, contextLines = 3) {
181087
+ const unified = [`-U${contextLines}`];
181078
181088
  const staged = await git.raw([
181079
181089
  "diff",
181080
181090
  "--cached",
181081
- "--no-ext-diff"
181091
+ "--no-ext-diff",
181092
+ ...unified
181082
181093
  ]);
181083
181094
  if (staged.trim().length > 0) return {
181084
181095
  diff: staged,
181085
181096
  hasStaged: true
181086
181097
  };
181087
- const [unstaged, untracked] = await Promise.all([git.raw(["diff", "--no-ext-diff"]), git.raw([
181098
+ const [unstaged, untracked] = await Promise.all([git.raw([
181099
+ "diff",
181100
+ "--no-ext-diff",
181101
+ ...unified
181102
+ ]), git.raw([
181088
181103
  "ls-files",
181089
181104
  "--others",
181090
181105
  "--exclude-standard",
@@ -181094,6 +181109,7 @@ async function getCommitDiff(git) {
181094
181109
  diff: [unstaged, ...await Promise.all(splitNull(untracked).map((file$4) => git.raw([
181095
181110
  "diff",
181096
181111
  "--no-index",
181112
+ ...unified,
181097
181113
  "--",
181098
181114
  "/dev/null",
181099
181115
  file$4
@@ -181112,20 +181128,177 @@ function createLiveGit(baseDir) {
181112
181128
  });
181113
181129
  return git;
181114
181130
  }
181115
- var system_prompt_default = "You are a strict Git commit message generator. ONLY output the commit message, nothing else.\n\nREQUIRED FORMAT:\n- First line: <type>(<scope>): <subject> (max 50 characters)\n- Types: feat, fix, docs, style, refactor, perf, test, chore, ci\n- Subject: imperative mood, lowercase, no period\n- If body needed: blank line + detailed explanation (wrapped at 72 chars)\n\nSTRICT RULES:\n1. MUST use conventional commits format: type(scope): subject\n2. Subject line MUST be under 50 characters\n3. Subject MUST be in imperative mood (e.g., \"add\", \"fix\", \"update\")\n4. Subject MUST be lowercase\n5. Subject MUST NOT end with a period\n6. MUST provide clear context about WHAT changed and WHY\n7. MUST be specific about affected functions, components, or modules\n8. MUST NOT use vague terms like \"Update\", \"Fix stuff\", \"Changes\"\n9. If including a body, MUST have exactly one blank line between subject and body\n10. Body lines MUST wrap at 72 characters\n\nOUTPUT INSTRUCTION:\nReturn ONLY the commit message. No explanations, no extra text, no markdown formatting.\n\nEXAMPLE:\nfeat(llm): add support for streaming responses\n\nImplement streaming response handling for commit message\ngeneration to improve user experience with large diffs.\nAdds new stream event handlers and updates the API contract.\n\nAnalyze the diff and generate a message following ALL rules above.";
181116
- async function generateCommitMessage(languageModel, instructions, diff) {
181131
+ const DEFAULT_MAX_DIFF_TOKENS = 24e3;
181132
+ const DEFAULT_PER_FILE_CAP = 400;
181133
+ const CHARS_PER_TOKEN = 4;
181134
+ const MINIMIZED_CONTEXT_LINES = 1;
181135
+ const FILE_SECTION_PREFIX = "diff --git ";
181136
+ const HUNK_HEADER_PREFIX = "@@ ";
181137
+ const NOISE_FILE_PATTERNS = [/\.(lock|min\.(?:js|mjs|cjs|css)|map)$/i, /-lock\.[^/]+$/i];
181138
+ function estimateTokens(text$1) {
181139
+ return Math.ceil(text$1.length / CHARS_PER_TOKEN);
181140
+ }
181141
+ async function minimizeDiff(git, options) {
181142
+ const { diff } = await getCommitDiff(git, MINIMIZED_CONTEXT_LINES);
181143
+ const sections = splitFileSections(diff).map(parseSection);
181144
+ const kept = [];
181145
+ const excludedPaths = /* @__PURE__ */ new Set();
181146
+ for (const section of sections) if (isNoiseFile(section.path)) excludedPaths.add(section.path);
181147
+ else kept.push(section);
181148
+ return {
181149
+ toc: buildToc(sections, excludedPaths, options.allFiles),
181150
+ body: kept.map((section) => capSection(section, options.perFileCap)).join("\n")
181151
+ };
181152
+ }
181153
+ function splitDiffIntoChunks(diff, maxTokens) {
181154
+ return packIntoChunks(splitFileSections(diff).flatMap((section) => estimateTokens(section) <= maxTokens ? [section] : splitOversizedSection(section, maxTokens)), maxTokens);
181155
+ }
181156
+ function isNoiseFile(path$4) {
181157
+ return NOISE_FILE_PATTERNS.some((pattern) => pattern.test(path$4));
181158
+ }
181159
+ function splitFileSections(diff) {
181160
+ return ("\n" + diff.trimStart()).split("\ndiff --git ").slice(1).map((part) => FILE_SECTION_PREFIX + part);
181161
+ }
181162
+ function parseSection(section) {
181163
+ let additions = 0;
181164
+ let deletions = 0;
181165
+ let binary = false;
181166
+ for (const line of section.split("\n").slice(1)) {
181167
+ if (line.startsWith("Binary files") || line.startsWith("GIT binary patch")) {
181168
+ binary = true;
181169
+ continue;
181170
+ }
181171
+ if (line.startsWith("+++") || line.startsWith("---")) continue;
181172
+ if (line.startsWith("+")) additions++;
181173
+ else if (line.startsWith("-")) deletions++;
181174
+ }
181175
+ return {
181176
+ path: extractPath(section.split("\n")[0]),
181177
+ content: section,
181178
+ additions,
181179
+ deletions,
181180
+ binary
181181
+ };
181182
+ }
181183
+ function extractPath(header) {
181184
+ const match = header.match(/^diff --git a\/(.+) b\/(.+)$/);
181185
+ return match ? match[2] : "";
181186
+ }
181187
+ function capSection(section, cap) {
181188
+ const lines = section.content.split("\n");
181189
+ const bodyLines = lines.length - 1;
181190
+ if (bodyLines <= cap) return section.content;
181191
+ return [...lines.slice(0, cap + 1), `[... ${bodyLines - cap} more lines truncated]`].join("\n");
181192
+ }
181193
+ function buildToc(sections, excludedPaths, allFiles) {
181194
+ const lines = ["Changed files:"];
181195
+ const seen = /* @__PURE__ */ new Set();
181196
+ for (const section of sections) {
181197
+ seen.add(section.path);
181198
+ if (section.binary) {
181199
+ lines.push(`- ${section.path} (binary)`);
181200
+ continue;
181201
+ }
181202
+ lines.push(`- ${section.path} (+${section.additions} -${section.deletions})`);
181203
+ }
181204
+ for (const path$4 of excludedPaths) {
181205
+ seen.add(path$4);
181206
+ lines.push(`- ${path$4} (excluded generated file)`);
181207
+ }
181208
+ for (const file$4 of allFiles) if (!seen.has(file$4)) lines.push(`- ${file$4}`);
181209
+ return lines.join("\n");
181210
+ }
181211
+ function packIntoChunks(pieces, maxTokens) {
181212
+ const chunks = [];
181213
+ let current = "";
181214
+ for (const piece of pieces) {
181215
+ if (current.length > 0 && estimateTokens(current + piece) > maxTokens) {
181216
+ chunks.push(current);
181217
+ current = "";
181218
+ }
181219
+ current += piece;
181220
+ }
181221
+ if (current.length > 0) chunks.push(current);
181222
+ return chunks;
181223
+ }
181224
+ function splitOversizedSection(section, maxTokens) {
181225
+ const [header, ...lines] = section.split("\n");
181226
+ const hunks = [];
181227
+ let currentHunk = [];
181228
+ for (const line of lines) {
181229
+ if (line.startsWith(HUNK_HEADER_PREFIX) && currentHunk.length > 0) {
181230
+ hunks.push(currentHunk);
181231
+ currentHunk = [];
181232
+ }
181233
+ currentHunk.push(line);
181234
+ }
181235
+ if (currentHunk.length > 0) hunks.push(currentHunk);
181236
+ return packIntoChunks(hunks.flatMap((hunk) => {
181237
+ const piece = [header, ...hunk].join("\n");
181238
+ return estimateTokens(piece) <= maxTokens ? [piece] : hardSplit(piece, maxTokens);
181239
+ }), maxTokens);
181240
+ }
181241
+ function hardSplit(text$1, maxTokens) {
181242
+ const maxChars = maxTokens * CHARS_PER_TOKEN;
181243
+ const chunks = [];
181244
+ let current = "";
181245
+ for (const line of text$1.split("\n")) {
181246
+ if (estimateTokens(line) > maxTokens) {
181247
+ if (current.length > 0) {
181248
+ chunks.push(current);
181249
+ current = "";
181250
+ }
181251
+ for (let index$2 = 0; index$2 < line.length; index$2 += maxChars) chunks.push(line.slice(index$2, index$2 + maxChars));
181252
+ continue;
181253
+ }
181254
+ const candidate = current.length === 0 ? line : `${current}\n${line}`;
181255
+ if (estimateTokens(candidate) > maxTokens) {
181256
+ chunks.push(current);
181257
+ current = line;
181258
+ continue;
181259
+ }
181260
+ current = candidate;
181261
+ }
181262
+ if (current.length > 0) chunks.push(current);
181263
+ return chunks;
181264
+ }
181265
+ const summarizePrompt = `You summarize parts of a large git diff for a commit message generator.
181266
+ Describe WHAT changed, in which files or modules, and any observable intent.
181267
+ Be factual and terse. Do NOT write a commit message. Maximum 120 words.`;
181268
+ async function ask(languageModel, system, messages) {
181117
181269
  return (await generateText({
181118
181270
  model: languageModel,
181119
- instructions: system_prompt_default,
181120
- messages: [{
181121
- role: "user",
181122
- content: `Changes:\n${diff}`
181123
- }, {
181124
- role: "user",
181125
- content: instructions || "Generate a concise git commit message based on the above instructions and diff."
181126
- }]
181271
+ instructions: system,
181272
+ messages
181127
181273
  })).text.trim();
181128
181274
  }
181275
+ async function generateCommitMessage(languageModel, style, instructions, diff) {
181276
+ return ask(languageModel, style, [{
181277
+ role: "user",
181278
+ content: `Changes:\n${diff}`
181279
+ }, {
181280
+ role: "user",
181281
+ content: instructions || "Generate a concise git commit message based on the above instructions and diff."
181282
+ }]);
181283
+ }
181284
+ async function summarizeChanges(languageModel, toc, chunk$1) {
181285
+ return ask(languageModel, summarizePrompt, [{
181286
+ role: "user",
181287
+ content: `All changed files:\n${toc}\n\nChanges (part of a larger diff):\n${chunk$1}`
181288
+ }, {
181289
+ role: "user",
181290
+ content: "Summarize these changes."
181291
+ }]);
181292
+ }
181293
+ async function generateCommitMessageFromSummaries(llm, style, instructions, toc, summaries) {
181294
+ return ask(llm, style, [{
181295
+ role: "user",
181296
+ content: `All changed files:\n${toc}\n\nSummaries of all changes:\n${summaries.map((summary, index$2) => `Part ${index$2 + 1}:\n${summary}`).join("\n\n")}`
181297
+ }, {
181298
+ role: "user",
181299
+ content: instructions || "Generate a concise git commit message covering ALL parts above."
181300
+ }]);
181301
+ }
181129
181302
  function resolveLanguageModel(modelConfig) {
181130
181303
  const envVarNames = Array.isArray(modelConfig.apiKeyEnv) ? modelConfig.apiKeyEnv : [modelConfig.apiKeyEnv];
181131
181304
  let apiKey = null;
@@ -181146,6 +181319,28 @@ function resolveLanguageModel(modelConfig) {
181146
181319
  ...modelConfig.options
181147
181320
  }).languageModel(modelConfig.model);
181148
181321
  }
181322
+ const BUILTIN_STYLES = {
181323
+ default: "You are a strict Git commit message generator. ONLY output the commit message, nothing else.\n\nREQUIRED FORMAT:\n\n- First line: <type>(<scope>): <subject> (max 50 characters)\n- Types: feat, fix, docs, style, refactor, perf, test, chore, ci\n- Subject: imperative mood, lowercase, no period\n- Optional body: blank line + concise explanation (wrapped at 72 chars)\n\nSTRICT RULES:\n\n- MUST use conventional commits format: type(scope): subject\n- Subject line MUST be under 50 characters\n- Subject MUST be in imperative mood (e.g., \"add\", \"fix\", \"update\")\n- Subject MUST be lowercase\n- Subject MUST NOT end with a period\n- MUST provide clear context about WHAT changed\n- MUST be specific about affected functions, components, or modules\n- MUST NOT use vague terms like \"Update\", \"Fix stuff\", \"Changes\"\n- Do NOT include a body for a small or focused change\n- Include a body only when the diff contains several substantial related changes and explaining them adds useful context\n- When a body is warranted, explain WHY when the rationale or impact is not obvious; do not force or invent a reason\n- If including a body, MUST have exactly one blank line between subject and body\n- Body lines MUST wrap at 72 characters\n\nOUTPUT INSTRUCTION:\nReturn ONLY the commit message. No explanations, no extra text, no markdown formatting.\n\nEXAMPLE:\nfeat(llm): add streaming responses\n\nAnalyze the diff and generate a message following ALL rules above.\n",
181324
+ concise: "You are a strict Git commit message generator. ONLY output the commit message, nothing else.\n\nREQUIRED FORMAT:\n- First line: <type>(<scope>): <subject> (max 50 characters)\n- Types: feat, fix, docs, style, refactor, perf, test, chore, ci\n- Subject: imperative mood, lowercase, no period\n- Exceptional body: blank line + minimal explanation (wrapped at 72 chars)\n\nSTRICT RULES:\n- MUST use conventional commits format: type(scope): subject\n- Subject line MUST be under 50 characters\n- Subject MUST be in imperative mood (e.g., \"add\", \"fix\", \"update\")\n- Subject MUST be lowercase\n- Subject MUST NOT end with a period\n- MUST provide clear context about the most important change\n- MUST be specific about affected functions, components, or modules\n- MUST NOT use vague terms like \"Update\", \"Fix stuff\", \"Changes\"\n- MUST prefer a subject line with no body\n- Include a body only when absolutely necessary to communicate critical context that cannot be expressed in the subject\n- If including a body, keep it as short as possible and explain WHY only when that rationale or impact is essential and supported by the diff\n- If including a body, MUST have exactly one blank line between subject and body\n- Body lines MUST wrap at 72 characters\n\nOUTPUT INSTRUCTION:\nReturn ONLY the commit message. No explanations, no extra text, no markdown formatting.\n\nEXAMPLE:\nfix(config): preserve provider settings\n\nAnalyze the diff and generate a message following ALL rules above.\n",
181325
+ explanatory: "You are a strict Git commit message generator. ONLY output the commit message, nothing else.\n\nREQUIRED FORMAT:\n- First line: <type>(<scope>): <subject> (max 72 characters)\n- Types: feat, fix, docs, style, refactor, perf, test, chore, ci\n- Subject: imperative mood, lowercase, no period\n- Encouraged body: blank line + concise explanation (wrapped at 72 chars)\n\nSTRICT RULES:\n- MUST use conventional commits format: type(scope): subject\n- Subject line MUST be under 72 characters\n- Subject MUST be in imperative mood (e.g., \"add\", \"fix\", \"update\")\n- Subject MUST be lowercase\n- Subject MUST NOT end with a period\n- MUST provide clear context about WHAT changed\n- MUST be specific about affected functions, components, or modules\n- MUST NOT use vague terms like \"Update\", \"Fix stuff\", \"Changes\"\n- Include a body whenever it adds meaningful context beyond the subject\n- Use the body to explain WHY the change was made or its impact when supported by the diff; do not force or invent a reason\n- Omit the body only when it would merely repeat the subject\n- If including a body, MUST have exactly one blank line between subject and body\n- Body lines MUST wrap at 72 characters\n\nOUTPUT INSTRUCTION:\nReturn ONLY the commit message. No explanations, no extra text, no markdown formatting.\n\nEXAMPLE:\nfix(config): preserve custom provider settings\n\nReload stored provider options before applying command-line overrides to\nprevent unrelated settings from being discarded.\n\nAnalyze the diff and generate a message following ALL rules above.\n",
181326
+ plain: "You are a strict Git commit message generator. ONLY output the commit message, nothing else.\n\nREQUIRED FORMAT:\n- Only line: <subject> (max 72 characters)\n- Subject: imperative mood, lowercase, no punctuation\n- No prefix, scope, emoji, or body\n\nSTRICT RULES:\n- MUST output exactly one plain subject line\n- Complete line MUST be under 72 characters\n- Subject MUST start with an imperative verb (e.g., \"add\", \"fix\", \"update\")\n- Subject MUST be lowercase\n- Subject MUST NOT end with punctuation\n- MUST provide clear context about the most important change\n- MUST be specific about affected functions, components, or modules\n- MUST NOT use vague terms like \"Update\", \"Fix stuff\", \"Changes\"\n- MUST NOT use conventional commit types, scopes, prefixes, or emoji\n- MUST NOT include a body\n\nOUTPUT INSTRUCTION:\nReturn ONLY the commit message. No explanations, no extra text, no markdown formatting.\n\nEXAMPLE:\npreserve custom provider settings\n\nAnalyze the diff and generate a message following ALL rules above.\n",
181327
+ gitmoji: "You are a strict Git commit message generator. ONLY output the commit message, nothing else.\n\nREQUIRED FORMAT:\n- Only line: <gitmoji> <type>(<scope>): <subject> (max 72 characters)\n- Types: feat, fix, docs, style, refactor, perf, test, chore, ci\n- Subject: imperative mood, lowercase, no period\n- No body\n\nSTRICT RULES:\n- MUST use gitmoji conventional commits format: <gitmoji> type(scope): subject\n- MUST choose exactly one gitmoji that best represents the change\n- Use relevant gitmoji such as ✨ for features, 🐛 for fixes, 📝 for documentation, ♻️ for refactoring, ✅ for tests, or 🔧 for configuration\n- Complete line MUST be under 72 characters\n- Subject MUST be in imperative mood (e.g., \"add\", \"fix\", \"update\")\n- Subject MUST be lowercase\n- Subject MUST NOT end with a period\n- MUST provide clear context about the most important change\n- MUST be specific about affected functions, components, or modules\n- MUST NOT use vague terms like \"Update\", \"Fix stuff\", \"Changes\"\n- MUST NOT include a body\n\nOUTPUT INSTRUCTION:\nReturn ONLY the commit message. No explanations, no extra text, no markdown formatting.\n\nEXAMPLE:\n✨ feat(cli): add interactive style selection\n\nAnalyze the diff and generate a message following ALL rules above.\n"
181328
+ };
181329
+ function getStyleKeys(styles$2) {
181330
+ return Object.keys({
181331
+ ...BUILTIN_STYLES,
181332
+ ...styles$2
181333
+ });
181334
+ }
181335
+ async function resolveStyle(styleKey, styles$2) {
181336
+ const style = styles$2?.[styleKey] ?? BUILTIN_STYLES[styleKey];
181337
+ if (!style) return;
181338
+ return resolveInstructionContent(style);
181339
+ }
181340
+ async function resolveInstructionContent(instruction) {
181341
+ if (typeof instruction === "string") return instruction;
181342
+ return readFile(path.resolve(instruction.path), "utf8");
181343
+ }
181149
181344
  const isUpKey = (key$1, keybindings = []) => key$1.name === "up" || keybindings.includes("vim") && key$1.name === "k" || keybindings.includes("emacs") && key$1.ctrl && key$1.name === "p";
181150
181345
  const isDownKey = (key$1, keybindings = []) => key$1.name === "down" || keybindings.includes("vim") && key$1.name === "j" || keybindings.includes("emacs") && key$1.ctrl && key$1.name === "n";
181151
181346
  const isSpaceKey = (key$1) => key$1.name === "space";
@@ -197948,7 +198143,11 @@ async function runWithLoading(label, task) {
197948
198143
  let index$2 = 0;
197949
198144
  const interval = setInterval(() => {
197950
198145
  readline.cursorTo(process.stdout, 0);
197951
- process.stdout.write(`${source_default.cyan(frames[index$2])} ${source_default.cyan(label)} ${source_default.reset.dim("...")}`);
198146
+ process.stdout.write([
198147
+ source_default.cyan(frames[index$2]) + " ",
198148
+ source_default.cyan(label),
198149
+ source_default.reset.dim("...")
198150
+ ].join(""));
197952
198151
  index$2 = (index$2 + 1) % frames.length;
197953
198152
  }, 80);
197954
198153
  try {
@@ -197959,6 +198158,10 @@ async function runWithLoading(label, task) {
197959
198158
  readline.clearLine(process.stdout, 0);
197960
198159
  }
197961
198160
  }
198161
+ const MAP_CONCURRENCY = 3;
198162
+ const PROMPT_RESERVE_TOKENS = 800;
198163
+ const MIN_CHUNK_BUDGET_TOKENS = 1e3;
198164
+ const MAX_TOC_LINES = 500;
197962
198165
  async function mainController(options = {}) {
197963
198166
  const { git, liveGit } = await getGit();
197964
198167
  const config$4 = await loadConfig((await git.revparse(["--show-toplevel"])).trim());
@@ -197970,10 +198173,17 @@ async function mainController(options = {}) {
197970
198173
  throw new Error(hint);
197971
198174
  }
197972
198175
  const languageModel = resolveLanguageModel(modelConfig);
198176
+ const styleKey = options.style ?? config$4.style ?? "default";
198177
+ const style = await resolveStyle(styleKey, config$4.styles);
198178
+ if (!style) {
198179
+ const availableStyles = getStyleKeys(config$4.styles).join(", ");
198180
+ throw new Error(`Style '${styleKey}' is not configured. Available styles: ${availableStyles}.`);
198181
+ }
198182
+ const instructions = config$4.instructions ? await resolveInstructionContent(config$4.instructions) : null;
197973
198183
  const forceLLMGenerate = options.generate || options.yolo;
197974
198184
  const forceExecPostCommand = options.post || options.yolo;
197975
- let finalCommitMessage = options.message?.trim() ?? "";
197976
- if (typeof options.message === "string" && finalCommitMessage.length === 0) throw new Error("Provided commit message cannot be empty.");
198185
+ let finalCommitMessage = options.input?.trim() ?? "";
198186
+ if (typeof options.input === "string" && finalCommitMessage.length === 0) throw new Error("Provided commit message input cannot be empty.");
197977
198187
  const files = await getChangedFiles(git);
197978
198188
  if (files.length === 0) return console.log("No changed files found.");
197979
198189
  const { diff, hasStaged } = await getCommitDiff(git);
@@ -197992,7 +198202,16 @@ async function mainController(options = {}) {
197992
198202
  }
197993
198203
  if (finalCommitMessage.length === 0) while (true) {
197994
198204
  if (forceLLMGenerate) console.log(source_default.yellow("• Using LLM to generate message"));
197995
- finalCommitMessage = (await runWithLoading("Generating commit message", () => generateCommitMessage(languageModel, config$4.instructions ?? null, diff))).trim();
198205
+ finalCommitMessage = (await runWithLoading("Generating commit message", () => generateMessage({
198206
+ git,
198207
+ languageModel,
198208
+ style,
198209
+ instructions,
198210
+ diff,
198211
+ files,
198212
+ perFileCap: config$4.perFileCap ?? DEFAULT_PER_FILE_CAP,
198213
+ maxDiffTokens: config$4.maxDiffTokens ?? DEFAULT_MAX_DIFF_TOKENS
198214
+ }))).trim();
197996
198215
  if (finalCommitMessage.length === 0) throw new Error("The selected model returned an empty commit message.");
197997
198216
  console.log(source_default.cyan.dim(finalCommitMessage));
197998
198217
  console.log("");
@@ -198012,6 +198231,32 @@ async function mainController(options = {}) {
198012
198231
  await liveGit.push();
198013
198232
  if (config$4.postCommand === "push-and-pull") await liveGit.pull();
198014
198233
  }
198234
+ async function generateMessage(options) {
198235
+ const { git, languageModel, style, instructions, diff } = options;
198236
+ if (estimateTokens(diff) <= options.maxDiffTokens) return generateCommitMessage(languageModel, style, instructions, diff);
198237
+ const { toc, body } = await minimizeDiff(git, {
198238
+ perFileCap: options.perFileCap,
198239
+ allFiles: options.files
198240
+ });
198241
+ if (estimateTokens(toc) + estimateTokens(body) <= options.maxDiffTokens) return generateCommitMessage(languageModel, style, instructions, `${toc}\n\n${body}`);
198242
+ let effectiveToc = toc;
198243
+ let chunkBudget = options.maxDiffTokens - estimateTokens(effectiveToc) - PROMPT_RESERVE_TOKENS;
198244
+ if (chunkBudget < MIN_CHUNK_BUDGET_TOKENS) {
198245
+ effectiveToc = toc.split("\n").slice(0, MAX_TOC_LINES).join("\n");
198246
+ chunkBudget = Math.max(options.maxDiffTokens - estimateTokens(effectiveToc) - PROMPT_RESERVE_TOKENS, MIN_CHUNK_BUDGET_TOKENS);
198247
+ }
198248
+ const chunks = splitDiffIntoChunks(body, chunkBudget);
198249
+ const summaries = new Array(chunks.length);
198250
+ let nextChunk = 0;
198251
+ async function worker() {
198252
+ while (nextChunk < chunks.length) {
198253
+ const index$2 = nextChunk++;
198254
+ summaries[index$2] = await summarizeChanges(languageModel, effectiveToc, chunks[index$2]);
198255
+ }
198256
+ }
198257
+ await Promise.all(Array.from({ length: Math.min(MAP_CONCURRENCY, chunks.length) }, worker));
198258
+ return generateCommitMessageFromSummaries(languageModel, style, instructions, effectiveToc, summaries);
198259
+ }
198015
198260
  var AppUserCanceledError = class extends Error {};
198016
198261
  function handleError(fn$1) {
198017
198262
  Promise.resolve().then(fn$1).catch((err) => {
@@ -198022,9 +198267,9 @@ function handleError(fn$1) {
198022
198267
  }
198023
198268
  });
198024
198269
  }
198025
- const app = new Command().name("gityo").version(`v${version$4}`, "-v, --version", "Show the current version.").description("Stage changes, generate or enter a commit message, create a commit, and run a post-commit git command.").option("-g, --generate", "Generate a commit message and commit without asking.").option("-m, --message <message>", "Use the provided message as the commit message.").option("-p, --post", "Run the post-commit git command without asking.").option("--model <model>", "Model key from config to use (defaults to \"default\").").option("-y, --yolo", "Skip all questions, and generate message, commit, run post command. [Will fail if no model available]").action((options) => {
198026
- if (options.generate && options.message) {
198027
- console.error("Cannot use --generate and --message together.");
198270
+ const app = new Command().name("gityo").description("Stage changes, generate or enter a commit message, create a commit, and run a post-commit git command.").option("-i, --input <input>", "Use the provided input as the commit message.").option("-s, --style <style>", "Commit message style key to use (defaults to \"default\").").option("-m, --model <model>", "Model key from config to use (defaults to \"default\").").option("-g, --generate", "Generate a commit message and commit without asking.").option("-p, --post", "Run the post-commit git command without asking.").option("-y, --yolo", "Skip all questions, and generate message, commit, run post command. [Will fail if no model available]").version(`v${version$4}`, "-v, --version", "Show the current version.").action((options) => {
198271
+ if (options.generate && options.input) {
198272
+ console.error("Cannot use --generate and --input together.");
198028
198273
  process.exit(1);
198029
198274
  }
198030
198275
  handleError(() => mainController(options));
package/package.json CHANGED
@@ -1,19 +1,24 @@
1
1
  {
2
2
  "name": "gityo",
3
- "version": "1.0.10",
3
+ "version": "1.0.12",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "git+https://github.com/NazmusSayad/gityo.git"
7
+ },
4
8
  "sideEffects": false,
5
9
  "type": "module",
6
10
  "scripts": {
7
- "lint": "tsc --noEmit && eslint .",
8
- "fix": "tsc --noEmit && eslint . --fix",
9
- "start": "tsx --watch ./src/index.ts",
11
+ "typecheck": "tsc --noEmit",
12
+ "typecheck:watch": "tsc --noEmit --watch",
13
+ "lint": "eslint .",
14
+ "lint:fix": "eslint . --fix",
10
15
  "build": "tsdown --config ./tsdown.config.ts",
11
- "dev": "concurrently --names \"R,T\" --prefix-colors \"blue.dim,magenta.dim\" \"tsdown --config ./tsdown.config.ts --watch\" \"tsc --noEmit --watch\"",
12
- "dev:tsc": "tsc --noEmit --watch",
16
+ "dev": "tsdown --config ./tsdown.config.ts --watch",
17
+ "start": "node --watch ./dist/index.js",
13
18
  "json": "tsx ./src/json.ts"
14
19
  },
15
20
  "bin": {
16
- "gityo": "./dist/index.js"
21
+ "gityo": "dist/index.js"
17
22
  },
18
23
  "devDependencies": {
19
24
  "@ai-sdk/amazon-bedrock": "^5.0.40",