@icode-js/icode 3.0.3 → 3.1.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 (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +40 -3
  3. package/bin/icode.js +4 -4
  4. package/package.json +1 -1
  5. package/src/cli.js +54 -23
  6. package/src/commands/ai.js +4 -4
  7. package/src/commands/checkout.js +2 -2
  8. package/src/commands/clean.js +1 -1
  9. package/src/commands/codereview.js +1 -1
  10. package/src/commands/completion.js +90 -0
  11. package/src/commands/config.js +117 -7
  12. package/src/commands/explain.js +2 -2
  13. package/src/commands/help.js +7 -0
  14. package/src/commands/info.js +1 -1
  15. package/src/commands/migrate.js +2 -2
  16. package/src/commands/push.js +29 -8
  17. package/src/commands/sync.js +2 -2
  18. package/src/commands/tag.js +2 -2
  19. package/src/commands/undo.js +60 -9
  20. package/src/core/{ai-client.js → ai/client.js} +3 -3
  21. package/src/core/{ai-config.js → ai/config.js} +2 -2
  22. package/src/core/{ai-diff-range.js → ai/diff-range.js} +2 -2
  23. package/src/core/cli/completion.js +687 -0
  24. package/src/core/cli/update-notifier.js +138 -0
  25. package/src/core/{shell.js → commands/shell.js} +13 -1
  26. package/src/core/{config-store.js → config/store.js} +55 -3
  27. package/src/core/{git-context.js → git/context.js} +25 -10
  28. package/src/core/{git-service.js → git/service.js} +78 -6
  29. package/src/core/remote-merge/client.js +42 -0
  30. package/src/core/remote-merge/providers/gitlab.js +537 -0
  31. package/src/core/remote-merge/shared.js +49 -0
  32. package/src/core/{prompts.js → tools/interactive.js} +46 -5
  33. package/src/workflows/ai-codereview-workflow.js +4 -4
  34. package/src/workflows/ai-commit-workflow.js +30 -15
  35. package/src/workflows/ai-conflict-workflow.js +4 -4
  36. package/src/workflows/ai-explain-workflow.js +4 -4
  37. package/src/workflows/ai-risk-review-workflow.js +1 -1
  38. package/src/workflows/checkout-workflow.js +3 -3
  39. package/src/workflows/clean-workflow.js +5 -5
  40. package/src/workflows/info-workflow.js +4 -4
  41. package/src/workflows/migrate-workflow.js +4 -4
  42. package/src/workflows/push-workflow.js +259 -24
  43. package/src/workflows/rollback-workflow.js +4 -4
  44. package/src/workflows/sync-workflow.js +4 -4
  45. package/src/workflows/tag-workflow.js +4 -4
  46. package/src/workflows/undo-workflow.js +12 -5
  47. /package/src/core/{ai-commit-summary.js → ai/commit-summary.js} +0 -0
  48. /package/src/core/{args.js → cli/args.js} +0 -0
  49. /package/src/core/{commit-conventions.js → git/commit-conventions.js} +0 -0
  50. /package/src/core/{hook-diagnostics.js → git/hook-diagnostics.js} +0 -0
  51. /package/src/core/{loading.js → tools/loading.js} +0 -0
  52. /package/src/core/{logger.js → tools/logger.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 './errors.js'
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 './errors.js'
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,13 @@ function normalizeRepoKey(repoRootPath) {
176
190
  export function getRepoPolicy(repoRootPath) {
177
191
  const key = normalizeRepoKey(repoRootPath)
178
192
  const config = readConfig()
179
- return config.repositories[key] || {
180
- protectedBranches: []
193
+ return {
194
+ protectedBranches: [],
195
+ remoteMerge: {
196
+ enabled: true,
197
+ projectUrl: ''
198
+ },
199
+ ...(config.repositories[key] || {})
181
200
  }
182
201
  }
183
202
 
@@ -186,9 +205,42 @@ export function setRepoPolicy(repoRootPath, policy) {
186
205
  const config = readConfig()
187
206
  config.repositories[key] = {
188
207
  protectedBranches: [],
208
+ remoteMerge: {
209
+ enabled: true,
210
+ projectUrl: ''
211
+ },
189
212
  ...(config.repositories[key] || {}),
190
213
  ...policy
191
214
  }
192
215
  writeConfig(config)
193
216
  return config.repositories[key]
194
217
  }
218
+
219
+ export function getPlatformConfig(platformName) {
220
+ const config = readConfig()
221
+ const name = String(platformName || '').trim()
222
+ if (!name) {
223
+ return {}
224
+ }
225
+
226
+ return config.platforms?.[name] || {}
227
+ }
228
+
229
+ export function setPlatformConfig(platformName, value) {
230
+ const config = readConfig()
231
+ const name = String(platformName || '').trim()
232
+ if (!name) {
233
+ throw new IcodeError('平台名称不能为空', { code: 'CONFIG_PLATFORM_EMPTY', exitCode: 2 })
234
+ }
235
+
236
+ config.platforms = {
237
+ ...(config.platforms || {}),
238
+ [name]: {
239
+ ...(config.platforms?.[name] || {}),
240
+ ...(value || {})
241
+ }
242
+ }
243
+
244
+ writeConfig(config)
245
+ return config.platforms[name]
246
+ }
@@ -1,8 +1,9 @@
1
1
  import fs from 'node:fs'
2
2
  import path from 'node:path'
3
- import { readConfig } from './config-store.js'
4
- import { IcodeError } from './errors.js'
5
- import { runCommand } from './shell.js'
3
+ import { runCommand } from '../commands/shell.js'
4
+ import { readConfig } from '../config/store.js'
5
+ import { IcodeError } from '../errors.js'
6
+ import { confirm } from '../tools/interactive.js'
6
7
 
7
8
  function cleanOutput(text) {
8
9
  return (text || '').trim()
@@ -13,6 +14,24 @@ async function commandOutput(cwd, args, allowFailure = false) {
13
14
  return result
14
15
  }
15
16
 
17
+ async function ensureGitRepository(cwd, options = {}) {
18
+ const confirmInit = options.confirm || confirm
19
+ const inside = await commandOutput(cwd, ['rev-parse', '--is-inside-work-tree'], true)
20
+ if (inside.exitCode === 0 && cleanOutput(inside.stdout) === 'true') {
21
+ return
22
+ }
23
+
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'])
33
+ }
34
+
16
35
  async function detectDefaultBranch(topLevelPath, fallbackCandidates = ['main', 'master']) {
17
36
  const headRefResult = await commandOutput(topLevelPath, ['symbolic-ref', 'refs/remotes/origin/HEAD'], true)
18
37
  if (headRefResult.exitCode === 0) {
@@ -44,13 +63,9 @@ export async function resolveGitContext(options = {}) {
44
63
  const configRepoMode = config.defaults?.repoMode || 'auto'
45
64
  const repoMode = options.repoMode || configRepoMode
46
65
 
47
- const inside = await commandOutput(cwd, ['rev-parse', '--is-inside-work-tree'], true)
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
- }
66
+ await ensureGitRepository(cwd, {
67
+ confirm: options.confirm
68
+ })
54
69
 
55
70
  const topLevelPath = cleanOutput((await commandOutput(cwd, ['rev-parse', '--show-toplevel'])).stdout)
56
71
  const gitDirRaw = cleanOutput((await commandOutput(cwd, ['rev-parse', '--git-dir'])).stdout)
@@ -1,14 +1,30 @@
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
+ function parseGitLogRecords(output) {
13
+ return output
14
+ .split('\x1e')
15
+ .map((record) => record.trim())
16
+ .filter(Boolean)
17
+ .map((record) => {
18
+ const [hash = '', subject = '', body = ''] = record.split('\x1f')
19
+ return {
20
+ hash: hash.trim(),
21
+ subject: subject.trim(),
22
+ body: body.trim()
23
+ }
24
+ })
25
+ .filter((item) => item.hash && item.subject)
26
+ }
27
+
12
28
  export class GitService {
13
29
  constructor(context) {
14
30
  this.context = context
@@ -77,6 +93,13 @@ export class GitService {
77
93
  return result.exitCode === 0
78
94
  }
79
95
 
96
+ async getRemoteUrl(remoteName = 'origin') {
97
+ const result = await this.exec(['remote', 'get-url', remoteName], {
98
+ allowFailure: true
99
+ })
100
+ return cleanOutput(result.stdout)
101
+ }
102
+
80
103
  async listLocalBranches() {
81
104
  const result = await this.exec(['for-each-ref', '--format=%(refname:short)', 'refs/heads'])
82
105
  return result.stdout
@@ -162,18 +185,50 @@ export class GitService {
162
185
  }
163
186
 
164
187
  async statusPorcelain() {
188
+ // 返回标准 porcelain 状态,供上层统一判断“是否存在未提交改动”。
165
189
  const result = await this.exec(['status', '--porcelain'])
166
190
  return result.stdout
167
191
  }
168
192
 
169
193
  async hasChanges() {
194
+ // 只要 porcelain 有输出,就说明暂存区、工作区或未跟踪文件存在改动。
170
195
  const output = await this.statusPorcelain()
171
196
  return cleanOutput(output).length > 0
172
197
  }
173
198
 
174
- async diffWorkingTree() {
175
- const result = await this.exec(['diff'])
199
+ // 列出未被忽略的未跟踪文件,供 AI 工作流补全新文件 diff。
200
+ async listUntrackedFiles() {
201
+ const result = await this.exec(['ls-files', '--others', '--exclude-standard'])
176
202
  return result.stdout
203
+ .split('\n')
204
+ .map((item) => item.trim())
205
+ .filter(Boolean)
206
+ }
207
+
208
+ // 通过 no-index diff 生成“/dev/null -> 新文件”的补丁,覆盖未跟踪文件内容。
209
+ async buildUntrackedFileDiff(filePath) {
210
+ const result = await this.exec(['diff', '--no-index', '--binary', '--', '/dev/null', filePath], {
211
+ allowFailure: true
212
+ })
213
+ return result.stdout
214
+ }
215
+
216
+ async diffWorkingTree() {
217
+ // git diff 默认不包含 untracked 文件,这里显式补齐,避免新仓库被误判成“无改动”。
218
+ const trackedResult = await this.exec(['diff'])
219
+ const untrackedFiles = await this.listUntrackedFiles()
220
+ const untrackedDiffs = []
221
+
222
+ for (const filePath of untrackedFiles) {
223
+ const diff = await this.buildUntrackedFileDiff(filePath)
224
+ if (cleanOutput(diff)) {
225
+ untrackedDiffs.push(diff.trimEnd())
226
+ }
227
+ }
228
+
229
+ return [trackedResult.stdout.trimEnd(), ...untrackedDiffs]
230
+ .filter(Boolean)
231
+ .join('\n\n')
177
232
  }
178
233
 
179
234
  async diffStaged() {
@@ -399,6 +454,23 @@ export class GitService {
399
454
  .filter(Boolean)
400
455
  }
401
456
 
457
+ // 读取指定 range 内的提交信息,返回按时间顺序排列的 hash/subject/body 列表。
458
+ async listCommitMessages(rangeSpec) {
459
+ const result = await this.exec(['log', '--reverse', '--format=%H%x1f%s%x1f%b%x1e', rangeSpec], {
460
+ allowFailure: true
461
+ })
462
+ return parseGitLogRecords(result.stdout)
463
+ }
464
+
465
+ // 读取单个 ref 的最新提交信息,供无法从 range 提取提交时回退使用。
466
+ async getCommitMessage(ref = 'HEAD') {
467
+ const result = await this.exec(['log', '-1', '--format=%H%x1f%s%x1f%b%x1e', ref], {
468
+ allowFailure: true
469
+ })
470
+ const commits = parseGitLogRecords(result.stdout)
471
+ return commits[0] || null
472
+ }
473
+
402
474
  async cherryPick(commits) {
403
475
  const normalized = Array.isArray(commits) ? commits : [commits]
404
476
  const list = normalized.map((item) => item.trim()).filter(Boolean)
@@ -0,0 +1,42 @@
1
+ import { IcodeError } from '../errors.js'
2
+ import { requestGitLabRemoteMerge, resolveGitLabProjectUrlFromRemote } from './providers/gitlab.js'
3
+
4
+ const REMOTE_MERGE_PROVIDERS = {
5
+ gitlab: {
6
+ requestRemoteMerge: requestGitLabRemoteMerge,
7
+ resolveProjectUrlFromRemote: resolveGitLabProjectUrlFromRemote
8
+ }
9
+ }
10
+
11
+ // 按 provider 读取远程合并实现,缺失或不支持时统一抛出明确错误。
12
+ function getRemoteMergeProvider(provider) {
13
+ const normalizedProvider = String(provider || '').trim()
14
+ if (!normalizedProvider) {
15
+ throw new IcodeError('远程合并缺少 provider', {
16
+ code: 'REMOTE_MERGE_PROVIDER_EMPTY',
17
+ exitCode: 2
18
+ })
19
+ }
20
+
21
+ const handler = REMOTE_MERGE_PROVIDERS[normalizedProvider]
22
+ if (!handler) {
23
+ throw new IcodeError(`当前仅支持: ${Object.keys(REMOTE_MERGE_PROVIDERS).join(', ')},收到 provider=${normalizedProvider}`, {
24
+ code: 'REMOTE_MERGE_PROVIDER_UNSUPPORTED',
25
+ exitCode: 2
26
+ })
27
+ }
28
+
29
+ return handler
30
+ }
31
+
32
+ // 统一根据 provider 从 remote URL 解析项目地址,避免工作流层感知平台细节。
33
+ export function resolveRemoteMergeProjectUrl(options = {}) {
34
+ const handler = getRemoteMergeProvider(options.provider)
35
+ return handler.resolveProjectUrlFromRemote(options.remoteUrl || '')
36
+ }
37
+
38
+ // 统一按 provider 调度远程合并请求,后续新增平台只需补 provider 实现。
39
+ export async function requestRemoteMerge(options = {}) {
40
+ const handler = getRemoteMergeProvider(options.provider)
41
+ return handler.requestRemoteMerge(options)
42
+ }