@icode-js/icode 3.1.0 → 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/README.md +105 -5
- package/package.json +1 -1
- package/src/cli.js +6 -0
- package/src/commands/clean.js +4 -1
- package/src/commands/codereview.js +4 -1
- package/src/commands/completion.js +9 -6
- package/src/commands/config.js +9 -6
- package/src/commands/doctor.js +80 -0
- package/src/commands/explain.js +4 -1
- package/src/commands/help.js +10 -0
- package/src/commands/info.js +4 -1
- package/src/commands/migrate.js +38 -1
- package/src/commands/push.js +45 -18
- package/src/commands/ready.js +110 -0
- package/src/commands/save.js +98 -0
- package/src/commands/undo.js +13 -1
- package/src/core/ai/diff-prompt.js +204 -0
- package/src/core/cli/args.js +23 -2
- package/src/core/cli/completion.js +33 -0
- package/src/core/config/store.js +2 -4
- package/src/core/git/context.js +14 -15
- package/src/core/git/preconditions.js +88 -0
- package/src/core/git/service.js +151 -1
- package/src/core/remote-merge/providers/gitlab.js +149 -90
- package/src/core/tools/interactive.js +209 -34
- package/src/core/tools/loading.js +5 -1
- package/src/core/tools/logger.js +5 -0
- package/src/workflows/ai-codereview-workflow.js +14 -2
- package/src/workflows/ai-commit-workflow.js +14 -6
- package/src/workflows/checkout-workflow.js +14 -2
- package/src/workflows/clean-workflow.js +87 -70
- package/src/workflows/doctor-workflow.js +131 -0
- package/src/workflows/migrate-workflow.js +265 -58
- package/src/workflows/push-workflow.js +232 -50
- package/src/workflows/ready-workflow.js +278 -0
- package/src/workflows/rollback-workflow.js +20 -5
- package/src/workflows/save-workflow.js +152 -0
- package/src/workflows/sync-workflow.js +18 -2
- package/src/workflows/undo-workflow.js +25 -7
package/src/core/config/store.js
CHANGED
|
@@ -193,8 +193,7 @@ export function getRepoPolicy(repoRootPath) {
|
|
|
193
193
|
return {
|
|
194
194
|
protectedBranches: [],
|
|
195
195
|
remoteMerge: {
|
|
196
|
-
enabled: true
|
|
197
|
-
projectUrl: ''
|
|
196
|
+
enabled: true
|
|
198
197
|
},
|
|
199
198
|
...(config.repositories[key] || {})
|
|
200
199
|
}
|
|
@@ -206,8 +205,7 @@ export function setRepoPolicy(repoRootPath, policy) {
|
|
|
206
205
|
config.repositories[key] = {
|
|
207
206
|
protectedBranches: [],
|
|
208
207
|
remoteMerge: {
|
|
209
|
-
enabled: true
|
|
210
|
-
projectUrl: ''
|
|
208
|
+
enabled: true
|
|
211
209
|
},
|
|
212
210
|
...(config.repositories[key] || {}),
|
|
213
211
|
...policy
|
package/src/core/git/context.js
CHANGED
|
@@ -3,7 +3,6 @@ import path from 'node:path'
|
|
|
3
3
|
import { runCommand } from '../commands/shell.js'
|
|
4
4
|
import { readConfig } from '../config/store.js'
|
|
5
5
|
import { IcodeError } from '../errors.js'
|
|
6
|
-
import { confirm } from '../tools/interactive.js'
|
|
7
6
|
|
|
8
7
|
function cleanOutput(text) {
|
|
9
8
|
return (text || '').trim()
|
|
@@ -14,22 +13,20 @@ async function commandOutput(cwd, args, allowFailure = false) {
|
|
|
14
13
|
return result
|
|
15
14
|
}
|
|
16
15
|
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
/**
|
|
17
|
+
* 确认当前目录属于 Git 仓库。
|
|
18
|
+
* 输入为工作目录,输出为空;不在仓库中时直接抛错,不隐式初始化仓库。
|
|
19
|
+
*/
|
|
20
|
+
async function ensureGitRepository(cwd) {
|
|
19
21
|
const inside = await commandOutput(cwd, ['rev-parse', '--is-inside-work-tree'], true)
|
|
20
22
|
if (inside.exitCode === 0 && cleanOutput(inside.stdout) === 'true') {
|
|
21
23
|
return
|
|
22
24
|
}
|
|
23
25
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
exitCode: 2
|
|
29
|
-
})
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
await commandOutput(cwd, ['init'])
|
|
26
|
+
throw new IcodeError('当前目录不在 Git 仓库中。请先进入已有仓库,或手动执行 git init。', {
|
|
27
|
+
code: 'NOT_IN_GIT_REPO',
|
|
28
|
+
exitCode: 2
|
|
29
|
+
})
|
|
33
30
|
}
|
|
34
31
|
|
|
35
32
|
async function detectDefaultBranch(topLevelPath, fallbackCandidates = ['main', 'master']) {
|
|
@@ -57,15 +54,17 @@ async function detectDefaultBranch(topLevelPath, fallbackCandidates = ['main', '
|
|
|
57
54
|
return 'main'
|
|
58
55
|
}
|
|
59
56
|
|
|
57
|
+
/**
|
|
58
|
+
* 解析当前 Git 仓库上下文。
|
|
59
|
+
* 输入为 cwd/repoMode 等选项,输出仓库根路径、git 目录、当前分支和默认分支等稳定上下文。
|
|
60
|
+
*/
|
|
60
61
|
export async function resolveGitContext(options = {}) {
|
|
61
62
|
const cwd = path.resolve(options.cwd || process.cwd())
|
|
62
63
|
const config = readConfig()
|
|
63
64
|
const configRepoMode = config.defaults?.repoMode || 'auto'
|
|
64
65
|
const repoMode = options.repoMode || configRepoMode
|
|
65
66
|
|
|
66
|
-
await ensureGitRepository(cwd
|
|
67
|
-
confirm: options.confirm
|
|
68
|
-
})
|
|
67
|
+
await ensureGitRepository(cwd)
|
|
69
68
|
|
|
70
69
|
const topLevelPath = cleanOutput((await commandOutput(cwd, ['rev-parse', '--show-toplevel'])).stdout)
|
|
71
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
|
+
}
|
package/src/core/git/service.js
CHANGED
|
@@ -9,6 +9,77 @@ 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
|
+
|
|
12
83
|
function parseGitLogRecords(output) {
|
|
13
84
|
return output
|
|
14
85
|
.split('\x1e')
|
|
@@ -76,6 +147,10 @@ export class GitService {
|
|
|
76
147
|
return 'rebase'
|
|
77
148
|
}
|
|
78
149
|
|
|
150
|
+
if (this.operationFileExists('MERGE_HEAD')) {
|
|
151
|
+
return 'merge'
|
|
152
|
+
}
|
|
153
|
+
|
|
79
154
|
return null
|
|
80
155
|
}
|
|
81
156
|
|
|
@@ -150,7 +225,9 @@ export class GitService {
|
|
|
150
225
|
async pull(branchName, options = {}) {
|
|
151
226
|
const args = ['pull', 'origin', branchName]
|
|
152
227
|
|
|
153
|
-
if (options.
|
|
228
|
+
if (options.rebase) {
|
|
229
|
+
args.push('--rebase')
|
|
230
|
+
} else if (options.noRebase !== false) {
|
|
154
231
|
args.push('--no-rebase')
|
|
155
232
|
}
|
|
156
233
|
|
|
@@ -176,6 +253,16 @@ export class GitService {
|
|
|
176
253
|
await this.exec(args)
|
|
177
254
|
}
|
|
178
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
|
+
|
|
179
266
|
async rebase(fromRef) {
|
|
180
267
|
await this.exec(['rebase', fromRef])
|
|
181
268
|
}
|
|
@@ -190,12 +277,75 @@ export class GitService {
|
|
|
190
277
|
return result.stdout
|
|
191
278
|
}
|
|
192
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
|
+
|
|
193
299
|
async hasChanges() {
|
|
194
300
|
// 只要 porcelain 有输出,就说明暂存区、工作区或未跟踪文件存在改动。
|
|
195
301
|
const output = await this.statusPorcelain()
|
|
196
302
|
return cleanOutput(output).length > 0
|
|
197
303
|
}
|
|
198
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
|
+
|
|
199
349
|
// 列出未被忽略的未跟踪文件,供 AI 工作流补全新文件 diff。
|
|
200
350
|
async listUntrackedFiles() {
|
|
201
351
|
const result = await this.exec(['ls-files', '--others', '--exclude-standard'])
|