@ksw8954/git-ai-commit 1.1.7 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/.github/workflows/publish.yml +36 -0
  2. package/CHANGELOG.md +21 -0
  3. package/README.md +25 -4
  4. package/dist/commands/ai.d.ts +7 -1
  5. package/dist/commands/ai.d.ts.map +1 -1
  6. package/dist/commands/ai.js +144 -22
  7. package/dist/commands/ai.js.map +1 -1
  8. package/dist/commands/commit.d.ts +1 -0
  9. package/dist/commands/commit.d.ts.map +1 -1
  10. package/dist/commands/commit.js +37 -34
  11. package/dist/commands/commit.js.map +1 -1
  12. package/dist/commands/completion.d.ts.map +1 -1
  13. package/dist/commands/completion.js +15 -5
  14. package/dist/commands/completion.js.map +1 -1
  15. package/dist/commands/config.d.ts +7 -2
  16. package/dist/commands/config.d.ts.map +1 -1
  17. package/dist/commands/config.js +37 -15
  18. package/dist/commands/config.js.map +1 -1
  19. package/dist/commands/configCommand.d.ts +5 -1
  20. package/dist/commands/configCommand.d.ts.map +1 -1
  21. package/dist/commands/configCommand.js +30 -3
  22. package/dist/commands/configCommand.js.map +1 -1
  23. package/dist/commands/git.js +3 -3
  24. package/dist/commands/hookCommand.d.ts +14 -0
  25. package/dist/commands/hookCommand.d.ts.map +1 -0
  26. package/dist/commands/hookCommand.js +180 -0
  27. package/dist/commands/hookCommand.js.map +1 -0
  28. package/dist/commands/prCommand.d.ts.map +1 -1
  29. package/dist/commands/prCommand.js +3 -1
  30. package/dist/commands/prCommand.js.map +1 -1
  31. package/dist/commands/tag.d.ts.map +1 -1
  32. package/dist/commands/tag.js +12 -2
  33. package/dist/commands/tag.js.map +1 -1
  34. package/dist/index.js +3 -0
  35. package/dist/index.js.map +1 -1
  36. package/package.json +2 -1
  37. package/src/__tests__/ai.test.ts +486 -7
  38. package/src/__tests__/commitCommand.test.ts +111 -0
  39. package/src/__tests__/config.test.ts +24 -6
  40. package/src/__tests__/git.test.ts +421 -98
  41. package/src/__tests__/preCommit.test.ts +19 -0
  42. package/src/__tests__/tagCommand.test.ts +510 -17
  43. package/src/commands/ai.ts +175 -24
  44. package/src/commands/commit.ts +40 -34
  45. package/src/commands/completion.ts +15 -5
  46. package/src/commands/config.ts +46 -23
  47. package/src/commands/configCommand.ts +41 -8
  48. package/src/commands/git.ts +3 -3
  49. package/src/commands/hookCommand.ts +193 -0
  50. package/src/commands/prCommand.ts +3 -1
  51. package/src/commands/tag.ts +13 -2
  52. package/src/index.ts +3 -0
  53. package/src/schema/config.schema.json +72 -0
@@ -1,5 +1,5 @@
1
1
  import { Command } from 'commander';
2
- import { ConfigService, SupportedLanguage } from './config';
2
+ import { ConfigService, SupportedLanguage, AIMode } from './config';
3
3
 
