@icode-js/icode 3.0.4 → 3.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 (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +142 -5
  3. package/bin/icode.js +4 -4
  4. package/package.json +1 -1
  5. package/src/cli.js +60 -23
  6. package/src/commands/ai.js +4 -4
  7. package/src/commands/checkout.js +2 -2
  8. package/src/commands/clean.js +5 -2
  9. package/src/commands/codereview.js +5 -2
  10. package/src/commands/completion.js +93 -0
  11. package/src/commands/config.js +123 -10
  12. package/src/commands/doctor.js +80 -0
  13. package/src/commands/explain.js +6 -3
  14. package/src/commands/help.js +17 -0
  15. package/src/commands/info.js +5 -2
  16. package/src/commands/migrate.js +40 -3
  17. package/src/commands/push.js +72 -24
  18. package/src/commands/ready.js +110 -0
  19. package/src/commands/save.js +98 -0
  20. package/src/commands/sync.js +2 -2
  21. package/src/commands/tag.js +2 -2
  22. package/src/commands/undo.js +73 -10
  23. package/src/core/{ai-client.js → ai/client.js} +3 -3
  24. package/src/core/{ai-config.js → ai/config.js} +2 -2
  25. package/src/core/ai/diff-prompt.js +204 -0
  26. package/src/core/{ai-diff-range.js → ai/diff-range.js} +2 -2
  27. package/src/core/{args.js → cli/args.js} +23 -2
  28. package/src/core/cli/completion.js +720 -0
  29. package/src/core/cli/update-notifier.js +138 -0
  30. package/src/core/{shell.js → commands/shell.js} +13 -1
  31. package/src/core/{config-store.js → config/store.js} +53 -3
  32. package/src/core/{git-context.js → git/context.js} +24 -10
  33. package/src/core/git/preconditions.js +88 -0
  34. package/src/core/{git-service.js → git/service.js} +195 -5
  35. package/src/core/remote-merge/client.js +42 -0
  36. package/src/core/remote-merge/providers/gitlab.js +596 -0
  37. package/src/core/remote-merge/shared.js +49 -0
  38. package/src/core/tools/interactive.js +368 -0
  39. package/src/core/{loading.js → tools/loading.js} +5 -1
  40. package/src/core/{logger.js → tools/logger.js} +5 -0
  41. package/src/workflows/ai-codereview-workflow.js +18 -6
  42. package/src/workflows/ai-commit-workflow.js +20 -12
  43. package/src/workflows/ai-conflict-workflow.js +4 -4
  44. package/src/workflows/ai-explain-workflow.js +4 -4
  45. package/src/workflows/ai-risk-review-workflow.js +1 -1
  46. package/src/workflows/checkout-workflow.js +17 -5
  47. package/src/workflows/clean-workflow.js +92 -75
  48. package/src/workflows/doctor-workflow.js +131 -0
  49. package/src/workflows/info-workflow.js +4 -4
  50. package/src/workflows/migrate-workflow.js +269 -62
  51. package/src/workflows/push-workflow.js +475 -58
  52. package/src/workflows/ready-workflow.js +278 -0
  53. package/src/workflows/rollback-workflow.js +24 -9
  54. package/src/workflows/save-workflow.js +152 -0
  55. package/src/workflows/sync-workflow.js +22 -6
  56. package/src/workflows/tag-workflow.js +4 -4
  57. package/src/workflows/undo-workflow.js +37 -12
  58. package/src/core/prompts.js +0 -152
  59. /package/src/core/{ai-commit-summary.js → ai/commit-summary.js} +0 -0
  60. /package/src/core/{commit-conventions.js → git/commit-conventions.js} +0 -0
  61. /package/src/core/{hook-diagnostics.js → git/hook-diagnostics.js} +0 -0
@@ -0,0 +1,368 @@
1
+ import { createInterface } from 'node:readline/promises'
2
+ import { stdin, stdout } from 'node:process'
3
+
4
+ /**
5
+ * 判断当前 stdin/stdout 是否连接到交互式终端。
6
+ * 输入为空,输出为布尔值。
7
+ */
8
+ function isInteractive() {
9
+ return Boolean(stdin.isTTY && stdout.isTTY)
10
+ }
11
+
12
+ /**
13
+ * 对外暴露交互终端判断,供工作流决定是否进入交互模式。
14
+ * 输入为空,输出为布尔值。
15
+ */
16
+ export function isInteractiveTerminal() {
17
+ return isInteractive()
18
+ }
19
+
20
+ /**
21
+ * 归一化 confirm 的默认值配置,兼容布尔值与 options.defaultValue 两种调用方式。
22
+ * 输入为 confirm 第二参数,输出为布尔默认值。
23
+ */
24
+ function resolveConfirmDefaultValue(optionsOrDefaultValue) {
25
+ if (typeof optionsOrDefaultValue === 'boolean') {
26
+ return optionsOrDefaultValue
27
+ }
28
+
29
+ if (optionsOrDefaultValue && typeof optionsOrDefaultValue === 'object') {
30
+ return optionsOrDefaultValue.defaultValue === true
31
+ }
32
+
33
+ return false
34
+ }
35
+
36
+ /**
37
+ * 读取确认输入并返回布尔结果;非交互环境直接返回默认值。
38
+ * 输入为提示文案与默认值配置,输出为用户是否确认。
39
+ */
40
+ export async function confirm(message, optionsOrDefaultValue = false) {
41
+ const defaultValue = resolveConfirmDefaultValue(optionsOrDefaultValue)
42
+ if (!isInteractive()) {
43
+ return defaultValue
44
+ }
45
+
46
+ const rl = createInterface({ input: stdin, output: stdout })
47
+ const suffix = defaultValue ? '[Y/n]' : '[y/N]'
48
+ const answer = (await rl.question(`${message} ${suffix} `)).trim().toLowerCase()
49
+ rl.close()
50
+
51
+ if (!answer) {
52
+ return defaultValue
53
+ }
54
+
55
+ if (['y', 'yes', '1', 'true', 'ok', 'sure', '是'].includes(answer)) {
56
+ return true
57
+ }
58
+
59
+ if (['n', 'no', '0', 'false', '否'].includes(answer)) {
60
+ return false
61
+ }
62
+
63
+ return defaultValue
64
+ }
65
+
66
+ /**
67
+ * 读取一行文本输入;非交互环境直接返回默认值。
68
+ * 输入为提示文案与默认值,输出为归一化后的文本。
69
+ */
70
+ export async function input(message, defaultValue = '') {
71
+ if (!isInteractive()) {
72
+ return defaultValue
73
+ }
74
+
75
+ const rl = createInterface({ input: stdin, output: stdout })
76
+ const answer = await rl.question(`${message}${defaultValue ? ` (${defaultValue})` : ''}: `)
77
+ rl.close()
78
+ const normalized = answer.trim()
79
+ return normalized || defaultValue
80
+ }
81
+
82
+ /**
83
+ * 在给定选项中返回单个值;非交互环境返回默认索引对应值。
84
+ * 输入为提示文案、选项列表与默认索引,输出为选中的 value。
85
+ */
86
+ export async function chooseOne(message, choices, defaultIndex = 0) {
87
+ if (!Array.isArray(choices) || choices.length === 0) {
88
+ throw new Error('chooseOne 需要至少一个可选项')
89
+ }
90
+
91
+ const safeDefaultIndex = Math.min(Math.max(defaultIndex, 0), choices.length - 1)
92
+ if (!isInteractive()) {
93
+ return choices[safeDefaultIndex].value
94
+ }
95
+
96
+ stdout.write(`${message}\n`)
97
+ choices.forEach((choice, index) => {
98
+ stdout.write(` ${index + 1}. ${choice.label}\n`)
99
+ })
100
+
101
+ const rl = createInterface({ input: stdin, output: stdout })
102
+ const answer = (await rl.question(`请选择 [${safeDefaultIndex + 1}]: `)).trim()
103
+ rl.close()
104
+
105
+ if (!answer) {
106
+ return choices[safeDefaultIndex].value
107
+ }
108
+
109
+ const numeric = Number(answer)
110
+ if (!Number.isInteger(numeric) || numeric < 1 || numeric > choices.length) {
111
+ return choices[safeDefaultIndex].value
112
+ }
113
+
114
+ return choices[numeric - 1].value
115
+ }
116
+
117
+ /**
118
+ * 按选项顺序输出已选 value。
119
+ * 输入为选项与已选集合,输出为稳定顺序的 value 数组。
120
+ */
121
+ function collectSelectedValues(choices, selected, maxSelections) {
122
+ return choices
123
+ .map((choice) => choice.value)
124
+ .filter((value) => selected.has(value))
125
+ .slice(0, maxSelections)
126
+ }
127
+
128
+ /**
129
+ * 使用旧版数字菜单进行多选,供不支持 raw mode 的终端回退。
130
+ * 输入为多选上下文,输出为已选 value 数组或 null。
131
+ */
132
+ async function chooseManyByNumberInput(message, choices, context) {
133
+ const selected = new Set(context.selectedValues)
134
+ const doneValue = '__prompt_done__'
135
+ const cancelValue = '__prompt_cancel__'
136
+
137
+ while (true) {
138
+ const menuChoices = choices.map((choice) => ({
139
+ value: choice.value,
140
+ label: `${selected.has(choice.value) ? '[x]' : '[ ]'} ${choice.label}`
141
+ }))
142
+ menuChoices.push({
143
+ value: doneValue,
144
+ label: `${context.doneLabel}(已选 ${selected.size} 项)`
145
+ })
146
+ menuChoices.push({
147
+ value: cancelValue,
148
+ label: context.cancelLabel
149
+ })
150
+
151
+ const defaultChoiceValue = selected.size >= context.minSelections ? doneValue : choices[0].value
152
+ const resolvedDefaultIndex = Math.max(0, menuChoices.findIndex((item) => item.value === defaultChoiceValue))
153
+ const selectedAction = await chooseOne(message, menuChoices, resolvedDefaultIndex)
154
+
155
+ if (selectedAction === cancelValue) {
156
+ return null
157
+ }
158
+
159
+ if (selectedAction === doneValue) {
160
+ if (selected.size < context.minSelections) {
161
+ stdout.write(`至少需要选择 ${context.minSelections} 项。\n`)
162
+ continue
163
+ }
164
+ return collectSelectedValues(choices, selected, context.maxSelections)
165
+ }
166
+
167
+ if (selected.has(selectedAction)) {
168
+ selected.delete(selectedAction)
169
+ continue
170
+ }
171
+
172
+ if (selected.size >= context.maxSelections) {
173
+ stdout.write(`最多只能选择 ${context.maxSelections} 项。\n`)
174
+ continue
175
+ }
176
+
177
+ selected.add(selectedAction)
178
+ }
179
+ }
180
+
181
+ /**
182
+ * 渲染 checkbox 多选列表。
183
+ * 输入为当前光标和选择状态,输出为本次渲染占用的行数。
184
+ */
185
+ function renderCheckboxChoices(message, choices, context, cursorIndex, selected, statusMessage, renderedLines) {
186
+ if (renderedLines > 0) {
187
+ stdout.write(`\x1b[${renderedLines}F`)
188
+ stdout.write('\x1b[J')
189
+ }
190
+
191
+ const lines = [
192
+ message,
193
+ `↑/↓ 移动,Space 选中/取消,Enter ${context.doneLabel},q ${context.cancelLabel}(已选 ${selected.size} 项)`
194
+ ]
195
+
196
+ if (statusMessage) {
197
+ lines.push(statusMessage)
198
+ }
199
+
200
+ choices.forEach((choice, index) => {
201
+ const cursor = index === cursorIndex ? '>' : ' '
202
+ const checkbox = selected.has(choice.value) ? '[x]' : '[ ]'
203
+ lines.push(`${cursor} ${checkbox} ${choice.label}`)
204
+ })
205
+
206
+ stdout.write(`${lines.join('\n')}\n`)
207
+ return lines.length
208
+ }
209
+
210
+ /**
211
+ * 使用键盘 checkbox 进行多选。
212
+ * 输入为多选上下文,输出为已选 value 数组或 null。
213
+ */
214
+ function chooseManyByCheckbox(message, choices, context) {
215
+ const selected = new Set(context.selectedValues)
216
+ let cursorIndex = 0
217
+ let renderedLines = 0
218
+ let statusMessage = ''
219
+ let closed = false
220
+ const wasRaw = stdin.isRaw === true
221
+
222
+ return new Promise((resolve) => {
223
+ const finish = (value) => {
224
+ if (closed) {
225
+ return
226
+ }
227
+
228
+ closed = true
229
+ stdin.off('data', onData)
230
+ stdin.setRawMode(wasRaw)
231
+ stdin.pause()
232
+ resolve(value)
233
+ }
234
+
235
+ const render = () => {
236
+ renderedLines = renderCheckboxChoices(message, choices, context, cursorIndex, selected, statusMessage, renderedLines)
237
+ }
238
+
239
+ const moveCursor = (offset) => {
240
+ statusMessage = ''
241
+ cursorIndex = (cursorIndex + offset + choices.length) % choices.length
242
+ render()
243
+ }
244
+
245
+ const toggleCurrent = () => {
246
+ statusMessage = ''
247
+ const currentValue = choices[cursorIndex].value
248
+ if (selected.has(currentValue)) {
249
+ selected.delete(currentValue)
250
+ render()
251
+ return
252
+ }
253
+
254
+ if (selected.size >= context.maxSelections) {
255
+ statusMessage = `最多只能选择 ${context.maxSelections} 项。`
256
+ render()
257
+ return
258
+ }
259
+
260
+ selected.add(currentValue)
261
+ render()
262
+ }
263
+
264
+ const complete = () => {
265
+ if (selected.size < context.minSelections) {
266
+ statusMessage = `至少需要选择 ${context.minSelections} 项。`
267
+ render()
268
+ return
269
+ }
270
+ finish(collectSelectedValues(choices, selected, context.maxSelections))
271
+ }
272
+
273
+ function onData(chunk) {
274
+ const input = chunk.toString('utf8')
275
+
276
+ for (let index = 0; index < input.length; index += 1) {
277
+ if (closed) {
278
+ return
279
+ }
280
+
281
+ const sequence = input.slice(index, index + 3)
282
+ if (sequence === '\u001b[A') {
283
+ moveCursor(-1)
284
+ index += 2
285
+ continue
286
+ }
287
+
288
+ if (sequence === '\u001b[B') {
289
+ moveCursor(1)
290
+ index += 2
291
+ continue
292
+ }
293
+
294
+ const key = input[index]
295
+ if (key === '\u0003' || key === '\u001b' || key.toLowerCase() === 'q') {
296
+ finish(null)
297
+ return
298
+ }
299
+
300
+ if (key === '\r' || key === '\n') {
301
+ complete()
302
+ continue
303
+ }
304
+
305
+ if (key === ' ') {
306
+ toggleCurrent()
307
+ continue
308
+ }
309
+
310
+ if (key.toLowerCase() === 'k') {
311
+ moveCursor(-1)
312
+ continue
313
+ }
314
+
315
+ if (key.toLowerCase() === 'j') {
316
+ moveCursor(1)
317
+ }
318
+ }
319
+ }
320
+
321
+ stdin.setRawMode(true)
322
+ stdin.resume()
323
+ stdin.on('data', onData)
324
+ render()
325
+ })
326
+ }
327
+
328
+ /**
329
+ * 在给定选项中进行多选;非交互环境返回默认值集合。
330
+ * 输入为提示文案、选项列表与多选配置,输出为选中的 value 数组或 null。
331
+ */
332
+ export async function chooseMany(message, choices, options = {}) {
333
+ if (!Array.isArray(choices) || choices.length === 0) {
334
+ throw new Error('chooseMany 需要至少一个可选项')
335
+ }
336
+
337
+ const parsedMin = Number(options.minSelections ?? 0)
338
+ const parsedMax = Number(options.maxSelections ?? choices.length)
339
+ const minSelections = Number.isFinite(parsedMin) ? Math.max(0, Math.floor(parsedMin)) : 0
340
+ const maxCap = Number.isFinite(parsedMax) ? Math.max(0, Math.floor(parsedMax)) : choices.length
341
+ const maxSelections = Math.max(minSelections, Math.min(choices.length, maxCap))
342
+ const doneLabel = options.doneLabel || '完成选择'
343
+ const cancelLabel = options.cancelLabel || '取消'
344
+ const defaultValues = Array.isArray(options.defaultValues) ? options.defaultValues : []
345
+ const defaultSet = new Set(defaultValues)
346
+ const selectedValues = choices
347
+ .map((choice) => choice.value)
348
+ .filter((value) => defaultSet.has(value))
349
+ .slice(0, maxSelections)
350
+
351
+ if (!isInteractive()) {
352
+ return selectedValues
353
+ }
354
+
355
+ const context = {
356
+ minSelections,
357
+ maxSelections,
358
+ doneLabel,
359
+ cancelLabel,
360
+ selectedValues
361
+ }
362
+
363
+ if (typeof stdin.setRawMode !== 'function') {
364
+ return chooseManyByNumberInput(message, choices, context)
365
+ }
366
+
367
+ return chooseManyByCheckbox(message, choices, context)
368
+ }
@@ -1,9 +1,11 @@
1
1
  const SPINNER_FRAMES = ['-', '\\', '|', '/']
2
2
 
3
+ // 判断当前进程是否适合渲染动态等待效果,输入为空,输出布尔值。
3
4
  function canRenderSpinner() {
4
5
  return Boolean(process.stderr.isTTY) && process.env.ICODE_NO_SPINNER !== '1'
5
6
  }
6
7
 
8
+ // 清理当前终端行,输入为空,输出为空,仅在 TTY 中生效。
7
9
  function clearLine() {
8
10
  if (!canRenderSpinner()) {
9
11
  return
@@ -12,6 +14,7 @@ function clearLine() {
12
14
  process.stderr.write('\r\x1b[2K')
13
15
  }
14
16
 
17
+ // 执行异步任务时展示等待动画,输入展示文案和任务函数,输出任务结果。
15
18
  export async function withSpinner(text, task) {
16
19
  if (!canRenderSpinner()) {
17
20
  return task()
@@ -21,7 +24,8 @@ export async function withSpinner(text, task) {
21
24
  const render = () => {
22
25
  const frame = SPINNER_FRAMES[frameIndex % SPINNER_FRAMES.length]
23
26
  frameIndex += 1
24
- process.stderr.write(`\r[icode] ${frame} ${text}`)
27
+ clearLine()
28
+ process.stderr.write(`[icode] ${frame} ${text}`)
25
29
  }
26
30
 
27
31
  render()
@@ -25,6 +25,11 @@ function print(stream, level, message) {
25
25
  }
26
26
 
27
27
  export const logger = {
28
+ // 按指定颜色渲染短文本,输入颜色名和文本,输出可直接拼进日志的 ANSI 字符串。
29
+ color(color, message) {
30
+ return colorize(color, String(message ?? ''))
31
+ },
32
+
28
33
  setVerbose(enabled) {
29
34
  verboseEnabled = Boolean(enabled)
30
35
  },
@@ -1,8 +1,9 @@
1
- import { askAi } from '../core/ai-client.js'
2
- import { resolveAiDiffRange } from '../core/ai-diff-range.js'
1
+ import { askAi } from '../core/ai/client.js'
2
+ import { buildLimitedDiffPrompt, formatDiffCoverage, summarizeDiffCoverage } from '../core/ai/diff-prompt.js'
3
+ import { resolveAiDiffRange } from '../core/ai/diff-range.js'
3
4
  import { IcodeError } from '../core/errors.js'
4
- import { resolveGitContext } from '../core/git-context.js'
5
- import { GitService } from '../core/git-service.js'
5
+ import { resolveGitContext } from '../core/git/context.js'
6
+ import { GitService } from '../core/git/service.js'
6
7
 
7
8
  function truncate(value, limit) {
8
9
  const text = value || ''
@@ -19,6 +20,14 @@ function joinSections(sections) {
19
20
  .join('\n\n')
20
21
  }
21
22
 
23
+ // 构建 AI code review prompt 中的 diff 片段,输入完整 diff,输出受限 diff 与覆盖范围。
24
+ function buildReviewDiffPrompt(diff) {
25
+ return buildLimitedDiffPrompt(diff, {
26
+ limit: 18000
27
+ })
28
+ }
29
+
30
+ // 执行 AI 代码审查工作流,输入 diff 范围和 AI 选项,输出审查文本与 diff 覆盖范围。
22
31
  export async function runAiCodeReviewWorkflow(options) {
23
32
  const context = await resolveGitContext({
24
33
  cwd: options.cwd,
@@ -101,10 +110,12 @@ export async function runAiCodeReviewWorkflow(options) {
101
110
  ])
102
111
  }
103
112
 
113
+ const limitedDiff = buildReviewDiffPrompt(diff)
114
+
104
115
  const review = await askAi(
105
116
  {
106
117
  systemPrompt: '你是严格的软件代码审查工程师,请优先关注 bug、安全风险、行为回归、缺失测试。输出中文 Markdown。',
107
- userPrompt: `请按如下结构输出:\n1. Findings(按严重度从高到低)\n2. Open Questions\n3. Summary\n\nFocus: ${options.focus || 'general'}\nRange: ${rangeSpec}\nDiff Source: ${diffSource}\n\nDiff Stat:\n${truncate(stat, 3000)}\n\nName Status:\n${truncate(nameStatus, 3000)}\n\nUnified Diff:\n${truncate(diff, 18000)}`
118
+ userPrompt: `请按如下结构输出:\n1. Findings(按严重度从高到低)\n2. Open Questions\n3. Summary\n\nFocus: ${options.focus || 'general'}\nRange: ${rangeSpec}\nDiff Source: ${diffSource}\n\n${formatDiffCoverage(limitedDiff.coverage)}\n\nDiff Stat:\n${truncate(stat, 3000)}\n\nName Status:\n${truncate(nameStatus, 3000)}\n\nUnified Diff:\n${limitedDiff.diff}`
108
119
  },
109
120
  {
110
121
  profile: options.profile,
@@ -121,6 +132,7 @@ export async function runAiCodeReviewWorkflow(options) {
121
132
 
122
133
  return {
123
134
  rangeSpec,
124
- review
135
+ review,
136
+ diffCoverage: summarizeDiffCoverage(limitedDiff.coverage)
125
137
  }
126
138
  }
@@ -1,10 +1,11 @@
1
1
  import { IcodeError } from '../core/errors.js'
2
- import { resolveGitContext } from '../core/git-context.js'
3
- import { GitService } from '../core/git-service.js'
4
- import { logger } from '../core/logger.js'
5
- import { askAiJson } from '../core/ai-client.js'
6
- import { scanCommitConventions } from '../core/commit-conventions.js'
7
- import { confirm } from '../core/prompts.js'
2
+ import { askAiJson } from '../core/ai/client.js'
3
+ import { buildLimitedDiffPrompt, formatDiffCoverage, summarizeDiffCoverage } from '../core/ai/diff-prompt.js'
4
+ import { resolveGitContext } from '../core/git/context.js'
5
+ import { scanCommitConventions } from '../core/git/commit-conventions.js'
6
+ import { GitService } from '../core/git/service.js'
7
+ import { confirm } from '../core/tools/interactive.js'
8
+ import { logger } from '../core/tools/logger.js'
8
9
 
9
10
  function normalizeCommitType(value) {
10
11
  const allowed = new Set(['feat', 'fix', 'docs', 'style', 'refactor', 'perf', 'test', 'chore', 'build', 'ci', 'revert'])
@@ -74,7 +75,9 @@ export async function runAiCommitWorkflow(options) {
74
75
  })
75
76
  }
76
77
 
77
- const limitedDiff = diff.length > 12000 ? `${diff.slice(0, 12000)}\n\n...<truncated>` : diff
78
+ const limitedDiff = buildLimitedDiffPrompt(diff, {
79
+ limit: 12000
80
+ })
78
81
  const conventionContext = scanCommitConventions(context)
79
82
  const conventionPrompt = conventionContext.hasConventions
80
83
  ? `Local commit conventions were detected from repository hooks/config files. Follow these local rules first when generating the commit message.\n\n${conventionContext.summary}\n\n`
@@ -89,7 +92,7 @@ export async function runAiCommitWorkflow(options) {
89
92
  const { parsed, text } = await askAiJson(
90
93
  {
91
94
  systemPrompt: `You are a senior software engineer. Generate a concise Conventional Commit message. Output JSON only. Language: ${language}.`,
92
- userPrompt: `${conventionPrompt}Based on the following git diff, return JSON with fields:\n{\"type\":\"feat|fix|docs|style|refactor|perf|test|chore|build|ci|revert\",\"scope\":\"optional\",\"subject\":\"required one-line summary\",\"body\":\"optional details\"}\n\nDiff Source: ${diffSource}\n\nDiff:\n${limitedDiff}`
95
+ userPrompt: `${conventionPrompt}Based on the following git diff, return JSON with fields:\n{\"type\":\"feat|fix|docs|style|refactor|perf|test|chore|build|ci|revert\",\"scope\":\"optional\",\"subject\":\"required one-line summary\",\"body\":\"optional details\"}\n\nDiff Source: ${diffSource}\n\n${formatDiffCoverage(limitedDiff.coverage)}\n\nDiff:\n${limitedDiff.diff}`
93
96
  },
94
97
  {
95
98
  profile: options.profile
@@ -98,13 +101,16 @@ export async function runAiCommitWorkflow(options) {
98
101
 
99
102
  const commitMessage = buildCommitMessage(parsed)
100
103
 
101
- logger.success(`AI 建议提交信息:\n${commitMessage}`)
104
+ if (!options.silentResultLog) {
105
+ logger.success(`AI 建议提交信息:\n${commitMessage}`)
106
+ }
102
107
 
103
108
  if (!options.apply) {
104
109
  return {
105
110
  applied: false,
106
111
  commitMessage,
107
- raw: text
112
+ raw: text,
113
+ diffCoverage: summarizeDiffCoverage(limitedDiff.coverage)
108
114
  }
109
115
  }
110
116
 
@@ -115,6 +121,7 @@ export async function runAiCommitWorkflow(options) {
115
121
  applied: false,
116
122
  commitMessage,
117
123
  raw: text,
124
+ diffCoverage: summarizeDiffCoverage(limitedDiff.coverage),
118
125
  canceled: true
119
126
  }
120
127
  }
@@ -130,7 +137,7 @@ export async function runAiCommitWorkflow(options) {
130
137
  })
131
138
 
132
139
  const commitId = await git.revParseShort('HEAD')
133
- if (commitId) {
140
+ if (commitId && !options.silentResultLog) {
134
141
  logger.success(`AI commit 已创建: ${commitId}`)
135
142
  }
136
143
 
@@ -138,6 +145,7 @@ export async function runAiCommitWorkflow(options) {
138
145
  applied: true,
139
146
  commitId,
140
147
  commitMessage,
141
- raw: text
148
+ raw: text,
149
+ diffCoverage: summarizeDiffCoverage(limitedDiff.coverage)
142
150
  }
143
151
  }
@@ -1,10 +1,10 @@
1
1
  import fs from 'node:fs'
2
2
  import path from 'node:path'
3
- import { askAi } from '../core/ai-client.js'
3
+ import { askAi } from '../core/ai/client.js'
4
4
  import { IcodeError } from '../core/errors.js'
5
- import { resolveGitContext } from '../core/git-context.js'
6
- import { GitService } from '../core/git-service.js'
7
- import { logger } from '../core/logger.js'
5
+ import { resolveGitContext } from '../core/git/context.js'
6
+ import { GitService } from '../core/git/service.js'
7
+ import { logger } from '../core/tools/logger.js'
8
8
 
9
9
  function extractConflictBlocks(content, maxBlocks = 3, maxLines = 80) {
10
10
  const lines = content.split('\n')
@@ -1,8 +1,8 @@
1
- import { askAi } from '../core/ai-client.js'
2
- import { resolveAiDiffRange } from '../core/ai-diff-range.js'
1
+ import { askAi } from '../core/ai/client.js'
2
+ import { resolveAiDiffRange } from '../core/ai/diff-range.js'
3
3
  import { IcodeError } from '../core/errors.js'
4
- import { resolveGitContext } from '../core/git-context.js'
5
- import { GitService } from '../core/git-service.js'
4
+ import { resolveGitContext } from '../core/git/context.js'
5
+ import { GitService } from '../core/git/service.js'
6
6
 
7
7
  function truncate(value, limit) {
8
8
  const text = value || ''
@@ -1,4 +1,4 @@
1
- import { askAiJson } from '../core/ai-client.js'
1
+ import { askAiJson } from '../core/ai/client.js'
2
2
 
3
3
  function normalizeDecision(value) {
4
4
  const normalized = (value || '').trim().toLowerCase()
@@ -1,8 +1,18 @@
1
1
  import { IcodeError } from '../core/errors.js'
2
- import { GitService } from '../core/git-service.js'
3
- import { resolveGitContext } from '../core/git-context.js'
4
- import { logger } from '../core/logger.js'
2
+ import { resolveGitContext } from '../core/git/context.js'
3
+ import {
4
+ assertCleanWorktree,
5
+ assertNoConflictedFiles,
6
+ assertNoPendingOperation,
7
+ readGitSafetyState
8
+ } from '../core/git/preconditions.js'
9
+ import { GitService } from '../core/git/service.js'
10
+ import { logger } from '../core/tools/logger.js'
5
11
 
12
+ /**
13
+ * 执行 checkout 工作流。
14
+ * 输入为目标分支、基线分支和执行选项,输出分支切换结果;实际切换前要求工作区干净且没有未完成 Git 操作。
15
+ */
6
16
  export async function runCheckoutWorkflow(input) {
7
17
  if (!input.branchName) {
8
18
  throw new IcodeError('缺少分支名: icode checkout <branch> [base]', {
@@ -19,6 +29,10 @@ export async function runCheckoutWorkflow(input) {
19
29
  const git = new GitService(context)
20
30
  const branchName = input.branchName.trim()
21
31
  const baseBranchName = input.baseBranchName?.trim() || context.defaultBranch
32
+ const safetyState = await readGitSafetyState(git)
33
+ assertNoPendingOperation(safetyState.operation, 'checkout', 'CHECKOUT_GIT_OPERATION_IN_PROGRESS')
34
+ assertNoConflictedFiles(safetyState.changes, 'checkout', 'CHECKOUT_CONFLICTED_FILES')
35
+ assertCleanWorktree(safetyState.changes, 'checkout', 'CHECKOUT_DIRTY_WORKTREE')
22
36
 
23
37
  logger.info(`仓库根目录: ${context.topLevelPath}`)
24
38
  if (context.inheritedFromParent) {
@@ -55,7 +69,6 @@ export async function runCheckoutWorkflow(input) {
55
69
  if (input.pullMain && context.defaultBranch !== branchName) {
56
70
  logger.info(`同步主分支到当前分支: ${context.defaultBranch}`)
57
71
  await git.pull(context.defaultBranch, {
58
- allowUnrelatedHistories: true,
59
72
  noRebase: true
60
73
  })
61
74
  }
@@ -63,7 +76,6 @@ export async function runCheckoutWorkflow(input) {
63
76
  if (remoteExists) {
64
77
  logger.info(`拉取远程分支: ${branchName}`)
65
78
  await git.pull(branchName, {
66
- allowUnrelatedHistories: true,
67
79
  noRebase: true
68
80
  })
69
81
  }