@icode-js/icode 3.0.2

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 (46) hide show
  1. package/README.md +346 -0
  2. package/bin/icode.js +6 -0
  3. package/package.json +34 -0
  4. package/src/cli.js +131 -0
  5. package/src/commands/ai.js +287 -0
  6. package/src/commands/checkout.js +59 -0
  7. package/src/commands/clean.js +65 -0
  8. package/src/commands/codereview.js +52 -0
  9. package/src/commands/config.js +513 -0
  10. package/src/commands/explain.js +80 -0
  11. package/src/commands/help.js +49 -0
  12. package/src/commands/info.js +57 -0
  13. package/src/commands/migrate.js +86 -0
  14. package/src/commands/push.js +125 -0
  15. package/src/commands/sync.js +74 -0
  16. package/src/commands/tag.js +53 -0
  17. package/src/commands/undo.js +66 -0
  18. package/src/core/ai-client.js +1125 -0
  19. package/src/core/ai-commit-summary.js +18 -0
  20. package/src/core/ai-config.js +342 -0
  21. package/src/core/ai-diff-range.js +117 -0
  22. package/src/core/args.js +47 -0
  23. package/src/core/commit-conventions.js +169 -0
  24. package/src/core/config-store.js +194 -0
  25. package/src/core/errors.js +25 -0
  26. package/src/core/git-context.js +105 -0
  27. package/src/core/git-service.js +428 -0
  28. package/src/core/hook-diagnostics.js +23 -0
  29. package/src/core/loading.js +36 -0
  30. package/src/core/logger.js +55 -0
  31. package/src/core/prompts.js +152 -0
  32. package/src/core/shell.js +77 -0
  33. package/src/workflows/ai-codereview-workflow.js +126 -0
  34. package/src/workflows/ai-commit-workflow.js +128 -0
  35. package/src/workflows/ai-conflict-workflow.js +102 -0
  36. package/src/workflows/ai-explain-workflow.js +116 -0
  37. package/src/workflows/ai-risk-review-workflow.js +49 -0
  38. package/src/workflows/checkout-workflow.js +85 -0
  39. package/src/workflows/clean-workflow.js +131 -0
  40. package/src/workflows/info-workflow.js +30 -0
  41. package/src/workflows/migrate-workflow.js +449 -0
  42. package/src/workflows/push-workflow.js +276 -0
  43. package/src/workflows/rollback-workflow.js +84 -0
  44. package/src/workflows/sync-workflow.js +141 -0
  45. package/src/workflows/tag-workflow.js +64 -0
  46. package/src/workflows/undo-workflow.js +328 -0
