@ksw8954/git-ai-commit 1.1.8 → 1.2.1

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 +31 -5
  2. package/CHANGELOG.md +16 -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 +108 -11
  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 +9 -3
  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 +128 -13
  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 +10 -4
  52. package/src/index.ts +3 -0
  53. package/src/schema/config.schema.json +72 -0
@@ -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
 
@@ -439,10 +441,14 @@ export class TagCommand {
439
441
  let pushSuccess = await GitService.pushTagToRemote(trimmedName, remote);
440
442
 
441
443
  if (!pushSuccess) {
442
- console.log(`⚠️ Normal push failed, trying force push...`);
443
- pushSuccess = await GitService.forcePushTag(trimmedName, remote);
444
- }
444
+ console.log(`⚠️ Normal push failed for ${remote}. Force push may be required.`);
445
+ const shouldForcePush = await this.confirmForcePush(trimmedName);
445
446
 
447
+ if (shouldForcePush) {
448
+ console.log(`Force pushing tag ${trimmedName} to ${remote}...`);
449
+ pushSuccess = await GitService.forcePushTag(trimmedName, remote);
450
+ }
451
+ }
446
452
  if (pushSuccess) {
447
453
  console.log(`✅ Tag ${trimmedName} pushed to ${remote} successfully!`);
448
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
+ }