ai-git-tools 2.0.65 → 2.0.67

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.65",
3
+ "version": "2.0.67",
4
4
  "description": "AI-powered Git automation tools for commit messages and PR generation",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -7,7 +7,6 @@ import { resolve, basename, dirname, join } from 'path';
7
7
  import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'fs';
8
8
  import { execSync } from 'child_process';
9
9
  import { tmpdir } from 'os';
10
- import inquirer from 'inquirer';
11
10
  import { TestGenerator } from '../core/test-generator.js';
12
11
  import { TestRunner } from '../core/test-runner.js';
13
12
  import { AIClient } from '../core/ai-client.js';
@@ -108,60 +107,72 @@ export async function writeAndTestCommand(options = {}) {
108
107
 
109
108
  // 自動從 state 檔讀取 file / issue(指令列將覆蓋)
110
109
  const state = loadDevState();
111
- const filePath = options.file || state?.filePath;
112
110
  const issueNumber = options.issue || state?.issueNumber;
113
- const fromState = !options.file && state?.filePath;
114
111
  const maxFixes = parseInt(options.maxFixes || '2', 10);
115
112
 
116
- if (!filePath) {
117
- logger.error('請提供原始碼路徑(--file <path>)或先執行 dev-from-issue');
113
+ // 決定要處理的檔案清單
114
+ let sourceFiles = [];
115
+ let fromState = false;
116
+ let fromGit = false;
117
+
118
+ if (options.file) {
119
+ // 明確指定單一檔案
120
+ sourceFiles = [options.file];
121
+ } else if (state?.filePath) {
122
+ // 從上一步 dev-from-issue 讀取
123
+ sourceFiles = [state.filePath];
124
+ fromState = true;
125
+ } else {
126
+ // 從 git 偵測所有異動的原始碼檔案
127
+ sourceFiles = await detectChangedSourceFiles(logger);
128
+ fromGit = sourceFiles.length > 0;
129
+ }
130
+
131
+ if (sourceFiles.length === 0) {
132
+ logger.error('偵測不到異動的原始碼,請用 --file 指定目標檔案。');
118
133
  throw new Error('缺少原始碼路徑');
119
134
  }
120
135
 
136
+ logger.header('write-and-test');
137
+ if (fromState) {
138
+ logger.info('從上一步 dev-from-issue 自動讀取');
139
+ } else if (fromGit) {
140
+ logger.info(`從 git 異動記錄偵測到 ${sourceFiles.length} 個檔案`);
141
+ }
142
+ console.log('');
143
+
144
+ const allResults = [];
145
+ for (const filePath of sourceFiles) {
146
+ const result = await processOneFile({ filePath, issueNumber, maxFixes, fromState, fromGit, options, logger });
147
+ allResults.push(result);
148
+ }
149
+
150
+ return allResults.length === 1 ? allResults[0] : allResults;
151
+ }
152
+
153
+ async function processOneFile({ filePath, issueNumber, maxFixes, fromState, fromGit, options, logger }) {
121
154
  const absoluteSourcePath = resolve(process.cwd(), filePath);
122
155
  if (!existsSync(absoluteSourcePath)) {
123
156
  logger.error(`原始碼不存在:${absoluteSourcePath}`);
124
157
  throw new Error(`找不到原始碼:${absoluteSourcePath}`);
125
158
  }
126
159
 
127
- // 自動從 state 讀取時,測試類型預設為 both
128
- const resolvedTestTypes = resolveRequestedTestTypes(absoluteSourcePath, options.testType || (fromState ? 'both' : 'auto'));
160
+ // 自動偵測時預設 both,手動指定時依 --test-type
161
+ const resolvedTestTypes = resolveRequestedTestTypes(absoluteSourcePath, options.testType || (fromState || fromGit ? 'both' : 'auto'));
129
162
  const testTargets = resolvedTestTypes.map((testType) => ({
130
163
  testType,
131
164
  testFilePath: deriveTestFilePath(absoluteSourcePath, testType),
132
165
  }));
133
166
 
134
- logger.header('write-and-test');
135
- if (fromState) {
136
- logger.info('從上一步 dev-from-issue 自動讀取');
137
- }
138
- console.log(`原始碼 :${absoluteSourcePath}`);
139
- console.log(`測試類型:${formatTestTypes(resolvedTestTypes)}`);
167
+ console.log(`── ${filePath}`);
168
+ console.log(` 測試類型:${formatTestTypes(resolvedTestTypes)}`);
140
169
  testTargets.forEach(({ testType, testFilePath }) => {
141
- console.log(`${testType === 'component' ? '元件測試' : '單元測試'}:${testFilePath}`);
170
+ console.log(` ${testType === 'component' ? '元件測試' : '單元測試'}:${testFilePath}`);
142
171
  });
143
- console.log(`最大修復:${maxFixes} 次`);
144
- if (issueNumber) {
145
- console.log(`報告 Issue:#${issueNumber}`);
146
- }
147
172
  console.log('');
148
173
 
149
- // 1. 詢問是否繼續(自動從 state 讀取時跳過)
150
- if (!options.noConfirm && !fromState) {
151
- const { confirmed } = await inquirer.prompt([{
152
- type: 'confirm',
153
- name: 'confirmed',
154
- message: `確認為 ${filePath} 生成 ${formatTestTypes(resolvedTestTypes)} 並執行?`,
155
- default: true,
156
- }]);
157
-
158
- if (!confirmed) {
159
- logger.info('已取消。');
160
- return;
161
- }
162
- }
163
174
 
164
- // 2. 生成測試
175
+ // 1. 生成測試
165
176
  logger.step('正在生成測試代碼...');
166
177
  const generatedTests = [];
167
178
  for (const { testType, testFilePath } of testTargets) {
@@ -176,7 +187,7 @@ export async function writeAndTestCommand(options = {}) {
176
187
  }
177
188
  logger.info(`實際測試類型:${formatTestTypes(generatedTests.map(({ testType }) => testType))}`);
178
189
 
179
- // 3. 執行測試 + 自動修復循環
190
+ // 2. 執行測試 + 自動修復循環
180
191
  let lastErrors = [];
181
192
  for (let attempt = 0; attempt <= maxFixes; attempt++) {
182
193
  if (attempt > 0) {
@@ -355,3 +366,57 @@ export function deriveTestFilePath(sourceFilePath, testType = 'unit') {
355
366
  const suffix = testType === 'component' ? 'component' : 'unit';
356
367
  return join(dir, '__tests__', `${name}.${suffix}.test${ext}`);
357
368
  }
369
+
370
+ /**
371
+ * 透過 git 偵測所有異動的原始碼檔案(回傳陣列)
372
+ * 優先順序:staged → unstaged → untracked → 最新 commit
373
+ * 排除測試檔、設定檔、lock 檔
374
+ */
375
+ async function detectChangedSourceFiles(logger) {
376
+ const SOURCE_EXTENSIONS = /\.(js|jsx|ts|tsx|mjs|cjs)$/;
377
+ const EXCLUDED = /(__tests__|\.test\.|\.spec\.|\.config\.|node_modules|dist\/|\.lock$|package\.json)/;
378
+
379
+ function filterSourceFiles(raw) {
380
+ return raw
381
+ .split('\n')
382
+ .map((f) => f.trim().replace(/^[MADRCU?!\s]+/, ''))
383
+ .filter((f) => f && SOURCE_EXTENSIONS.test(f) && !EXCLUDED.test(f) && existsSync(resolve(process.cwd(), f)));
384
+ }
385
+
386
+ try {
387
+ // 1. staged 檔案
388
+ const staged = filterSourceFiles(
389
+ execSync('git diff --cached --name-only', { encoding: 'utf-8', stdio: 'pipe', cwd: process.cwd() })
390
+ );
391
+ if (staged.length > 0) {
392
+ logger.info(`偵測到 staged 異動 ${staged.length} 個檔案`);
393
+ return staged;
394
+ }
395
+
396
+ // 2. unstaged 工作區異動(含 untracked 合併)
397
+ const unstaged = filterSourceFiles(
398
+ execSync('git diff --name-only', { encoding: 'utf-8', stdio: 'pipe', cwd: process.cwd() })
399
+ );
400
+ const untracked = filterSourceFiles(
401
+ execSync('git ls-files --others --exclude-standard', { encoding: 'utf-8', stdio: 'pipe', cwd: process.cwd() })
402
+ );
403
+ const working = [...new Set([...unstaged, ...untracked])];
404
+ if (working.length > 0) {
405
+ logger.info(`偵測到工作區異動 ${working.length} 個檔案`);
406
+ return working;
407
+ }
408
+
409
+ // 3. 最新 commit 的異動
410
+ const lastCommit = filterSourceFiles(
411
+ execSync('git diff-tree --no-commit-id -r --name-only HEAD', { encoding: 'utf-8', stdio: 'pipe', cwd: process.cwd() })
412
+ );
413
+ if (lastCommit.length > 0) {
414
+ logger.info(`偵測到最後 commit 異動 ${lastCommit.length} 個檔案`);
415
+ return lastCommit;
416
+ }
417
+ } catch {
418
+ // git 取得失敗,直接回傳空陣列
419
+ }
420
+
421
+ return [];
422
+ }