ai-git-tools 2.0.24 → 2.0.26

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-git-tools",
3
- "version": "2.0.24",
3
+ "version": "2.0.26",
4
4
  "description": "AI-powered Git automation tools for commit messages and PR generation",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -14,8 +14,16 @@ import { handleError } from '../utils/helpers.js';
14
14
  /**
15
15
  * 獲取檔案的變更內容
16
16
  */
17
- function getFileDiff(filePath, isNew) {
17
+ function getFileDiff(filePath, isNew, isDeleted) {
18
18
  try {
19
+ if (isDeleted) {
20
+ // 刪除的檔案:顯示刪除前的內容(前 50 行)
21
+ const diff = execSync(`git show HEAD:"${filePath}"`, {
22
+ encoding: 'utf-8',
23
+ }).toString();
24
+ const lines = diff.split('\n').slice(0, 50);
25
+ return `[已刪除]\n${lines.join('\n')}${lines.length >= 50 ? '\n...' : ''}`;
26
+ }
19
27
  if (isNew) {
20
28
  // 新檔案:讀取完整內容(前 100 行)
21
29
  const content = readFileSync(filePath, 'utf-8');
@@ -52,11 +60,6 @@ function getAllChanges() {
52
60
  const statusCode = line.substring(0, 2);
53
61
  const filePath = line.substring(3).trim();
54
62
 
55
- // 跳過已刪除的檔案
56
- if (statusCode.includes('D')) {
57
- continue;
58
- }
59
-
60
63
  // 跳過某些不需要提交的檔案
61
64
  if (
62
65
  filePath.includes('node_modules/') ||
@@ -68,11 +71,13 @@ function getAllChanges() {
68
71
  }
69
72
 
70
73
  const isNew = statusCode.includes('?') || statusCode.includes('A');
74
+ const isDeleted = statusCode.includes('D');
71
75
  const isStaged = statusCode[0] !== ' ' && statusCode[0] !== '?';
72
76
 
73
77
  changes.push({
74
78
  filePath,
75
79
  isNew,
80
+ isDeleted,
76
81
  isStaged,
77
82
  statusCode,
78
83
  });
@@ -95,12 +100,13 @@ async function analyzeAndGroupChanges(changes, config) {
95
100
  const maxDiffPerFile = Math.floor(config.ai.maxDiffLength / Math.max(changes.length, 1));
96
101
  const changeSummary = changes
97
102
  .map((change, index) => {
98
- const diff = getFileDiff(change.filePath, change.isNew);
103
+ const diff = getFileDiff(change.filePath, change.isNew, change.isDeleted);
99
104
  const lines = diff.split('\n');
100
105
  const truncatedDiff = lines.slice(0, Math.min(50, maxDiffPerFile / 100)).join('\n');
101
- return `[檔案 ${index}] ${change.filePath}\n${
102
- change.isNew ? '(新檔案)' : '(已修改)'
103
- }\n${truncatedDiff}\n`;
106
+ let status = '(已修改)';
107
+ if (change.isNew) status = '(新檔案)';
108
+ if (change.isDeleted) status = '(已刪除)';
109
+ return `[檔案 ${index}] ${change.filePath}\n${status}\n${truncatedDiff}\n`;
104
110
  })
105
111
  .join('\n---\n\n');
106
112
 
@@ -176,8 +182,11 @@ ${changeSummary}
176
182
  async function generateCommitMessage(group, files, config) {
177
183
  const filesList = files
178
184
  .map((file) => {
179
- const diff = getFileDiff(file.filePath, file.isNew);
180
- return `檔案: ${file.filePath}\n${diff}`;
185
+ const diff = getFileDiff(file.filePath, file.isNew, file.isDeleted);
186
+ let status = '修改';
187
+ if (file.isNew) status = '新增';
188
+ if (file.isDeleted) status = '刪除';
189
+ return `檔案: ${file.filePath} [${status}]\n${diff}`;
181
190
  })
182
191
  .join('\n\n---\n\n');
183
192
 
@@ -238,54 +247,71 @@ async function commitGroup(group, files, config) {
238
247
  // 忽略錯誤(可能沒有 staged 的檔案)
239
248
  }
240
249
 
241
- // Add 這組的檔案
242
- for (const file of files) {
243
- console.log(` ├─ ${file.filePath}`);
244
- try {
245
- // 使用 JSON.stringify 來正確處理包含空格或特殊字符的檔案路徑
246
- execSync(`git add ${JSON.stringify(file.filePath)}`, { encoding: 'utf-8' });
247
- } catch (addError) {
248
- console.error(` ⚠️ 無法加入檔案: ${file.filePath}`, addError.message);
249
- throw addError;
250
+ // Add 這組的檔案(使用 try-finally 確保失敗時清理)
251
+ const addedFiles = [];
252
+ try {
253
+ for (const file of files) {
254
+ const fileStatus = file.isNew ? '新增' : file.isDeleted ? '刪除' : '修改';
255
+ console.log(` ├─ [${fileStatus}] ${file.filePath}`);
256
+ try {
257
+ // 使用 JSON.stringify 來正確處理包含空格或特殊字符的檔案路徑
258
+ // git add 對於刪除的檔案也能正確處理
259
+ execSync(`git add ${JSON.stringify(file.filePath)}`, { encoding: 'utf-8' });
260
+ addedFiles.push(file.filePath);
261
+ } catch (addError) {
262
+ console.error(` ⚠️ 無法加入檔案: ${file.filePath}`, addError.message);
263
+ throw addError;
264
+ }
250
265
  }
251
- }
252
266
 
253
- // 生成 commit message
254
- console.log(` └─ 生成 commit message...`);
255
- const commitMessage = await generateCommitMessage(group, files, config);
256
-
257
- if (!commitMessage) {
258
- console.log(` ❌ 無法生成 commit message,跳過此群組`);
259
- return false;
260
- }
267
+ // 生成 commit message
268
+ console.log(` └─ 生成 commit message...`);
269
+ const commitMessage = await generateCommitMessage(group, files, config);
261
270
 
262
- console.log(`\n 📝 Commit Message:`);
263
- console.log(` ${'─'.repeat(50)}`);
264
- commitMessage.split('\n').forEach((line) => {
265
- console.log(` ${line}`);
266
- });
267
- console.log(` ${'─'.repeat(50)}`);
271
+ if (!commitMessage) {
272
+ console.log(` ❌ 無法生成 commit message,跳過此群組`);
273
+ return false;
274
+ }
268
275
 
269
- // 執行 commit
270
- // 使用暫存檔案避免 commit message 中的特殊字元問題
271
- const tmpFile = '.git/COMMIT_EDITMSG_TMP';
272
- try {
273
- writeFileSync(tmpFile, commitMessage, 'utf-8');
274
- execSync(`git commit -F ${tmpFile}`, {
275
- stdio: 'inherit',
276
+ console.log(`\n 📝 Commit Message:`);
277
+ console.log(` ${'─'.repeat(50)}`);
278
+ commitMessage.split('\n').forEach((line) => {
279
+ console.log(` ${line}`);
276
280
  });
277
- unlinkSync(tmpFile);
278
- } catch (commitError) {
281
+ console.log(` ${'─'.repeat(50)}`);
282
+
283
+ // 執行 commit
284
+ // 使用暫存檔案避免 commit message 中的特殊字元問題
285
+ const tmpFile = '.git/COMMIT_EDITMSG_TMP';
279
286
  try {
287
+ writeFileSync(tmpFile, commitMessage, 'utf-8');
288
+ execSync(`git commit -F ${tmpFile}`, {
289
+ stdio: 'inherit',
290
+ });
280
291
  unlinkSync(tmpFile);
281
- } catch (e) {
282
- // 忽略刪除暫存檔案的錯誤
292
+ } catch (commitError) {
293
+ try {
294
+ unlinkSync(tmpFile);
295
+ } catch (e) {
296
+ // 忽略刪除暫存檔案的錯誤
297
+ }
298
+ throw commitError;
283
299
  }
284
- throw commitError;
285
- }
286
300
 
287
- console.log(` ✅ Commit 完成!`);
288
- return true;
301
+ console.log(` ✅ Commit 完成!`);
302
+ return true;
303
+ } catch (error) {
304
+ // 如果失敗,unstage 所有已經 add 的檔案
305
+ if (addedFiles.length > 0) {
306
+ console.log(` 🔄 清理已 staged 的檔案...`);
307
+ try {
308
+ execSync('git reset HEAD -- .', { stdio: 'ignore' });
309
+ } catch (e) {
310
+ // 忽略 reset 錯誤
311
+ }
312
+ }
313
+ throw error;
314
+ }
289
315
  } catch (error) {
290
316
  console.error(` ❌ Commit 失敗:`, error.message);
291
317
  return false;
@@ -318,12 +344,14 @@ export async function commitAllCommand() {
318
344
 
319
345
  if (changes.length === 0) {
320
346
  logger.info('沒有需要提交的變更');
321
- process.exit(0);
347
+ return;
322
348
  }
323
349
 
324
350
  console.log(`📊 找到 ${changes.length} 個變更的檔案:\n`);
325
351
  changes.forEach((change, index) => {
326
- const status = change.isNew ? '新增' : '修改';
352
+ let status = '修改';
353
+ if (change.isNew) status = '新增';
354
+ if (change.isDeleted) status = '刪除';
327
355
  console.log(` [${index}] ${status} - ${change.filePath}`);
328
356
  });
329
357
  console.log();
@@ -333,7 +361,7 @@ export async function commitAllCommand() {
333
361
 
334
362
  if (!groups || groups.length === 0) {
335
363
  logger.error('AI 分析失敗或沒有產生分組');
336
- process.exit(1);
364
+ throw new Error('AI 分析失敗或沒有產生分組');
337
365
  }
338
366
 
339
367
  // 驗證所有檔案都被包含在分組中
@@ -428,6 +456,6 @@ export async function commitAllCommand() {
428
456
  }
429
457
  } catch (error) {
430
458
  handleError(error);
431
- process.exit(1);
459
+ throw error;
432
460
  }
433
461
  }
@@ -31,7 +31,7 @@ export async function commitCommand() {
31
31
  if (!diff.trim()) {
32
32
  logger.error('沒有 staged 的變更');
33
33
  console.log('💡 請先使用 git add 來 stage 你的變更');
34
- process.exit(1);
34
+ throw new Error('沒有 staged 的變更');
35
35
  }
36
36
 
37
37
  logger.step('正在分析變更內容...\n');
@@ -115,7 +115,7 @@ ${truncatedDiff}`;
115
115
  console.log(' 1. 檢查網路連線');
116
116
  console.log(' 2. 嘗試更換 AI 模型(使用 --model 參數)');
117
117
  console.log(' 3. 確認變更內容不會太複雜或太大');
118
- process.exit(1);
118
+ throw new Error('無法產生有效的 commit message');
119
119
  }
120
120
 
121
121
  logger.success('產生的 Commit Message:');
@@ -132,6 +132,6 @@ ${truncatedDiff}`;
132
132
  logger.success('Commit 完成!\n');
133
133
  } catch (error) {
134
134
  handleError(error);
135
- process.exit(1);
135
+ throw error;
136
136
  }
137
137
  }
@@ -33,6 +33,6 @@ export async function prCommand() {
33
33
  await workflow.execute();
34
34
  } catch (error) {
35
35
  handleError(error);
36
- process.exit(1);
36
+ throw error;
37
37
  }
38
38
  }
@@ -7,17 +7,23 @@ import { CopilotClient } from '@github/copilot-sdk';
7
7
 
8
8
  export class AIClient {
9
9
  /**
10
- * 發送 prompt 並等待回應(帶重試機制)
10
+ * 發送 prompt 並等待回應(帶重試機制和超時保護)
11
11
  */
12
- static async sendAndWait(prompt, model = 'gpt-4.1', maxRetries = 3) {
13
- const client = new CopilotClient();
12
+ static async sendAndWait(prompt, model = 'gpt-4.1', maxRetries = 3, timeout = 60000) {
14
13
  let lastError = null;
15
14
 
16
15
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
16
+ const client = new CopilotClient();
17
17
  try {
18
18
  const session = await client.createSession({ model });
19
- const response = await session.sendAndWait({ prompt });
20
- await client.stop();
19
+
20
+ // 使用 Promise.race 實現超時控制
21
+ const responsePromise = session.sendAndWait({ prompt });
22
+ const timeoutPromise = new Promise((_, reject) => {
23
+ setTimeout(() => reject(new Error(`AI 請求超時 (${timeout}ms)`)), timeout);
24
+ });
25
+
26
+ const response = await Promise.race([responsePromise, timeoutPromise]);
21
27
 
22
28
  const content = response?.data?.content || '';
23
29
  return content.trim();
@@ -26,7 +32,13 @@ export class AIClient {
26
32
  if (attempt < maxRetries) {
27
33
  console.log(`⚠️ AI 請求失敗,重試第 ${attempt}/${maxRetries} 次...`);
28
34
  await new Promise((resolve) => setTimeout(resolve, 1000 * attempt));
29
- continue;
35
+ }
36
+ } finally {
37
+ // 確保每次都關閉 client,無論成功或失敗
38
+ try {
39
+ await client.stop();
40
+ } catch (e) {
41
+ // 忽略關閉錯誤
30
42
  }
31
43
  }
32
44
  }
@@ -29,19 +29,27 @@ export class AIAnalyzer {
29
29
  const { client, session } = await this.createClient();
30
30
 
31
31
  try {
32
- const response = await session.sendAndWait({ prompt });
33
- const prContent = response?.data.content?.trim() || '';
32
+ // 使用超時保護 (60 )
33
+ const responsePromise = session.sendAndWait({ prompt });
34
+ const timeoutPromise = new Promise((_, reject) => {
35
+ setTimeout(() => reject(new Error('AI 請求超時 (60 秒)')), 60000);
36
+ });
34
37
 
35
- await client.stop();
38
+ const response = await Promise.race([responsePromise, timeoutPromise]);
39
+ const prContent = response?.data.content?.trim() || '';
36
40
 
37
41
  if (!prContent) {
38
42
  throw new Error('AI 未能生成 PR 內容');
39
43
  }
40
44
 
41
45
  return this.parsePRContent(prContent);
42
- } catch (error) {
43
- await client.stop();
44
- throw error;
46
+ } finally {
47
+ // 確保 client 一定會被關閉
48
+ try {
49
+ await client.stop();
50
+ } catch (e) {
51
+ // 忽略關閉錯誤
52
+ }
45
53
  }
46
54
  }
47
55
 
@@ -56,10 +64,15 @@ export class AIAnalyzer {
56
64
 
57
65
  try {
58
66
  log.info(' 正在使用 AI 深度分析程式碼變更...');
59
- const response = await session.sendAndWait({ prompt });
60
- const content = response?.data.content?.trim() || '';
67
+
68
+ // 使用超時保護 (60 )
69
+ const responsePromise = session.sendAndWait({ prompt });
70
+ const timeoutPromise = new Promise((_, reject) => {
71
+ setTimeout(() => reject(new Error('AI 請求超時 (60 秒)')), 60000);
72
+ });
61
73
 
62
- await client.stop();
74
+ const response = await Promise.race([responsePromise, timeoutPromise]);
75
+ const content = response?.data.content?.trim() || '';
63
76
 
64
77
  // 解析 JSON
65
78
  try {
@@ -85,9 +98,15 @@ export class AIAnalyzer {
85
98
  return this.getFallbackAnalysis(changedFiles);
86
99
  }
87
100
  } catch (error) {
88
- await client.stop();
89
101
  log.warning(` AI 分析失敗 (${error.message}),使用基礎分析...\n`);
90
102
  return this.getFallbackAnalysis(changedFiles);
103
+ } finally {
104
+ // 確保 client 一定會被關閉
105
+ try {
106
+ await client.stop();
107
+ } catch (e) {
108
+ // 忽略關閉錯誤
109
+ }
91
110
  }
92
111
  }
93
112
 
@@ -49,14 +49,16 @@ export class GitOperations {
49
49
  /**
50
50
  * 獲取變更統計
51
51
  */
52
- getChangeStats(baseBranch, headBranch) {
52
+ getChangeStats(baseBranch, headBranch, useRemoteHead = false) {
53
53
  try {
54
- const stats = execSync(`git diff --shortstat origin/${baseBranch}...${headBranch}`, {
54
+ // 使用本地 headBranch 以確保能檢測到未推送的變更
55
+ const headRef = useRemoteHead ? `origin/${headBranch}` : headBranch;
56
+ const stats = execSync(`git diff --shortstat origin/${baseBranch}...${headRef}`, {
55
57
  encoding: 'utf-8',
56
58
  }).trim();
57
59
 
58
60
  const filesChanged = execSync(
59
- `git diff --name-only origin/${baseBranch}...${headBranch} | wc -l`,
61
+ `git diff --name-only origin/${baseBranch}...${headRef} | wc -l`,
60
62
  { encoding: 'utf-8' }
61
63
  ).trim();
62
64
 
@@ -69,9 +71,11 @@ export class GitOperations {
69
71
  /**
70
72
  * 獲取變更的檔案列表
71
73
  */
72
- getChangedFiles(baseBranch, headBranch) {
74
+ getChangedFiles(baseBranch, headBranch, useRemoteHead = false) {
73
75
  try {
74
- const files = execSync(`git diff --name-only origin/${baseBranch}...${headBranch}`, {
76
+ // 使用本地 headBranch 以確保能檢測到未推送的變更
77
+ const headRef = useRemoteHead ? `origin/${headBranch}` : headBranch;
78
+ const files = execSync(`git diff --name-only origin/${baseBranch}...${headRef}`, {
75
79
  encoding: 'utf-8',
76
80
  })
77
81
  .split('\n')
@@ -86,9 +90,11 @@ export class GitOperations {
86
90
  * 獲取 commit 列表
87
91
  */
88
92
  getCommits(baseBranch, headBranch, options = {}) {
89
- const { oneline = true, noDecorate = true } = options;
93
+ const { oneline = true, noDecorate = true, useRemoteHead = false } = options;
90
94
  try {
91
- let cmd = `git log origin/${baseBranch}..origin/${headBranch}`;
95
+ // 使用本地 headBranch 以確保能檢測到未推送的 commit
96
+ const headRef = useRemoteHead ? `origin/${headBranch}` : headBranch;
97
+ let cmd = `git log origin/${baseBranch}..${headRef}`;
92
98
  if (oneline) cmd += ' --oneline';
93
99
  if (noDecorate) cmd += ' --no-decorate';
94
100
 
@@ -98,7 +104,7 @@ export class GitOperations {
98
104
  '無法比較分支差異',
99
105
  'GIT_COMPARE_FAILED',
100
106
  ['檢查遠端分支是否存在: git branch -r', '執行診斷: npm run diagnose:pr'],
101
- `git log origin/${baseBranch}..origin/${headBranch}`
107
+ `git log origin/${baseBranch}..${headBranch}`
102
108
  );
103
109
  }
104
110
  }
@@ -106,15 +112,18 @@ export class GitOperations {
106
112
  /**
107
113
  * 獲取 diff
108
114
  */
109
- getDiff(baseBranch, headBranch, maxBuffer = CONSTANTS.MAX_BUFFER_SIZE) {
115
+ getDiff(baseBranch, headBranch, useRemoteHead = false, maxBuffer = CONSTANTS.MAX_BUFFER_SIZE) {
110
116
  try {
111
- return execSync(`git diff origin/${baseBranch}...${headBranch}`, {
117
+ // 使用本地 headBranch 以確保能檢測到未推送的變更
118
+ const headRef = useRemoteHead ? `origin/${headBranch}` : headBranch;
119
+ return execSync(`git diff origin/${baseBranch}...${headRef}`, {
112
120
  encoding: 'utf-8',
113
121
  maxBuffer,
114
122
  });
115
123
  } catch (error) {
116
124
  // 嘗試替代方案
117
- return execSync(`git diff origin/${baseBranch}..${headBranch}`, {
125
+ const headRef = useRemoteHead ? `origin/${headBranch}` : headBranch;
126
+ return execSync(`git diff origin/${baseBranch}..${headRef}`, {
118
127
  encoding: 'utf-8',
119
128
  maxBuffer,
120
129
  });
@@ -1,4 +1,5 @@
1
1
  import { createInterface } from 'readline';
2
+ import { execSync } from 'child_process';
2
3
  import { GitOperations } from './git-operations.js';
3
4
  import { GitHubAPI } from './github-api.js';
4
5
  import { AIAnalyzer } from '../ai/code-analyzer.js';
@@ -202,12 +203,35 @@ export class PRWorkflow {
202
203
  await this.git.fetch();
203
204
  log.success('同步完成\n');
204
205
 
205
- // 檢查本地 commit 差異
206
+ // 顯示分支狀態診斷信息
207
+ if (this.config.output?.verbose) {
208
+ console.log(`${colors.cyan}🔍 分支診斷信息:${colors.reset}`);
209
+ try {
210
+ const currentBranch = this.git.getCurrentBranch();
211
+ console.log(` 當前分支: ${currentBranch}`);
212
+ console.log(` Base 分支: origin/${baseBranch}`);
213
+ console.log(` Head 分支: ${headBranch} (本地)`);
214
+
215
+ // 檢查遠端分支是否存在
216
+ try {
217
+ execSync(`git rev-parse --verify origin/${headBranch}`, { stdio: 'pipe' });
218
+ console.log(` 遠端 ${headBranch}: ✓ 存在`);
219
+ } catch (e) {
220
+ console.log(` 遠端 ${headBranch}: ✗ 不存在(尚未推送)`);
221
+ }
222
+ } catch (e) {
223
+ // 忽略診斷錯誤
224
+ }
225
+ console.log('');
226
+ }
227
+
228
+ // 檢查本地 commit 差異(使用本地 headBranch)
206
229
  let localCommits;
207
230
  try {
208
231
  localCommits = this.git.getCommits(baseBranch, headBranch, {
209
232
  oneline: true,
210
233
  noDecorate: true,
234
+ useRemoteHead: false, // 使用本地分支以檢測未推送的commit
211
235
  });
212
236
  } catch (error) {
213
237
  throw new PRError(
@@ -261,10 +285,11 @@ export class PRWorkflow {
261
285
  * 收集變更資料
262
286
  */
263
287
  collectChangeData(baseBranch, headBranch) {
264
- const stats = this.git.getChangeStats(baseBranch, headBranch);
265
- const changedFiles = this.git.getChangedFiles(baseBranch, headBranch);
266
- const commits = this.git.getCommits(baseBranch, headBranch);
267
- const diff = this.git.getDiff(baseBranch, headBranch);
288
+ // 此時已經推送完成,使用本地分支即可(本地和遠端應該已同步)
289
+ const stats = this.git.getChangeStats(baseBranch, headBranch, false);
290
+ const changedFiles = this.git.getChangedFiles(baseBranch, headBranch, false);
291
+ const commits = this.git.getCommits(baseBranch, headBranch, { useRemoteHead: false });
292
+ const diff = this.git.getDiff(baseBranch, headBranch, false);
268
293
  const truncatedDiff = this.git.truncateDiff(diff);
269
294
 
270
295
  console.log(`📈 變更統計: ${stats.stats}`);
@@ -59,6 +59,7 @@ export class InteractiveSelect {
59
59
  const cleanup = () => {
60
60
  process.stdin.setRawMode(false);
61
61
  process.stdin.removeAllListeners('keypress');
62
+ process.stdin.pause(); // 確保 stdin stream 被關閉
62
63
  process.stdout.write(cursor.show);
63
64
  console.log('');
64
65
  };