4
4
  export interface ConfigOptions {
5
5
  show?: boolean;
@@ -10,7 +10,10 @@ export interface ConfigOptions {
10
10
  model?: string;
11
11
  fallbackModel?: string;
12
12
  reasoningEffort?: string;
13
- mode?: 'custom' | 'openai';
13
+ mode?: AIMode;
14
+ coAuthor?: string;
15
+ noCoAuthor?: boolean;
16
+ maxTokens?: string;
14
17
  }
15
18
 
16
19
  export class ConfigCommand {
@@ -28,7 +31,10 @@ export class ConfigCommand {
28
31
  .option('-m, --model <model>', 'Persist default AI model')
29
32
  .option('--fallback-model <model>', 'Persist fallback model for rate limit (429) retry')
30
33
  .option('--reasoning-effort <level>', 'Thinking effort for reasoning models (minimal | low | medium | high)')
31
- .option('--mode <mode>', 'Persist AI mode (custom | openai)')
34
+ .option('--mode <mode>', 'Persist AI mode (custom | openai | gemini)')
35
+ .option('--co-author <value>', 'Set co-author for commits (e.g. "Name <email>")')
36
+ .option('--no-co-author', 'Disable co-author')
37
+ .option('--max-tokens <number>', 'Persist max completion tokens for AI responses')
32
38
  .action(this.handleConfig.bind(this));
33
39
  }
34
40
 
@@ -42,14 +48,14 @@ export class ConfigCommand {
42
48
  return normalized;
43
49
  }
44
50
 
45
- private validateMode(mode: string): 'custom' | 'openai' {
51
+ private validateMode(mode: string): AIMode {
46
52
  const normalized = mode?.toLowerCase();
47
- if (normalized !== 'custom' && normalized !== 'openai') {
48
- console.error('Mode must be either "custom" or "openai".');
53
+ if (normalized !== 'custom' && normalized !== 'openai' && normalized !== 'gemini') {
54
+ console.error('Mode must be one of: "custom", "openai", "gemini".');
49
55
  process.exit(1);
50
56
  }
51
57
 
52
- return normalized;
58
+ return normalized as AIMode;
53
59
  }
54
60
 
55
61
  private sanitizeStringValue(value?: string): string | undefined {
@@ -68,9 +74,11 @@ export class ConfigCommand {
68
74
  model?: string;
69
75
  fallbackModel?: string;
70
76
  reasoningEffort?: string;
71
- mode?: 'custom' | 'openai';
77
+ mode?: AIMode;
72
78
  language?: SupportedLanguage;
73
79
  autoPush?: boolean;
80
+ coAuthor?: string | false;
81
+ maxCompletionTokens?: number;
74
82
  } = {};
75
83
 
76
84
  if (options.language) {
@@ -110,6 +118,26 @@ export class ConfigCommand {
110
118
  updates.mode = this.validateMode(options.mode);
111
119
  }
112
120
 
121
+
122
+ if (options.noCoAuthor) {
123
+ updates.coAuthor = false;
124
+ } else if (options.coAuthor !== undefined) {
125
+ const coAuthor = this.sanitizeStringValue(options.coAuthor);
126
+ if (coAuthor && !/.+<.+@.+>/.test(coAuthor)) {
127
+ console.error('Co-author must be in "Name <email>" format.');
128
+ process.exit(1);
129
+ }
130
+ updates.coAuthor = coAuthor;
131
+ }
132
+ if (options.maxTokens !== undefined) {
133
+ const parsed = parseInt(options.maxTokens, 10);
134
+ if (isNaN(parsed) || parsed <= 0) {
135
+ console.error('Max tokens must be a positive number.');
136
+ process.exit(1);
137
+ }
138
+ updates.maxCompletionTokens = parsed;
139
+ }
140
+
113
141
  const hasUpdates = Object.keys(updates).length > 0;
114
142
 
115
143
  if (!options.show && !hasUpdates) {
@@ -120,8 +148,11 @@ export class ConfigCommand {
120
148
  console.log(' git-ai-commit config -k sk-xxx # Persist API key securely on disk');
121
149
  console.log(' git-ai-commit config -b https://api.test # Persist custom API base URL');
122
150
  console.log(' git-ai-commit config --mode openai # Use OpenAI-compatible environment defaults');
151
+ console.log(' git-ai-commit config --mode gemini # Use Gemini native SDK (GEMINI_API_KEY)');
123
152
  console.log(' git-ai-commit config --model gpt-4o-mini # Persist preferred AI model');
124
153
  console.log(' git-ai-commit config --fallback-model glm-4-flash # Fallback model for 429 retry');
154
+ console.log(' git-ai-commit config --co-author "Name <email>" # Set co-author for commits');
155
+ console.log(' git-ai-commit config --max-tokens 2000 # Set max completion tokens for AI');
125
156
  return;
126
157
  }
127
158
 
@@ -148,6 +179,8 @@ export class ConfigCommand {
148
179
  console.log(`Fallback Model: ${config.fallbackModel || 'Not set'}`);
149
180
  console.log(`Reasoning Effort: ${config.reasoningEffort || 'Not set (model default)'}`);
150
181
  console.log(`Mode: ${config.mode || 'custom (default)'}`);
182
+ console.log(`Co-author: ${config.coAuthor === false ? 'disabled' : config.coAuthor}`);
183
+ console.log(`Max Completion Tokens: ${config.maxCompletionTokens || 'Not set (using per-command defaults)'}`);
151
184
  } catch (error) {
152
185
  console.error('Error reading configuration:', error instanceof Error ? error.message : error);
153
186
  process.exit(1);
@@ -4,11 +4,11 @@ import { promisify } from 'util';
4
4
  const execAsync = promisify(exec);
5
5
  const execFileAsync = promisify(execFile);
6
6
  const GIT_DIFF_MAX_BUFFER = 50 * 1024 * 1024;
7
- const MAX_DIFF_TOKENS = 50000;
7
+ const MAX_DIFF_TOKENS = 25000;
8
8
  const APPROX_CHARS_PER_TOKEN = 4;
9
9
  const MAX_DIFF_CHARS = MAX_DIFF_TOKENS * APPROX_CHARS_PER_TOKEN;
10
- const MAX_FILE_LINES = 400;
11
- const MAX_NEW_FILE_LINES = 200;
10
+ const MAX_FILE_LINES = 200;
11
+ const MAX_NEW_FILE_LINES = 100;
12
12
 
13
13
  const splitDiffSections = (diff: string): string[] => {
14
14
  const lines = diff.split('\n');
@@ -0,0 +1,193 @@
1
+ import { Command } from 'commander';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import { execFileSync } from 'child_process';
5
+
6
+ const HOOK_NAME = 'prepare-commit-msg';
7
+ const HOOK_SIGNATURE = '# installed by git-ai-commit';
8
+
9
+ const HOOK_SCRIPT = `#!/bin/sh
10
+ ${HOOK_SIGNATURE}
11
+
12
+ COMMIT_MSG_FILE="$1"
13
+ COMMIT_SOURCE="$2"
14
+
15
+ # Skip if message was provided via -m, merge, squash, or amend
16
+ if [ -n "$COMMIT_SOURCE" ]; then
17
+ exit 0
18
+ fi
19
+
20
+ # Skip if no staged changes
21
+ if ! git diff --cached --quiet --exit-code 2>/dev/null; then
22
+ # Generate AI commit message
23
+ AI_MSG=$(git-ai-commit commit --message-only 2>/dev/null)
24
+ if [ $? -eq 0 ] && [ -n "$AI_MSG" ]; then
25
+ # Prepend AI message, keep original commented lines below
26
+ ORIGINAL=$(cat "$COMMIT_MSG_FILE")
27
+ printf '%s\n\n%s' "$AI_MSG" "$ORIGINAL" > "$COMMIT_MSG_FILE"
28
+ fi
29
+ fi
30
+ `;
31
+
32
+ export class HookCommand {
33
+ private program: Command;
34
+
35
+ constructor() {
36
+ this.program = new Command('hook')
37
+ .description('Manage git-ai-commit prepare-commit-msg hook');
38
+
39
+ this.program
40
+ .command('install')
41
+ .description('Install prepare-commit-msg hook in the current repository')
42
+ .action(this.handleInstall.bind(this));
43
+
44
+ this.program
45
+ .command('uninstall')
46
+ .description('Remove prepare-commit-msg hook from the current repository')
47
+ .action(this.handleUninstall.bind(this));
48
+
49
+ this.program
50
+ .command('status')
51
+ .description('Show hook installation status')
52
+ .action(this.handleStatus.bind(this));
53
+ }
54
+
55
+ private getGitRoot(): string | null {
56
+ try {
57
+ return execFileSync('git', ['rev-parse', '--show-toplevel'], {
58
+ encoding: 'utf-8',
59
+ }).trim();
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ private getHooksDir(): string | null {
66
+ try {
67
+ const hooksPath = execFileSync('git', ['rev-parse', '--git-path', 'hooks'], {
68
+ encoding: 'utf-8',
69
+ }).trim();
70
+ // git rev-parse --git-path returns relative path; resolve it
71
+ const gitRoot = this.getGitRoot();
72
+ if (!gitRoot) return null;
73
+ return path.resolve(gitRoot, hooksPath);
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ private getHookPath(): string | null {
80
+ const hooksDir = this.getHooksDir();
81
+ if (!hooksDir) return null;
82
+ return path.join(hooksDir, HOOK_NAME);
83
+ }
84
+
85
+ private isOurHook(hookPath: string): boolean {
86
+ try {
87
+ const content = fs.readFileSync(hookPath, 'utf-8');
88
+ return content.includes(HOOK_SIGNATURE);
89
+ } catch {
90
+ return false;
91
+ }
92
+ }
93
+
94
+ private handleInstall(): void {
95
+ const gitRoot = this.getGitRoot();
96
+ if (!gitRoot) {
97
+ console.error('Error: Not a git repository.');
98
+ process.exit(1);
99
+ }
100
+
101
+ const hookPath = this.getHookPath();
102
+ if (!hookPath) {
103
+ console.error('Error: Could not determine git hooks directory.');
104
+ process.exit(1);
105
+ }
106
+
107
+ // Check if hook already exists
108
+ if (fs.existsSync(hookPath)) {
109
+ if (this.isOurHook(hookPath)) {
110
+ console.log('Hook is already installed. Use "git-ai-commit hook uninstall" to remove it.');
111
+ return;
112
+ }
113
+ console.error(`Error: ${HOOK_NAME} hook already exists and was not installed by git-ai-commit.`);
114
+ console.error(`Path: ${hookPath}`);
115
+ console.error('Remove or rename the existing hook first, then try again.');
116
+ process.exit(1);
117
+ }
118
+
119
+ // Create hooks directory if it doesn't exist
120
+ const hooksDir = path.dirname(hookPath);
121
+ if (!fs.existsSync(hooksDir)) {
122
+ fs.mkdirSync(hooksDir, { recursive: true });
123
+ }
124
+
125
+ fs.writeFileSync(hookPath, HOOK_SCRIPT, { mode: 0o755 });
126
+ console.log(`Installed ${HOOK_NAME} hook.`);
127
+ console.log(`Path: ${hookPath}`);
128
+ console.log('');
129
+ console.log('Now "git commit" will auto-generate an AI commit message.');
130
+ console.log('The message will be pre-filled in your editor for review.');
131
+ console.log('Use "git commit -m ..." to skip AI generation.');
132
+ }
133
+
134
+ private handleUninstall(): void {
135
+ const gitRoot = this.getGitRoot();
136
+ if (!gitRoot) {
137
+ console.error('Error: Not a git repository.');
138
+ process.exit(1);
139
+ }
140
+
141
+ const hookPath = this.getHookPath();
142
+ if (!hookPath) {
143
+ console.error('Error: Could not determine git hooks directory.');
144
+ process.exit(1);
145
+ }
146
+
147
+ if (!fs.existsSync(hookPath)) {
148
+ console.log('No hook installed.');
149
+ return;
150
+ }
151
+
152
+ if (!this.isOurHook(hookPath)) {
153
+ console.error(`Error: ${HOOK_NAME} hook exists but was not installed by git-ai-commit.`);
154
+ console.error('Will not remove hooks installed by other tools.');
155
+ process.exit(1);
156
+ }
157
+
158
+ fs.unlinkSync(hookPath);
159
+ console.log(`Removed ${HOOK_NAME} hook.`);
160
+ }
161
+
162
+ private handleStatus(): void {
163
+ const gitRoot = this.getGitRoot();
164
+ if (!gitRoot) {
165
+ console.error('Error: Not a git repository.');
166
+ process.exit(1);
167
+ }
168
+
169
+ const hookPath = this.getHookPath();
170
+ if (!hookPath) {
171
+ console.error('Error: Could not determine git hooks directory.');
172
+ process.exit(1);
173
+ }
174
+
175
+ if (!fs.existsSync(hookPath)) {
176
+ console.log('Not installed.');
177
+ console.log('Run "git-ai-commit hook install" to set up the prepare-commit-msg hook.');
178
+ return;
179
+ }
180
+
181
+ if (this.isOurHook(hookPath)) {
182
+ console.log('Installed.');
183
+ console.log(`Path: ${hookPath}`);
184
+ } else {
185
+ console.log(`A ${HOOK_NAME} hook exists but was not installed by git-ai-commit.`);
186
+ console.log(`Path: ${hookPath}`);
187
+ }
188
+ }
189
+
190
+ getCommand(): Command {
191
+ return this.program;
192
+ }
193
+ }
@@ -64,7 +64,9 @@ export class PullRequestCommand {
64
64
  fallbackModel: existingConfig.fallbackModel,
65
65
  reasoningEffort: existingConfig.reasoningEffort,
66
66
  language: existingConfig.language,
67
- verbose: false
67
+ verbose: false,
68
+ mode: existingConfig.mode,
69
+ maxCompletionTokens: existingConfig.maxCompletionTokens,
68
70
  });
69
71
 
70
72
  const aiResult = await aiService.generatePullRequestMessage(
@@ -68,7 +68,9 @@ export class TagCommand {
68
68
  model: mergedModel,
69
69
  fallbackModel: storedConfig.fallbackModel,
70
70
  reasoningEffort: storedConfig.reasoningEffort,
71
- language: storedConfig.language
71
+ language: storedConfig.language,
72
+ mode: storedConfig.mode,
73
+ maxCompletionTokens: storedConfig.maxCompletionTokens,
72
74
  };
73
75
  }
74
76
 
@@ -436,8 +438,17 @@ export class TagCommand {
436
438
  } else {
437
439
  for (const remote of selectedRemotes) {
438
440
  console.log(`Pushing tag ${trimmedName} to ${remote}...`);
439
- const pushSuccess = await GitService.pushTagToRemote(trimmedName, remote);
441
+ let pushSuccess = await GitService.pushTagToRemote(trimmedName, remote);
442
+
443
+ if (!pushSuccess) {
444
+ console.log(`⚠️ Normal push failed for ${remote}. Force push may be required.`);
445
+ const shouldForcePush = await this.confirmForcePush(trimmedName);
440
446
 
447
+ if (shouldForcePush) {
448
+ console.log(`Force pushing tag ${trimmedName} to ${remote}...`);
449
+ pushSuccess = await GitService.forcePushTag(trimmedName, remote);
450
+ }
451
+ }
441
452
  if (pushSuccess) {
442
453
  console.log(`✅ Tag ${trimmedName} pushed to ${remote} successfully!`);
443
454
  } else {
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@ import { PullRequestCommand } from './commands/prCommand';
9
9
  import { TagCommand } from './commands/tag';
10
10
  import { HistoryCommand } from './commands/history';
11
11
  import { CompletionCommand } from './commands/completion';
12
+ import { HookCommand } from './commands/hookCommand';
12
13
 
13
14
  function getPackageVersion(): string {
14
15
  try {
@@ -36,6 +37,7 @@ const pullRequestCommand = new PullRequestCommand();
36
37
  const tagCommand = new TagCommand();
37
38
  const historyCommand = new HistoryCommand();
38
39
  const completionCommand = new CompletionCommand();
40
+ const hookCommand = new HookCommand();
39
41
 
40
42
  program.addCommand(commitCommand.getCommand());
41
43
  program.addCommand(configCommand.getCommand());
@@ -43,5 +45,6 @@ program.addCommand(pullRequestCommand.getCommand());
43
45
  program.addCommand(tagCommand.getCommand());
44
46
  program.addCommand(historyCommand.getCommand());
45
47
  program.addCommand(completionCommand.getCommand());
48
+ program.addCommand(hookCommand.getCommand());
46
49
 
47
50
  program.parse();
@@ -0,0 +1,72 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://raw.githubusercontent.com/onaries/git-ai-commit/main/src/schema/config.schema.json",
4
+ "title": "git-ai-commit configuration",
5
+ "description": "Configuration file for git-ai-commit CLI (~/.git-ai-commit/config.json)",
6
+ "type": "object",
7
+ "properties": {
8
+ "$schema": {
9
+ "type": "string",
10
+ "description": "JSON Schema reference for editor support"
11
+ },
12
+ "apiKey": {
13
+ "type": "string",
14
+ "description": "API key for AI requests. Overrides AI_API_KEY / OPENAI_API_KEY environment variables."
15
+ },
16
+ "baseURL": {
17
+ "type": "string",
18
+ "format": "uri",
19
+ "description": "Custom API base URL. Overrides AI_BASE_URL / OPENAI_BASE_URL environment variables. Not used in gemini mode."
20
+ },
21
+ "model": {
22
+ "type": "string",
23
+ "description": "AI model to use for generation.",
24
+ "default": "zai-org/GLM-4.5-FP8",
25
+ "examples": ["gpt-4o-mini", "gpt-4", "gemini-2.0-flash", "claude-3-haiku"]
26
+ },
27
+ "fallbackModel": {
28
+ "type": "string",
29
+ "description": "Fallback model used when the primary model returns a 429 (rate limit) error.",
30
+ "examples": ["glm-4-flash", "gpt-4o-mini"]
31
+ },
32
+ "reasoningEffort": {
33
+ "type": "string",
34
+ "enum": ["minimal", "low", "medium", "high"],
35
+ "description": "Thinking effort level for reasoning models."
36
+ },
37
+ "mode": {
38
+ "type": "string",
39
+ "enum": ["custom", "openai", "gemini"],
40
+ "default": "custom",
41
+ "description": "AI provider mode. 'custom' uses AI_* vars first, 'openai' prefers OPENAI_* vars, 'gemini' uses Google Gemini native SDK."
42
+ },
43
+ "language": {
44
+ "type": "string",
45
+ "enum": ["ko", "en"],
46
+ "default": "ko",
47
+ "description": "Default language for AI-generated output."
48
+ },
49
+ "autoPush": {
50
+ "type": "boolean",
51
+ "default": false,
52
+ "description": "Automatically push to remote after a successful commit."
53
+ },
54
+ "coAuthor": {
55
+ "oneOf": [
56
+ {
57
+ "type": "string",
58
+ "pattern": ".+<.+@.+>",
59
+ "description": "Co-authored-by trailer value in 'Name <email>' format."
60
+ },
61
+ {
62
+ "type": "boolean",
63
+ "const": false,
64
+ "description": "Set to false to disable the default Co-authored-by trailer."
65
+ }
66
+ ],
67
+ "default": "git-ai-commit <git-ai-commit@users.noreply.github.com>",
68
+ "description": "Co-authored-by trailer appended to AI-generated commit messages. Defaults to 'git-ai-commit <git-ai-commit@users.noreply.github.com>'. Set to false to disable."
69
+ }
70
+ },
71
+ "additionalProperties": false
72
+ }