@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.
- package/LICENSE +21 -0
- package/README.md +142 -5
- package/bin/icode.js +4 -4
- package/package.json +1 -1
- package/src/cli.js +60 -23
- package/src/commands/ai.js +4 -4
- package/src/commands/checkout.js +2 -2
- package/src/commands/clean.js +5 -2
- package/src/commands/codereview.js +5 -2
- package/src/commands/completion.js +93 -0
- package/src/commands/config.js +123 -10
- package/src/commands/doctor.js +80 -0
- package/src/commands/explain.js +6 -3
- package/src/commands/help.js +17 -0
- package/src/commands/info.js +5 -2
- package/src/commands/migrate.js +40 -3
- package/src/commands/push.js +72 -24
- package/src/commands/ready.js +110 -0
- package/src/commands/save.js +98 -0
- package/src/commands/sync.js +2 -2
- package/src/commands/tag.js +2 -2
- package/src/commands/undo.js +73 -10
- package/src/core/{ai-client.js → ai/client.js} +3 -3
- package/src/core/{ai-config.js → ai/config.js} +2 -2
- package/src/core/ai/diff-prompt.js +204 -0
- package/src/core/{ai-diff-range.js → ai/diff-range.js} +2 -2
- package/src/core/{args.js → cli/args.js} +23 -2
- package/src/core/cli/completion.js +720 -0
- package/src/core/cli/update-notifier.js +138 -0
- package/src/core/{shell.js → commands/shell.js} +13 -1
- package/src/core/{config-store.js → config/store.js} +53 -3
- package/src/core/{git-context.js → git/context.js} +24 -10
- package/src/core/git/preconditions.js +88 -0
- package/src/core/{git-service.js → git/service.js} +195 -5
- package/src/core/remote-merge/client.js +42 -0
- package/src/core/remote-merge/providers/gitlab.js +596 -0
- package/src/core/remote-merge/shared.js +49 -0
- package/src/core/tools/interactive.js +368 -0
- package/src/core/{loading.js → tools/loading.js} +5 -1
- package/src/core/{logger.js → tools/logger.js} +5 -0
- package/src/workflows/ai-codereview-workflow.js +18 -6
- package/src/workflows/ai-commit-workflow.js +20 -12
- package/src/workflows/ai-conflict-workflow.js +4 -4
- package/src/workflows/ai-explain-workflow.js +4 -4
- package/src/workflows/ai-risk-review-workflow.js +1 -1
- package/src/workflows/checkout-workflow.js +17 -5
- package/src/workflows/clean-workflow.js +92 -75
- package/src/workflows/doctor-workflow.js +131 -0
- package/src/workflows/info-workflow.js +4 -4
- package/src/workflows/migrate-workflow.js +269 -62
- package/src/workflows/push-workflow.js +475 -58
- package/src/workflows/ready-workflow.js +278 -0
- package/src/workflows/rollback-workflow.js +24 -9
- package/src/workflows/save-workflow.js +152 -0
- package/src/workflows/sync-workflow.js +22 -6
- package/src/workflows/tag-workflow.js +4 -4
- package/src/workflows/undo-workflow.js +37 -12
- package/src/core/prompts.js +0 -152
- /package/src/core/{ai-commit-summary.js → ai/commit-summary.js} +0 -0
- /package/src/core/{commit-conventions.js → git/commit-conventions.js} +0 -0
- /package/src/core/{hook-diagnostics.js → git/hook-diagnostics.js} +0 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { createRequire } from 'node:module'
|
|
2
|
+
import { runCommand } from '../commands/shell.js'
|
|
3
|
+
import { logger } from '../tools/logger.js'
|
|
4
|
+
|
|
5
|
+
const require = createRequire(import.meta.url)
|
|
6
|
+
const packageJson = require('../../../package.json')
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 规范化版本号,输出仅包含数字段的 semver 主体数组。
|
|
10
|
+
* 输入为字符串版本号,输出为数字数组;若格式不合法则返回 null。
|
|
11
|
+
*/
|
|
12
|
+
function normalizeVersionSegments(version) {
|
|
13
|
+
const normalized = String(version || '').trim()
|
|
14
|
+
if (!normalized) {
|
|
15
|
+
return null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const coreVersion = normalized.split('-')[0]
|
|
19
|
+
if (!/^\d+(?:\.\d+)*$/.test(coreVersion)) {
|
|
20
|
+
return null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return coreVersion.split('.').map((segment) => Number(segment))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 解析 npm view 返回的版本文本,兼容 JSON 字符串与纯文本两种输出。
|
|
28
|
+
* 输入为命令 stdout,输出为去空白后的版本号字符串。
|
|
29
|
+
*/
|
|
30
|
+
function parsePublishedVersion(stdout) {
|
|
31
|
+
const normalized = String(stdout || '').trim()
|
|
32
|
+
if (!normalized) {
|
|
33
|
+
return ''
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const parsed = JSON.parse(normalized)
|
|
38
|
+
if (typeof parsed === 'string') {
|
|
39
|
+
return parsed.trim()
|
|
40
|
+
}
|
|
41
|
+
} catch {
|
|
42
|
+
// npm 在异常场景下也可能输出纯文本版本号,这里保留原始文本继续解析。
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return normalized.replace(/^"|"$/g, '').trim()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 比较两个版本号,判断远端版本是否严格高于当前版本。
|
|
50
|
+
* 输入为当前版本和远端版本,输出为布尔值。
|
|
51
|
+
*/
|
|
52
|
+
export function hasNewerVersion(currentVersion, latestVersion) {
|
|
53
|
+
const currentSegments = normalizeVersionSegments(currentVersion)
|
|
54
|
+
const latestSegments = normalizeVersionSegments(latestVersion)
|
|
55
|
+
|
|
56
|
+
if (!currentSegments || !latestSegments) {
|
|
57
|
+
return false
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const maxLength = Math.max(currentSegments.length, latestSegments.length)
|
|
61
|
+
for (let index = 0; index < maxLength; index += 1) {
|
|
62
|
+
const currentValue = currentSegments[index] || 0
|
|
63
|
+
const latestValue = latestSegments[index] || 0
|
|
64
|
+
|
|
65
|
+
if (latestValue > currentValue) {
|
|
66
|
+
return true
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (latestValue < currentValue) {
|
|
70
|
+
return false
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return false
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 查询 npm registry 上当前包的最新发布版本。
|
|
79
|
+
* 输入为可选 runner/packageName,输出为最新版本号;失败时返回空字符串。
|
|
80
|
+
*/
|
|
81
|
+
export async function fetchLatestPublishedVersion(options = {}) {
|
|
82
|
+
const packageName = options.packageName || packageJson.name
|
|
83
|
+
const runner = options.runner || runCommand
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
const result = await runner('npm', ['view', packageName, 'version', '--json'], {
|
|
87
|
+
allowFailure: true,
|
|
88
|
+
env: {
|
|
89
|
+
...process.env,
|
|
90
|
+
NO_UPDATE_NOTIFIER: '1',
|
|
91
|
+
npm_config_update_notifier: 'false',
|
|
92
|
+
npm_config_loglevel: 'silent'
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
if (result.exitCode !== 0) {
|
|
97
|
+
return ''
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return parsePublishedVersion(result.stdout)
|
|
101
|
+
} catch {
|
|
102
|
+
return ''
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 在命令成功结束后提示可用更新版本。
|
|
108
|
+
* 输入为可选 runner/warn/packageName/currentVersion,输出为通知结果对象。
|
|
109
|
+
*/
|
|
110
|
+
export async function notifyIfCliUpdateAvailable(options = {}) {
|
|
111
|
+
const packageName = options.packageName || packageJson.name
|
|
112
|
+
const currentVersion = options.currentVersion || packageJson.version
|
|
113
|
+
const warn = options.warn || logger.warn
|
|
114
|
+
|
|
115
|
+
const latestVersion = await fetchLatestPublishedVersion({
|
|
116
|
+
runner: options.runner,
|
|
117
|
+
packageName
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
if (!latestVersion || !hasNewerVersion(currentVersion, latestVersion)) {
|
|
121
|
+
return {
|
|
122
|
+
notified: false,
|
|
123
|
+
packageName,
|
|
124
|
+
currentVersion,
|
|
125
|
+
latestVersion
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
warn(`发现新版本可用: ${currentVersion} -> ${latestVersion}`)
|
|
130
|
+
warn(`更新命令: npm i -g ${packageName}@latest`)
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
notified: true,
|
|
134
|
+
packageName,
|
|
135
|
+
currentVersion,
|
|
136
|
+
latestVersion
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process'
|
|
2
|
-
import { IcodeError } from '
|
|
2
|
+
import { IcodeError } from '../errors.js'
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* 把命令与参数拼成可读字符串,供日志与报错复用。
|
|
6
|
+
* 输入为命令名与参数数组,输出为单行命令文本。
|
|
7
|
+
*/
|
|
4
8
|
function stringifyCommand(command, args) {
|
|
5
9
|
return [command, ...args].join(' ')
|
|
6
10
|
}
|
|
7
11
|
|
|
12
|
+
/**
|
|
13
|
+
* 组合命令失败消息,尽量附带 stderr/stdout 便于定位问题。
|
|
14
|
+
* 输入为命令信息与执行结果,输出为最终错误文案。
|
|
15
|
+
*/
|
|
8
16
|
function formatFailureMessage(command, args, result) {
|
|
9
17
|
const baseMessage = `命令执行失败(${result.exitCode}): ${stringifyCommand(command, args)}`
|
|
10
18
|
const detailLines = [result.stderr, result.stdout].map((item) => item.trim()).filter(Boolean)
|
|
@@ -16,6 +24,10 @@ function formatFailureMessage(command, args, result) {
|
|
|
16
24
|
return `${baseMessage}\n${detailLines.join('\n')}`
|
|
17
25
|
}
|
|
18
26
|
|
|
27
|
+
/**
|
|
28
|
+
* 执行外部命令并收集 stdout/stderr;允许按需把非零退出码作为普通结果返回。
|
|
29
|
+
* 输入为命令名、参数数组与执行选项,输出为标准化执行结果。
|
|
30
|
+
*/
|
|
19
31
|
export async function runCommand(command, args = [], options = {}) {
|
|
20
32
|
const { cwd = process.cwd(), env = process.env, allowFailure = false } = options
|
|
21
33
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs'
|
|
2
2
|
import os from 'node:os'
|
|
3
3
|
import path from 'node:path'
|
|
4
|
-
import { IcodeError } from '
|
|
4
|
+
import { IcodeError } from '../errors.js'
|
|
5
5
|
|
|
6
6
|
const DEFAULT_CONFIG = {
|
|
7
7
|
version: 1,
|
|
@@ -9,6 +9,12 @@ const DEFAULT_CONFIG = {
|
|
|
9
9
|
repoMode: 'auto',
|
|
10
10
|
defaultMainBranches: ['main', 'master']
|
|
11
11
|
},
|
|
12
|
+
platforms: {
|
|
13
|
+
remoteMerge: {
|
|
14
|
+
provider: '',
|
|
15
|
+
apiKey: ''
|
|
16
|
+
}
|
|
17
|
+
},
|
|
12
18
|
ai: {
|
|
13
19
|
activeProfile: '',
|
|
14
20
|
profiles: {},
|
|
@@ -74,6 +80,14 @@ export function readConfig() {
|
|
|
74
80
|
...cloneDefault().defaults,
|
|
75
81
|
...(parsed.defaults || {})
|
|
76
82
|
},
|
|
83
|
+
platforms: {
|
|
84
|
+
...cloneDefault().platforms,
|
|
85
|
+
...(parsed.platforms || {}),
|
|
86
|
+
remoteMerge: {
|
|
87
|
+
...cloneDefault().platforms.remoteMerge,
|
|
88
|
+
...(parsed.platforms?.remoteMerge || {})
|
|
89
|
+
}
|
|
90
|
+
},
|
|
77
91
|
ai: {
|
|
78
92
|
...cloneDefault().ai,
|
|
79
93
|
...(parsed.ai || {}),
|
|
@@ -176,8 +190,12 @@ function normalizeRepoKey(repoRootPath) {
|
|
|
176
190
|
export function getRepoPolicy(repoRootPath) {
|
|
177
191
|
const key = normalizeRepoKey(repoRootPath)
|
|
178
192
|
const config = readConfig()
|
|
179
|
-
return
|
|
180
|
-
protectedBranches: []
|
|
193
|
+
return {
|
|
194
|
+
protectedBranches: [],
|
|
195
|
+
remoteMerge: {
|
|
196
|
+
enabled: true
|
|
197
|
+
},
|
|
198
|
+
...(config.repositories[key] || {})
|
|
181
199
|
}
|
|
182
200
|
}
|
|
183
201
|
|
|
@@ -186,9 +204,41 @@ export function setRepoPolicy(repoRootPath, policy) {
|
|
|
186
204
|
const config = readConfig()
|
|
187
205
|
config.repositories[key] = {
|
|
188
206
|
protectedBranches: [],
|
|
207
|
+
remoteMerge: {
|
|
208
|
+
enabled: true
|
|
209
|
+
},
|
|
189
210
|
...(config.repositories[key] || {}),
|
|
190
211
|
...policy
|
|
191
212
|
}
|
|
192
213
|
writeConfig(config)
|
|
193
214
|
return config.repositories[key]
|
|
194
215
|
}
|
|
216
|
+
|
|
217
|
+
export function getPlatformConfig(platformName) {
|
|
218
|
+
const config = readConfig()
|
|
219
|
+
const name = String(platformName || '').trim()
|
|
220
|
+
if (!name) {
|
|
221
|
+
return {}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return config.platforms?.[name] || {}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function setPlatformConfig(platformName, value) {
|
|
228
|
+
const config = readConfig()
|
|
229
|
+
const name = String(platformName || '').trim()
|
|
230
|
+
if (!name) {
|
|
231
|
+
throw new IcodeError('平台名称不能为空', { code: 'CONFIG_PLATFORM_EMPTY', exitCode: 2 })
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
config.platforms = {
|
|
235
|
+
...(config.platforms || {}),
|
|
236
|
+
[name]: {
|
|
237
|
+
...(config.platforms?.[name] || {}),
|
|
238
|
+
...(value || {})
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
writeConfig(config)
|
|
243
|
+
return config.platforms[name]
|
|
244
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import fs from 'node:fs'
|
|
2
2
|
import path from 'node:path'
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
3
|
+
import { runCommand } from '../commands/shell.js'
|
|
4
|
+
import { readConfig } from '../config/store.js'
|
|
5
|
+
import { IcodeError } from '../errors.js'
|
|
6
6
|
|
|
7
7
|
function cleanOutput(text) {
|
|
8
8
|
return (text || '').trim()
|
|
@@ -13,6 +13,22 @@ async function commandOutput(cwd, args, allowFailure = false) {
|
|
|
13
13
|
return result
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* 确认当前目录属于 Git 仓库。
|
|
18
|
+
* 输入为工作目录,输出为空;不在仓库中时直接抛错,不隐式初始化仓库。
|
|
19
|
+
*/
|
|
20
|
+
async function ensureGitRepository(cwd) {
|
|
21
|
+
const inside = await commandOutput(cwd, ['rev-parse', '--is-inside-work-tree'], true)
|
|
22
|
+
if (inside.exitCode === 0 && cleanOutput(inside.stdout) === 'true') {
|
|
23
|
+
return
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
throw new IcodeError('当前目录不在 Git 仓库中。请先进入已有仓库,或手动执行 git init。', {
|
|
27
|
+
code: 'NOT_IN_GIT_REPO',
|
|
28
|
+
exitCode: 2
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
16
32
|
async function detectDefaultBranch(topLevelPath, fallbackCandidates = ['main', 'master']) {
|
|
17
33
|
const headRefResult = await commandOutput(topLevelPath, ['symbolic-ref', 'refs/remotes/origin/HEAD'], true)
|
|
18
34
|
if (headRefResult.exitCode === 0) {
|
|
@@ -38,19 +54,17 @@ async function detectDefaultBranch(topLevelPath, fallbackCandidates = ['main', '
|
|
|
38
54
|
return 'main'
|
|
39
55
|
}
|
|
40
56
|
|
|
57
|
+
/**
|
|
58
|
+
* 解析当前 Git 仓库上下文。
|
|
59
|
+
* 输入为 cwd/repoMode 等选项,输出仓库根路径、git 目录、当前分支和默认分支等稳定上下文。
|
|
60
|
+
*/
|
|
41
61
|
export async function resolveGitContext(options = {}) {
|
|
42
62
|
const cwd = path.resolve(options.cwd || process.cwd())
|
|
43
63
|
const config = readConfig()
|
|
44
64
|
const configRepoMode = config.defaults?.repoMode || 'auto'
|
|
45
65
|
const repoMode = options.repoMode || configRepoMode
|
|
46
66
|
|
|
47
|
-
|
|
48
|
-
if (inside.exitCode !== 0 || cleanOutput(inside.stdout) !== 'true') {
|
|
49
|
-
throw new IcodeError('当前目录不在 Git 仓库中。', {
|
|
50
|
-
code: 'NOT_IN_GIT_REPO',
|
|
51
|
-
exitCode: 2
|
|
52
|
-
})
|
|
53
|
-
}
|
|
67
|
+
await ensureGitRepository(cwd)
|
|
54
68
|
|
|
55
69
|
const topLevelPath = cleanOutput((await commandOutput(cwd, ['rev-parse', '--show-toplevel'])).stdout)
|
|
56
70
|
const gitDirRaw = cleanOutput((await commandOutput(cwd, ['rev-parse', '--git-dir'])).stdout)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { IcodeError } from '../errors.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 读取当前仓库执行状态。
|
|
5
|
+
* 输入为 GitService 实例,输出当前分支、未完成操作和工作区改动摘要。
|
|
6
|
+
*/
|
|
7
|
+
export async function readGitSafetyState(git) {
|
|
8
|
+
const [currentBranch, operation, changes] = await Promise.all([
|
|
9
|
+
git.getCurrentBranch(),
|
|
10
|
+
git.getInProgressOperation(),
|
|
11
|
+
git.getStatusSummary()
|
|
12
|
+
])
|
|
13
|
+
|
|
14
|
+
return {
|
|
15
|
+
currentBranch,
|
|
16
|
+
operation,
|
|
17
|
+
changes
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 断言当前处于普通分支。
|
|
23
|
+
* 输入为分支名、命令名和错误码,输出为空;不满足时抛出 IcodeError。
|
|
24
|
+
*/
|
|
25
|
+
export function assertOnBranch(currentBranch, commandName, code) {
|
|
26
|
+
if (currentBranch) {
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
throw new IcodeError(`当前不在普通分支上,${commandName} 已停止。`, {
|
|
31
|
+
code,
|
|
32
|
+
exitCode: 2
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 断言不存在未完成的 Git 操作。
|
|
38
|
+
* 输入为操作名、命令名和错误码,输出为空;检测到 merge/rebase/cherry-pick/revert 时抛错。
|
|
39
|
+
*/
|
|
40
|
+
export function assertNoPendingOperation(operation, commandName, code) {
|
|
41
|
+
if (!operation) {
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
throw new IcodeError(`检测到未完成的 Git 操作: ${operation}。请先完成或中止该操作,${commandName} 已停止。`, {
|
|
46
|
+
code,
|
|
47
|
+
exitCode: 2,
|
|
48
|
+
meta: {
|
|
49
|
+
operation
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 断言不存在冲突文件。
|
|
56
|
+
* 输入为改动摘要、命令名和错误码,输出为空;存在冲突文件时抛错。
|
|
57
|
+
*/
|
|
58
|
+
export function assertNoConflictedFiles(changes, commandName, code) {
|
|
59
|
+
if (!changes || changes.conflicted === 0) {
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
throw new IcodeError(`存在 ${changes.conflicted} 个冲突文件,${commandName} 已停止。`, {
|
|
64
|
+
code,
|
|
65
|
+
exitCode: 2,
|
|
66
|
+
meta: {
|
|
67
|
+
changes
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 断言工作区干净。
|
|
74
|
+
* 输入为改动摘要、命令名和错误码,输出为空;存在任何未提交改动时抛错。
|
|
75
|
+
*/
|
|
76
|
+
export function assertCleanWorktree(changes, commandName, code) {
|
|
77
|
+
if (changes && changes.clean) {
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
throw new IcodeError(`工作区存在未提交改动,${commandName} 已停止。请先执行 save 或手动清理。`, {
|
|
82
|
+
code,
|
|
83
|
+
exitCode: 2,
|
|
84
|
+
meta: {
|
|
85
|
+
changes
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
}
|
|
@@ -1,14 +1,101 @@
|
|
|
1
|
-
import { IcodeError } from './errors.js'
|
|
2
|
-
import { buildHookHint, detectHookFailure } from './hook-diagnostics.js'
|
|
3
|
-
import { logger } from './logger.js'
|
|
4
|
-
import { runCommand } from './shell.js'
|
|
5
1
|
import fs from 'node:fs'
|
|
6
2
|
import path from 'node:path'
|
|
3
|
+
import { buildHookHint, detectHookFailure } from './hook-diagnostics.js'
|
|
4
|
+
import { runCommand } from '../commands/shell.js'
|
|
5
|
+
import { IcodeError } from '../errors.js'
|
|
6
|
+
import { logger } from '../tools/logger.js'
|
|
7
7
|
|
|
8
8
|
function cleanOutput(text) {
|
|
9
9
|
return (text || '').trim()
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* 解析 porcelain v1 单行状态。
|
|
14
|
+
* 输入为 `git status --porcelain` 的一行文本,输出为索引区状态、工作区状态和文件路径。
|
|
15
|
+
* 关键假设:本项目只需要稳定分类改动,不在这里还原 rename 的源路径细节。
|
|
16
|
+
*/
|
|
17
|
+
function parsePorcelainLine(line) {
|
|
18
|
+
const indexStatus = line.slice(0, 1)
|
|
19
|
+
const worktreeStatus = line.slice(1, 2)
|
|
20
|
+
const rawPath = line.slice(3).trim()
|
|
21
|
+
const separatorIndex = rawPath.indexOf(' -> ')
|
|
22
|
+
const filePath = separatorIndex === -1 ? rawPath : rawPath.slice(separatorIndex + 4)
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
raw: line,
|
|
26
|
+
indexStatus,
|
|
27
|
+
worktreeStatus,
|
|
28
|
+
filePath
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 汇总 porcelain 状态行。
|
|
34
|
+
* 输入为状态行数组,输出为 staged/unstaged/untracked/conflicted 计数和文件明细。
|
|
35
|
+
* 关键假设:`??` 表示未跟踪文件,`U` 或双边冲突组合表示未解决冲突。
|
|
36
|
+
*/
|
|
37
|
+
function summarizeStatusEntries(entries) {
|
|
38
|
+
const files = []
|
|
39
|
+
const summary = {
|
|
40
|
+
staged: 0,
|
|
41
|
+
unstaged: 0,
|
|
42
|
+
untracked: 0,
|
|
43
|
+
conflicted: 0,
|
|
44
|
+
files
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
for (const entry of entries) {
|
|
48
|
+
const isUntracked = entry.indexStatus === '?' && entry.worktreeStatus === '?'
|
|
49
|
+
const isConflicted = entry.indexStatus === 'U' ||
|
|
50
|
+
entry.worktreeStatus === 'U' ||
|
|
51
|
+
(entry.indexStatus === 'A' && entry.worktreeStatus === 'A') ||
|
|
52
|
+
(entry.indexStatus === 'D' && entry.worktreeStatus === 'D')
|
|
53
|
+
const hasStaged = !isUntracked && entry.indexStatus !== ' ' && entry.indexStatus !== '?' && !isConflicted
|
|
54
|
+
const hasUnstaged = !isUntracked && entry.worktreeStatus !== ' ' && entry.worktreeStatus !== '?' && !isConflicted
|
|
55
|
+
|
|
56
|
+
if (isUntracked) {
|
|
57
|
+
summary.untracked += 1
|
|
58
|
+
}
|
|
59
|
+
if (isConflicted) {
|
|
60
|
+
summary.conflicted += 1
|
|
61
|
+
}
|
|
62
|
+
if (hasStaged) {
|
|
63
|
+
summary.staged += 1
|
|
64
|
+
}
|
|
65
|
+
if (hasUnstaged) {
|
|
66
|
+
summary.unstaged += 1
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
files.push({
|
|
70
|
+
path: entry.filePath,
|
|
71
|
+
indexStatus: entry.indexStatus,
|
|
72
|
+
worktreeStatus: entry.worktreeStatus,
|
|
73
|
+
staged: hasStaged,
|
|
74
|
+
unstaged: hasUnstaged,
|
|
75
|
+
untracked: isUntracked,
|
|
76
|
+
conflicted: isConflicted
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return summary
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function parseGitLogRecords(output) {
|
|
84
|
+
return output
|
|
85
|
+
.split('\x1e')
|
|
86
|
+
.map((record) => record.trim())
|
|
87
|
+
.filter(Boolean)
|
|
88
|
+
.map((record) => {
|
|
89
|
+
const [hash = '', subject = '', body = ''] = record.split('\x1f')
|
|
90
|
+
return {
|
|
91
|
+
hash: hash.trim(),
|
|
92
|
+
subject: subject.trim(),
|
|
93
|
+
body: body.trim()
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
.filter((item) => item.hash && item.subject)
|
|
97
|
+
}
|
|
98
|
+
|
|
12
99
|
export class GitService {
|
|
13
100
|
constructor(context) {
|
|
14
101
|
this.context = context
|
|
@@ -60,6 +147,10 @@ export class GitService {
|
|
|
60
147
|
return 'rebase'
|
|
61
148
|
}
|
|
62
149
|
|
|
150
|
+
if (this.operationFileExists('MERGE_HEAD')) {
|
|
151
|
+
return 'merge'
|
|
152
|
+
}
|
|
153
|
+
|
|
63
154
|
return null
|
|
64
155
|
}
|
|
65
156
|
|
|
@@ -77,6 +168,13 @@ export class GitService {
|
|
|
77
168
|
return result.exitCode === 0
|
|
78
169
|
}
|
|
79
170
|
|
|
171
|
+
async getRemoteUrl(remoteName = 'origin') {
|
|
172
|
+
const result = await this.exec(['remote', 'get-url', remoteName], {
|
|
173
|
+
allowFailure: true
|
|
174
|
+
})
|
|
175
|
+
return cleanOutput(result.stdout)
|
|
176
|
+
}
|
|
177
|
+
|
|
80
178
|
async listLocalBranches() {
|
|
81
179
|
const result = await this.exec(['for-each-ref', '--format=%(refname:short)', 'refs/heads'])
|
|
82
180
|
return result.stdout
|
|
@@ -127,7 +225,9 @@ export class GitService {
|
|
|
127
225
|
async pull(branchName, options = {}) {
|
|
128
226
|
const args = ['pull', 'origin', branchName]
|
|
129
227
|
|
|
130
|
-
if (options.
|
|
228
|
+
if (options.rebase) {
|
|
229
|
+
args.push('--rebase')
|
|
230
|
+
} else if (options.noRebase !== false) {
|
|
131
231
|
args.push('--no-rebase')
|
|
132
232
|
}
|
|
133
233
|
|
|
@@ -153,6 +253,16 @@ export class GitService {
|
|
|
153
253
|
await this.exec(args)
|
|
154
254
|
}
|
|
155
255
|
|
|
256
|
+
// 继续已解决冲突的 merge 操作,供 undo recover 统一恢复合并现场。
|
|
257
|
+
async mergeContinue() {
|
|
258
|
+
await this.exec(['merge', '--continue'])
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// 中止未完成的 merge 操作,保留给显式 recover abort 使用,不自动清理用户现场。
|
|
262
|
+
async mergeAbort() {
|
|
263
|
+
await this.exec(['merge', '--abort'], { allowFailure: true })
|
|
264
|
+
}
|
|
265
|
+
|
|
156
266
|
async rebase(fromRef) {
|
|
157
267
|
await this.exec(['rebase', fromRef])
|
|
158
268
|
}
|
|
@@ -167,12 +277,75 @@ export class GitService {
|
|
|
167
277
|
return result.stdout
|
|
168
278
|
}
|
|
169
279
|
|
|
280
|
+
/**
|
|
281
|
+
* 读取并解析工作区状态。
|
|
282
|
+
* 输入为空,输出为结构化改动摘要,供 ready/save 等场景命令复用。
|
|
283
|
+
*/
|
|
284
|
+
async getStatusSummary() {
|
|
285
|
+
const output = await this.statusPorcelain()
|
|
286
|
+
const entries = output
|
|
287
|
+
.split('\n')
|
|
288
|
+
.map((line) => line.trimEnd())
|
|
289
|
+
.filter(Boolean)
|
|
290
|
+
.map(parsePorcelainLine)
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
clean: entries.length === 0,
|
|
294
|
+
total: entries.length,
|
|
295
|
+
...summarizeStatusEntries(entries)
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
170
299
|
async hasChanges() {
|
|
171
300
|
// 只要 porcelain 有输出,就说明暂存区、工作区或未跟踪文件存在改动。
|
|
172
301
|
const output = await this.statusPorcelain()
|
|
173
302
|
return cleanOutput(output).length > 0
|
|
174
303
|
}
|
|
175
304
|
|
|
305
|
+
/**
|
|
306
|
+
* 读取当前分支的 upstream ref。
|
|
307
|
+
* 输入为空,输出为 upstream 短 ref;没有 upstream 时返回空字符串。
|
|
308
|
+
*/
|
|
309
|
+
async getUpstreamRef() {
|
|
310
|
+
const result = await this.exec(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], {
|
|
311
|
+
allowFailure: true
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
if (result.exitCode !== 0) {
|
|
315
|
+
return ''
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return cleanOutput(result.stdout)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* 读取当前 HEAD 与 upstream 的领先/落后数量。
|
|
323
|
+
* 输入为空,输出 upstream、ahead、behind;没有 upstream 时 explicitly 标记 configured=false。
|
|
324
|
+
*/
|
|
325
|
+
async getUpstreamStatus() {
|
|
326
|
+
const upstream = await this.getUpstreamRef()
|
|
327
|
+
if (!upstream) {
|
|
328
|
+
return {
|
|
329
|
+
configured: false,
|
|
330
|
+
upstream: '',
|
|
331
|
+
ahead: 0,
|
|
332
|
+
behind: 0
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const result = await this.exec(['rev-list', '--left-right', '--count', `${upstream}...HEAD`])
|
|
337
|
+
const [behindRaw = '0', aheadRaw = '0'] = cleanOutput(result.stdout).split(/\s+/)
|
|
338
|
+
const behind = Number.parseInt(behindRaw, 10)
|
|
339
|
+
const ahead = Number.parseInt(aheadRaw, 10)
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
configured: true,
|
|
343
|
+
upstream,
|
|
344
|
+
ahead: Number.isInteger(ahead) ? ahead : 0,
|
|
345
|
+
behind: Number.isInteger(behind) ? behind : 0
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
176
349
|
// 列出未被忽略的未跟踪文件,供 AI 工作流补全新文件 diff。
|
|
177
350
|
async listUntrackedFiles() {
|
|
178
351
|
const result = await this.exec(['ls-files', '--others', '--exclude-standard'])
|
|
@@ -431,6 +604,23 @@ export class GitService {
|
|
|
431
604
|
.filter(Boolean)
|
|
432
605
|
}
|
|
433
606
|
|
|
607
|
+
// 读取指定 range 内的提交信息,返回按时间顺序排列的 hash/subject/body 列表。
|
|
608
|
+
async listCommitMessages(rangeSpec) {
|
|
609
|
+
const result = await this.exec(['log', '--reverse', '--format=%H%x1f%s%x1f%b%x1e', rangeSpec], {
|
|
610
|
+
allowFailure: true
|
|
611
|
+
})
|
|
612
|
+
return parseGitLogRecords(result.stdout)
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// 读取单个 ref 的最新提交信息,供无法从 range 提取提交时回退使用。
|
|
616
|
+
async getCommitMessage(ref = 'HEAD') {
|
|
617
|
+
const result = await this.exec(['log', '-1', '--format=%H%x1f%s%x1f%b%x1e', ref], {
|
|
618
|
+
allowFailure: true
|
|
619
|
+
})
|
|
620
|
+
const commits = parseGitLogRecords(result.stdout)
|
|
621
|
+
return commits[0] || null
|
|
622
|
+
}
|
|
623
|
+
|
|
434
624
|
async cherryPick(commits) {
|
|
435
625
|
const normalized = Array.isArray(commits) ? commits : [commits]
|
|
436
626
|
const list = normalized.map((item) => item.trim()).filter(Boolean)
|