@@ -0,0 +1,77 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { IcodeError } from './errors.js'
3
+
4
+ function stringifyCommand(command, args) {
5
+ return [command, ...args].join(' ')
6
+ }
7
+
8
+ function formatFailureMessage(command, args, result) {
9
+ const baseMessage = `命令执行失败(${result.exitCode}): ${stringifyCommand(command, args)}`
10
+ const detailLines = [result.stderr, result.stdout].map((item) => item.trim()).filter(Boolean)
11
+
12
+ if (!detailLines.length) {
13
+ return baseMessage
14
+ }
15
+
16
+ return `${baseMessage}\n${detailLines.join('\n')}`
17
+ }
18
+
19
+ export async function runCommand(command, args = [], options = {}) {
20
+ const { cwd = process.cwd(), env = process.env, allowFailure = false } = options
21
+
22
+ return new Promise((resolve, reject) => {
23
+ const child = spawn(command, args, {
24
+ cwd,
25
+ env,
26
+ stdio: ['ignore', 'pipe', 'pipe']
27
+ })
28
+
29
+ let stdout = ''
30
+ let stderr = ''
31
+
32
+ child.stdout.on('data', (chunk) => {
33
+ stdout += chunk.toString()
34
+ })
35
+
36
+ child.stderr.on('data', (chunk) => {
37
+ stderr += chunk.toString()
38
+ })
39
+
40
+ child.on('error', (error) => {
41
+ reject(
42
+ new IcodeError(`执行命令失败: ${stringifyCommand(command, args)}`, {
43
+ cause: error,
44
+ code: 'COMMAND_SPAWN_ERROR',
45
+ meta: {
46
+ command,
47
+ args,
48
+ cwd
49
+ }
50
+ })
51
+ )
52
+ })
53
+
54
+ child.on('close', (exitCode) => {
55
+ const result = {
56
+ command,
57
+ args,
58
+ cwd,
59
+ exitCode,
60
+ stdout,
61
+ stderr
62
+ }
63
+
64
+ if (exitCode === 0 || allowFailure) {
65
+ resolve(result)
66
+ return
67
+ }
68
+
69
+ reject(
70
+ new IcodeError(formatFailureMessage(command, args, result), {
71
+ code: 'COMMAND_EXEC_ERROR',
72
+ meta: result
73
+ })
74
+ )
75
+ })
76
+ })
77
+ }
@@ -0,0 +1,126 @@
1
+ import { askAi } from '../core/ai-client.js'
2
+ import { resolveAiDiffRange } from '../core/ai-diff-range.js'
3
+ import { IcodeError } from '../core/errors.js'
4
+ import { resolveGitContext } from '../core/git-context.js'
5
+ import { GitService } from '../core/git-service.js'
6
+
7
+ function truncate(value, limit) {
8
+ const text = value || ''
9
+ if (text.length <= limit) {
10
+ return text
11
+ }
12
+ return `${text.slice(0, limit)}\n\n...<truncated>`
13
+ }
14
+
15
+ function joinSections(sections) {
16
+ return sections
17
+ .map((item) => (item || '').trim())
18
+ .filter(Boolean)
19
+ .join('\n\n')
20
+ }
21
+
22
+ export async function runAiCodeReviewWorkflow(options) {
23
+ const context = await resolveGitContext({
24
+ cwd: options.cwd,
25
+ repoMode: options.repoMode
26
+ })
27
+ const git = new GitService(context)
28
+
29
+ const explicitBase = Boolean((options.baseRef || '').trim())
30
+ const explicitHead = Boolean((options.headRef || '').trim())
31
+ const useRangeMode = explicitBase || explicitHead
32
+ const headRef = options.headRef || 'HEAD'
33
+
34
+ let rangeSpec = ''
35
+ let diff = ''
36
+ let stat = ''
37
+ let nameStatus = ''
38
+ let diffSource = 'three-dot-range'
39
+ let rangeError = null
40
+
41
+ if (useRangeMode) {
42
+ try {
43
+ const rangeResult = await resolveAiDiffRange({
44
+ git,
45
+ context,
46
+ baseRef: options.baseRef,
47
+ headRef,
48
+ explicitHead,
49
+ label: 'Code Review'
50
+ })
51
+ rangeSpec = rangeResult.rangeSpec
52
+ diff = rangeResult.diff
53
+ } catch (error) {
54
+ rangeError = error
55
+ }
56
+ }
57
+
58
+ if (diff.trim()) {
59
+ stat = await git.diffStat(rangeSpec)
60
+ nameStatus = await git.diffNameStatus(rangeSpec)
61
+ } else {
62
+ if (rangeError && useRangeMode) {
63
+ throw rangeError
64
+ }
65
+
66
+ // 默认回退策略: 当范围 diff 为空时,自动审查“未提交代码(暂存 + 工作区)”。
67
+ const stagedDiff = await git.diffStaged()
68
+ const workingDiff = await git.diffWorkingTree()
69
+
70
+ if (rangeError && !stagedDiff.trim() && !workingDiff.trim()) {
71
+ throw rangeError
72
+ }
73
+
74
+ if (!stagedDiff.trim() && !workingDiff.trim()) {
75
+ if (!useRangeMode) {
76
+ throw new IcodeError('暂存区/工作区没有代码改动。若要评审分支差异,请显式传入 --base 或 --head。', {
77
+ code: 'AI_CODEREVIEW_EMPTY_DIFF',
78
+ exitCode: 2
79
+ })
80
+ }
81
+
82
+ throw new IcodeError(`范围 ${rangeSpec} 内没有代码差异,且暂存区/工作区也没有改动。`, {
83
+ code: 'AI_CODEREVIEW_EMPTY_DIFF',
84
+ exitCode: 2
85
+ })
86
+ }
87
+
88
+ diffSource = 'uncommitted'
89
+ rangeSpec = 'uncommitted(staged+working-tree)'
90
+ diff = joinSections([
91
+ stagedDiff ? `--- STAGED DIFF ---\n${stagedDiff}` : '',
92
+ workingDiff ? `--- WORKING TREE DIFF ---\n${workingDiff}` : ''
93
+ ])
94
+ stat = joinSections([
95
+ stagedDiff ? `--- STAGED STAT ---\n${await git.diffStagedStat()}` : '',
96
+ workingDiff ? `--- WORKING TREE STAT ---\n${await git.diffStat()}` : ''
97
+ ])
98
+ nameStatus = joinSections([
99
+ stagedDiff ? `--- STAGED NAME STATUS ---\n${await git.diffStagedNameStatus()}` : '',
100
+ workingDiff ? `--- WORKING TREE NAME STATUS ---\n${await git.diffNameStatus()}` : ''
101
+ ])
102
+ }
103
+
104
+ const review = await askAi(
105
+ {
106
+ 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)}`
108
+ },
109
+ {
110
+ profile: options.profile,
111
+ dumpResponse: options.dumpResponse
112
+ }
113
+ )
114
+
115
+ if (!review || !review.trim()) {
116
+ throw new IcodeError('AI Code Review 返回为空,请检查 AI profile/model 是否可用后重试。', {
117
+ code: 'AI_CODEREVIEW_EMPTY_RESPONSE',
118
+ exitCode: 2
119
+ })
120
+ }
121
+
122
+ return {
123
+ rangeSpec,
124
+ review
125
+ }
126
+ }
@@ -0,0 +1,128 @@
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'
8
+
9
+ function normalizeCommitType(value) {
10
+ const allowed = new Set(['feat', 'fix', 'docs', 'style', 'refactor', 'perf', 'test', 'chore', 'build', 'ci', 'revert'])
11
+ const normalized = (value || '').trim().toLowerCase()
12
+ return allowed.has(normalized) ? normalized : 'chore'
13
+ }
14
+
15
+ function buildCommitMessage(parsed) {
16
+ const type = normalizeCommitType(parsed.type)
17
+ const scope = (parsed.scope || '').trim()
18
+ const subject = (parsed.subject || '').trim().replace(/\n/g, ' ')
19
+
20
+ if (!subject) {
21
+ throw new IcodeError('AI 未返回有效的提交标题(subject)。', {
22
+ code: 'AI_COMMIT_SUBJECT_EMPTY',
23
+ exitCode: 2
24
+ })
25
+ }
26
+
27
+ const header = scope ? `${type}(${scope}): ${subject}` : `${type}: ${subject}`
28
+ const body = (parsed.body || '').trim()
29
+ if (!body) {
30
+ return header
31
+ }
32
+
33
+ return `${header}\n\n${body}`
34
+ }
35
+
36
+ export async function runAiCommitWorkflow(options) {
37
+ const context = await resolveGitContext({
38
+ cwd: options.cwd,
39
+ repoMode: options.repoMode
40
+ })
41
+ const git = new GitService(context)
42
+
43
+ if (!options.silentContextLog) {
44
+ logger.info(`仓库根目录: ${context.topLevelPath}`)
45
+ }
46
+
47
+ let diff = await git.diffStaged()
48
+ let diffSource = 'staged'
49
+
50
+ if (!diff.trim()) {
51
+ diff = await git.diffWorkingTree()
52
+ diffSource = 'working-tree'
53
+ }
54
+
55
+ if (!diff.trim()) {
56
+ throw new IcodeError('没有可用于生成提交信息的代码改动。', {
57
+ code: 'AI_COMMIT_EMPTY_DIFF',
58
+ exitCode: 2
59
+ })
60
+ }
61
+
62
+ const limitedDiff = diff.length > 12000 ? `${diff.slice(0, 12000)}\n\n...<truncated>` : diff
63
+ const conventionContext = scanCommitConventions(context)
64
+ const conventionPrompt = conventionContext.hasConventions
65
+ ? `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`
66
+ : ''
67
+
68
+ const language = (options.lang || 'zh').trim().toLowerCase() === 'en' ? 'English' : 'Chinese'
69
+
70
+ if (conventionContext.hasConventions && !options.silentContextLog) {
71
+ logger.info(`检测到提交规范配置,AI 将优先参考: ${conventionContext.sources.join(', ')}`)
72
+ }
73
+
74
+ const { parsed, text } = await askAiJson(
75
+ {
76
+ systemPrompt: `You are a senior software engineer. Generate a concise Conventional Commit message. Output JSON only. Language: ${language}.`,
77
+ 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}`
78
+ },
79
+ {
80
+ profile: options.profile
81
+ }
82
+ )
83
+
84
+ const commitMessage = buildCommitMessage(parsed)
85
+
86
+ logger.success(`AI 建议提交信息:\n${commitMessage}`)
87
+
88
+ if (!options.apply) {
89
+ return {
90
+ applied: false,
91
+ commitMessage,
92
+ raw: text
93
+ }
94
+ }
95
+
96
+ if (!options.yes) {
97
+ const accepted = await confirm('是否应用该提交信息并执行 commit ?', true)
98
+ if (!accepted) {
99
+ return {
100
+ applied: false,
101
+ commitMessage,
102
+ raw: text,
103
+ canceled: true
104
+ }
105
+ }
106
+ }
107
+
108
+ if (diffSource === 'working-tree') {
109
+ // 从 working-tree 生成信息时,提交前统一暂存,避免 commit 为空。
110
+ await git.stageAll()
111
+ }
112
+
113
+ await git.commit(commitMessage, {
114
+ noVerify: options.noVerify
115
+ })
116
+
117
+ const commitId = await git.revParseShort('HEAD')
118
+ if (commitId) {
119
+ logger.success(`AI commit 已创建: ${commitId}`)
120
+ }
121
+
122
+ return {
123
+ applied: true,
124
+ commitId,
125
+ commitMessage,
126
+ raw: text
127
+ }
128
+ }
@@ -0,0 +1,102 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { askAi } from '../core/ai-client.js'
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'
8
+
9
+ function extractConflictBlocks(content, maxBlocks = 3, maxLines = 80) {
10
+ const lines = content.split('\n')
11
+ const blocks = []
12
+ let index = 0
13
+
14
+ while (index < lines.length && blocks.length < maxBlocks) {
15
+ if (!lines[index].startsWith('<<<<<<<')) {
16
+ index += 1
17
+ continue
18
+ }
19
+
20
+ const start = index
21
+ let end = index
22
+
23
+ while (end < lines.length && !lines[end].startsWith('>>>>>>>')) {
24
+ end += 1
25
+ }
26
+
27
+ if (end < lines.length) {
28
+ end += 1
29
+ }
30
+
31
+ const segment = lines.slice(start, Math.min(end, start + maxLines)).join('\n')
32
+ blocks.push(segment)
33
+ index = end
34
+ }
35
+
36
+ return blocks
37
+ }
38
+
39
+ export async function runAiConflictWorkflow(options) {
40
+ const context = await resolveGitContext({
41
+ cwd: options.cwd,
42
+ repoMode: options.repoMode
43
+ })
44
+ const git = new GitService(context)
45
+
46
+ const conflictedFiles = await git.listConflictedFiles()
47
+ if (!conflictedFiles.length) {
48
+ throw new IcodeError('当前没有检测到冲突文件。', {
49
+ code: 'AI_CONFLICT_NONE',
50
+ exitCode: 2
51
+ })
52
+ }
53
+
54
+ const snippets = []
55
+ for (const filePath of conflictedFiles.slice(0, 10)) {
56
+ const absolutePath = path.resolve(context.topLevelPath, filePath)
57
+ if (!fs.existsSync(absolutePath)) {
58
+ continue
59
+ }
60
+
61
+ const content = fs.readFileSync(absolutePath, 'utf8')
62
+ const blocks = extractConflictBlocks(content)
63
+ if (!blocks.length) {
64
+ continue
65
+ }
66
+
67
+ snippets.push({
68
+ filePath,
69
+ blocks
70
+ })
71
+ }
72
+
73
+ if (!snippets.length) {
74
+ throw new IcodeError('冲突文件中没有检测到可解析的冲突块。', {
75
+ code: 'AI_CONFLICT_BLOCKS_EMPTY',
76
+ exitCode: 2
77
+ })
78
+ }
79
+
80
+ const rawPayload = snippets
81
+ .map((item) => `FILE: ${item.filePath}\n${item.blocks.map((block, index) => `--- block ${index + 1} ---\n${block}`).join('\n')}`)
82
+ .join('\n\n')
83
+
84
+ const payload = rawPayload.length > 14000 ? `${rawPayload.slice(0, 14000)}\n\n...<truncated>` : rawPayload
85
+
86
+ logger.info(`检测到冲突文件: ${conflictedFiles.join(', ')}`)
87
+
88
+ const suggestion = await askAi(
89
+ {
90
+ systemPrompt: '你是资深代码合并助手。请基于冲突块给出可执行的合并方案。输出中文 Markdown。',
91
+ userPrompt: `请按以下格式输出:\n1) 每个文件的冲突原因\n2) 推荐保留哪一侧或如何融合\n3) 具体手工修改步骤\n4) 修改后需要执行的 git 命令\n\n冲突内容:\n${payload}`
92
+ },
93
+ {
94
+ profile: options.profile
95
+ }
96
+ )
97
+
98
+ return {
99
+ conflictedFiles,
100
+ suggestion
101
+ }
102
+ }
@@ -0,0 +1,116 @@
1
+ import { askAi } from '../core/ai-client.js'
2
+ import { resolveAiDiffRange } from '../core/ai-diff-range.js'
3
+ import { IcodeError } from '../core/errors.js'
4
+ import { resolveGitContext } from '../core/git-context.js'
5
+ import { GitService } from '../core/git-service.js'
6
+
7
+ function truncate(value, limit) {
8
+ const text = value || ''
9
+ if (text.length <= limit) {
10
+ return text
11
+ }
12
+ return `${text.slice(0, limit)}\n\n...<truncated>`
13
+ }
14
+
15
+ function joinSections(sections) {
16
+ return sections
17
+ .map((item) => (item || '').trim())
18
+ .filter(Boolean)
19
+ .join('\n\n')
20
+ }
21
+
22
+ export async function runAiExplainWorkflow(options) {
23
+ const context = await resolveGitContext({
24
+ cwd: options.cwd,
25
+ repoMode: options.repoMode
26
+ })
27
+ const git = new GitService(context)
28
+
29
+ const headRef = options.headRef || 'HEAD'
30
+ const explicitBase = Boolean((options.baseRef || '').trim())
31
+ const explicitHead = Boolean((options.headRef || '').trim())
32
+ const explicitRange = explicitBase || explicitHead
33
+
34
+ let rangeSpec = ''
35
+ let diff = ''
36
+ let stat = ''
37
+ let nameStatus = ''
38
+ let diffSource = 'three-dot-range'
39
+ let rangeError = null
40
+
41
+ try {
42
+ const rangeResult = await resolveAiDiffRange({
43
+ git,
44
+ context,
45
+ baseRef: options.baseRef,
46
+ headRef,
47
+ explicitHead,
48
+ label: 'Explain'
49
+ })
50
+ rangeSpec = rangeResult.rangeSpec
51
+ diff = rangeResult.diff
52
+ } catch (error) {
53
+ rangeError = error
54
+ }
55
+
56
+ if (diff.trim()) {
57
+ stat = await git.diffStat(rangeSpec)
58
+ nameStatus = await git.diffNameStatus(rangeSpec)
59
+ } else {
60
+ if (rangeError && explicitRange) {
61
+ throw rangeError
62
+ }
63
+
64
+ const stagedDiff = await git.diffStaged()
65
+ const workingDiff = await git.diffWorkingTree()
66
+
67
+ if (rangeError && !stagedDiff.trim() && !workingDiff.trim()) {
68
+ throw rangeError
69
+ }
70
+
71
+ if (!stagedDiff.trim() && !workingDiff.trim()) {
72
+ throw new IcodeError(`范围 ${rangeSpec} 内没有代码差异,且暂存区/工作区也没有改动。`, {
73
+ code: 'AI_EXPLAIN_EMPTY_DIFF',
74
+ exitCode: 2
75
+ })
76
+ }
77
+
78
+ diffSource = 'uncommitted'
79
+ rangeSpec = 'uncommitted(staged+working-tree)'
80
+ diff = joinSections([
81
+ stagedDiff ? `--- STAGED DIFF ---\n${stagedDiff}` : '',
82
+ workingDiff ? `--- WORKING TREE DIFF ---\n${workingDiff}` : ''
83
+ ])
84
+ stat = joinSections([
85
+ stagedDiff ? `--- STAGED STAT ---\n${await git.diffStagedStat()}` : '',
86
+ workingDiff ? `--- WORKING TREE STAT ---\n${await git.diffStat()}` : ''
87
+ ])
88
+ nameStatus = joinSections([
89
+ stagedDiff ? `--- STAGED NAME STATUS ---\n${await git.diffStagedNameStatus()}` : '',
90
+ workingDiff ? `--- WORKING TREE NAME STATUS ---\n${await git.diffNameStatus()}` : ''
91
+ ])
92
+ }
93
+
94
+ const explanation = await askAi(
95
+ {
96
+ systemPrompt: '你是资深软件工程师,擅长把 Git diff 用自然语言讲清楚。',
97
+ userPrompt: `请用中文自然语言解释以下 Git diff,输出简洁清晰,面向不熟悉代码的同事。\n要求:\n1. 先给整体改动概览\n2. 再按文件或模块说明主要改动\n3. 如有可能影响行为/兼容性/风险点,请指出但不要过度推测\n4. 不要输出 JSON 或代码块,只输出自然语言(可用简短项目符号)\n\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)}`
98
+ },
99
+ {
100
+ profile: options.profile,
101
+ dumpResponse: options.dumpResponse
102
+ }
103
+ )
104
+
105
+ if (!explanation || !explanation.trim()) {
106
+ throw new IcodeError('AI Explain 返回为空,请检查 AI profile/model 是否可用后重试。', {
107
+ code: 'AI_EXPLAIN_EMPTY_RESPONSE',
108
+ exitCode: 2
109
+ })
110
+ }
111
+
112
+ return {
113
+ rangeSpec,
114
+ explanation
115
+ }
116
+ }
@@ -0,0 +1,49 @@
1
+ import { askAiJson } from '../core/ai-client.js'
2
+
3
+ function normalizeDecision(value) {
4
+ const normalized = (value || '').trim().toLowerCase()
5
+ if (['allow', 'warn', 'block'].includes(normalized)) {
6
+ return normalized
7
+ }
8
+ return 'warn'
9
+ }
10
+
11
+ function normalizeRisk(value) {
12
+ const normalized = (value || '').trim().toLowerCase()
13
+ if (['low', 'medium', 'high'].includes(normalized)) {
14
+ return normalized
15
+ }
16
+ return 'medium'
17
+ }
18
+
19
+ function truncate(value, limit) {
20
+ if (!value || value.length <= limit) {
21
+ return value
22
+ }
23
+
24
+ return `${value.slice(0, limit)}\n\n...<truncated>`
25
+ }
26
+
27
+ export async function runAiRiskReviewWorkflow({ git, context, currentBranch, targetBranches, profile }) {
28
+ const stat = await git.diffStat()
29
+ const nameStatus = await git.diffNameStatus()
30
+ const recentLog = await git.logOneline('', 25)
31
+
32
+ const { text, parsed } = await askAiJson(
33
+ {
34
+ systemPrompt: '你是资深发布风控助手。请根据改动给出 push 风险评估。输出 JSON。',
35
+ userPrompt: `请仅返回 JSON:\n{\"decision\":\"allow|warn|block\",\"riskLevel\":\"low|medium|high\",\"reasons\":[\"...\"],\"checks\":[\"...\"]}\n\n仓库根目录: ${context.topLevelPath}\n当前分支: ${currentBranch}\n目标分支: ${targetBranches.join(', ')}\n\nDiff Stat:\n${truncate(stat, 2500)}\n\nName Status:\n${truncate(nameStatus, 2500)}\n\nRecent Commits:\n${truncate(recentLog, 2500)}`
36
+ },
37
+ {
38
+ profile
39
+ }
40
+ )
41
+
42
+ return {
43
+ raw: text,
44
+ decision: normalizeDecision(parsed.decision),
45
+ riskLevel: normalizeRisk(parsed.riskLevel),
46
+ reasons: Array.isArray(parsed.reasons) ? parsed.reasons : [],
47
+ checks: Array.isArray(parsed.checks) ? parsed.checks : []
48
+ }
49
+ }
@@ -0,0 +1,85 @@
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'
5
+
6
+ export async function runCheckoutWorkflow(input) {
7
+ if (!input.branchName) {
8
+ throw new IcodeError('缺少分支名: icode checkout <branch> [base]', {
9
+ code: 'CHECKOUT_BRANCH_REQUIRED',
10
+ exitCode: 2
11
+ })
12
+ }
13
+
14
+ const context = await resolveGitContext({
15
+ cwd: input.cwd,
16
+ repoMode: input.repoMode
17
+ })
18
+
19
+ const git = new GitService(context)
20
+ const branchName = input.branchName.trim()
21
+ const baseBranchName = input.baseBranchName?.trim() || context.defaultBranch
22
+
23
+ logger.info(`仓库根目录: ${context.topLevelPath}`)
24
+ if (context.inheritedFromParent) {
25
+ logger.warn('当前目录继承了父级 Git 仓库,命令将基于父仓库根目录执行。')
26
+ }
27
+
28
+ await git.fetch()
29
+
30
+ const localExists = await git.branchExistsLocal(branchName)
31
+ const remoteExists = await git.branchExistsRemote(branchName)
32
+
33
+ if (localExists) {
34
+ logger.info(`切换本地分支: ${branchName}`)
35
+ await git.checkout(branchName)
36
+ } else if (remoteExists) {
37
+ logger.info(`创建并跟踪远程分支: ${branchName}`)
38
+ await git.checkoutTracking(branchName)
39
+ } else {
40
+ const baseLocalExists = await git.branchExistsLocal(baseBranchName)
41
+ const baseRemoteExists = await git.branchExistsRemote(baseBranchName)
42
+
43
+ if (!baseLocalExists && !baseRemoteExists) {
44
+ throw new IcodeError(`基线分支不存在: ${baseBranchName}`, {
45
+ code: 'CHECKOUT_BASE_MISSING',
46
+ exitCode: 2
47
+ })
48
+ }
49
+
50
+ const fromRef = baseLocalExists ? baseBranchName : `origin/${baseBranchName}`
51
+ logger.info(`从 ${fromRef} 新建分支: ${branchName}`)
52
+ await git.checkoutNewBranch(branchName, fromRef)
53
+ }
54
+
55
+ if (input.pullMain && context.defaultBranch !== branchName) {
56
+ logger.info(`同步主分支到当前分支: ${context.defaultBranch}`)
57
+ await git.pull(context.defaultBranch, {
58
+ allowUnrelatedHistories: true,
59
+ noRebase: true
60
+ })
61
+ }
62
+
63
+ if (remoteExists) {
64
+ logger.info(`拉取远程分支: ${branchName}`)
65
+ await git.pull(branchName, {
66
+ allowUnrelatedHistories: true,
67
+ noRebase: true
68
+ })
69
+ }
70
+
71
+ if (input.pushOrigin && !remoteExists) {
72
+ logger.info(`推送新分支到远程: ${branchName}`)
73
+ await git.push(branchName, {
74
+ setUpstream: true,
75
+ noVerify: input.noVerify
76
+ })
77
+ }
78
+
79
+ return {
80
+ branchName,
81
+ baseBranchName,
82
+ remoteExists,
83
+ repoRoot: context.topLevelPath
84
+ }
85
+ }