@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.
Files changed (39) hide show
  1. package/README.md +105 -5
  2. package/package.json +1 -1
  3. package/src/cli.js +6 -0
  4. package/src/commands/clean.js +4 -1
  5. package/src/commands/codereview.js +4 -1
  6. package/src/commands/completion.js +9 -6
  7. package/src/commands/config.js +9 -6
  8. package/src/commands/doctor.js +80 -0
  9. package/src/commands/explain.js +4 -1
  10. package/src/commands/help.js +10 -0
  11. package/src/commands/info.js +4 -1
  12. package/src/commands/migrate.js +38 -1
  13. package/src/commands/push.js +45 -18
  14. package/src/commands/ready.js +110 -0
  15. package/src/commands/save.js +98 -0
  16. package/src/commands/undo.js +13 -1
  17. package/src/core/ai/diff-prompt.js +204 -0
  18. package/src/core/cli/args.js +23 -2
  19. package/src/core/cli/completion.js +33 -0
  20. package/src/core/config/store.js +2 -4
  21. package/src/core/git/context.js +14 -15
  22. package/src/core/git/preconditions.js +88 -0
  23. package/src/core/git/service.js +151 -1
  24. package/src/core/remote-merge/providers/gitlab.js +149 -90
  25. package/src/core/tools/interactive.js +209 -34
  26. package/src/core/tools/loading.js +5 -1
  27. package/src/core/tools/logger.js +5 -0
  28. package/src/workflows/ai-codereview-workflow.js +14 -2
  29. package/src/workflows/ai-commit-workflow.js +14 -6
  30. package/src/workflows/checkout-workflow.js +14 -2
  31. package/src/workflows/clean-workflow.js +87 -70
  32. package/src/workflows/doctor-workflow.js +131 -0
  33. package/src/workflows/migrate-workflow.js +265 -58
  34. package/src/workflows/push-workflow.js +232 -50
  35. package/src/workflows/ready-workflow.js +278 -0
  36. package/src/workflows/rollback-workflow.js +20 -5
  37. package/src/workflows/save-workflow.js +152 -0
  38. package/src/workflows/sync-workflow.js +18 -2
  39. package/src/workflows/undo-workflow.js +25 -7
@@ -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
@@ -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
- async function ensureGitRepository(cwd, options = {}) {
18
- const confirmInit = options.confirm || confirm
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
- const shouldInit = await confirmInit('当前目录还没有初始化 Git 仓库,是否现在执行 git init?')
25
- if (!shouldInit) {
26
- throw new IcodeError('当前目录不在 Git 仓库中。', {
27
- code: 'NOT_IN_GIT_REPO',
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
+ }
@@ -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.noRebase !== false) {
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'])