@icode-js/icode 3.2.0 → 3.4.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 (75) hide show
  1. package/README.md +287 -24
  2. package/bin/icode.js +10 -0
  3. package/package.json +3 -3
  4. package/src/cli.js +175 -8
  5. package/src/commands/checkout.js +73 -6
  6. package/src/commands/clean.js +47 -7
  7. package/src/commands/config-ux.js +292 -0
  8. package/src/commands/config.js +199 -48
  9. package/src/commands/continue.js +104 -0
  10. package/src/commands/doctor.js +15 -29
  11. package/src/commands/help.js +54 -34
  12. package/src/commands/history.js +147 -0
  13. package/src/commands/info.js +39 -4
  14. package/src/commands/log.js +142 -0
  15. package/src/commands/migrate.js +19 -12
  16. package/src/commands/open.js +138 -0
  17. package/src/commands/pr.js +219 -0
  18. package/src/commands/push.js +62 -22
  19. package/src/commands/ready.js +18 -48
  20. package/src/commands/release.js +112 -0
  21. package/src/commands/rescue.js +164 -0
  22. package/src/commands/save.js +52 -11
  23. package/src/commands/stash.js +210 -0
  24. package/src/commands/sw.js +89 -0
  25. package/src/commands/sync.js +19 -2
  26. package/src/commands/tag.js +40 -1
  27. package/src/commands/undo.js +9 -3
  28. package/src/core/ai/diff-range.js +1 -1
  29. package/src/core/cli/completion.js +237 -1
  30. package/src/core/cli/output.js +46 -0
  31. package/src/core/cli/render.js +121 -0
  32. package/src/core/commands/shell.js +9 -1
  33. package/src/core/config/repo-config.js +93 -0
  34. package/src/core/config/store.js +60 -10
  35. package/src/core/git/autostash.js +46 -0
  36. package/src/core/git/backup.js +170 -0
  37. package/src/core/git/conflicts.js +58 -0
  38. package/src/core/git/context.js +72 -5
  39. package/src/core/git/file-guard.js +193 -0
  40. package/src/core/git/operation-state.js +63 -0
  41. package/src/core/git/operations.js +32 -0
  42. package/src/core/git/semver.js +194 -0
  43. package/src/core/git/service.js +454 -24
  44. package/src/core/platform/http.js +53 -0
  45. package/src/core/platform/index.js +165 -0
  46. package/src/core/platform/providers.js +454 -0
  47. package/src/core/platform/remote-url.js +92 -0
  48. package/src/core/remote-merge/client.js +33 -3
  49. package/src/core/tools/browser.js +28 -0
  50. package/src/core/tools/commit-prompt.js +137 -0
  51. package/src/core/tools/interactive.js +272 -40
  52. package/src/core/tools/logger.js +33 -23
  53. package/src/core/tools/theme.js +228 -0
  54. package/src/workflows/ai-commit-workflow.js +52 -20
  55. package/src/workflows/checkout-workflow.js +24 -10
  56. package/src/workflows/clean-workflow.js +135 -83
  57. package/src/workflows/continue-workflow.js +233 -0
  58. package/src/workflows/doctor-workflow.js +10 -7
  59. package/src/workflows/history-workflow.js +211 -0
  60. package/src/workflows/migrate-workflow.js +39 -14
  61. package/src/workflows/pr-workflow.js +252 -0
  62. package/src/workflows/push/commit-step.js +123 -0
  63. package/src/workflows/push/local-merge-step.js +213 -0
  64. package/src/workflows/push/plan.js +63 -0
  65. package/src/workflows/push/remote-merge-step.js +224 -0
  66. package/src/workflows/push-workflow.js +185 -492
  67. package/src/workflows/ready-workflow.js +14 -10
  68. package/src/workflows/release-workflow.js +189 -0
  69. package/src/workflows/rescue-workflow.js +137 -0
  70. package/src/workflows/save-workflow.js +106 -41
  71. package/src/workflows/switch-workflow.js +171 -0
  72. package/src/workflows/sync-workflow.js +143 -44
  73. package/src/workflows/tag-workflow.js +103 -17
  74. package/src/workflows/undo-workflow.js +113 -34
  75. package/src/workflows/rollback-workflow.js +0 -99
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs'
2
2
  import path from 'node:path'
3
3
  import { runCommand } from '../commands/shell.js'
4
+ import { readRepoConfig } from '../config/repo-config.js'
4
5
  import { readConfig } from '../config/store.js'
5
6
  import { IcodeError } from '../errors.js'
6
7
 
@@ -29,11 +30,15 @@ async function ensureGitRepository(cwd) {
29
30
  })
30
31
  }
31
32
 
32
- async function detectDefaultBranch(topLevelPath, fallbackCandidates = ['main', 'master']) {
33
- const headRefResult = await commandOutput(topLevelPath, ['symbolic-ref', 'refs/remotes/origin/HEAD'], true)
33
+ async function detectDefaultBranch(topLevelPath, remoteName, fallbackCandidates = ['main', 'master'], preferred = '') {
34
+ if (preferred) {
35
+ return preferred
36
+ }
37
+
38
+ const headRefResult = await commandOutput(topLevelPath, ['symbolic-ref', `refs/remotes/${remoteName}/HEAD`], true)
34
39
  if (headRefResult.exitCode === 0) {
35
40
  const headRef = cleanOutput(headRefResult.stdout)
36
- const branchName = headRef.replace('refs/remotes/origin/', '')
41
+ const branchName = headRef.replace(`refs/remotes/${remoteName}/`, '')
37
42
  if (branchName) {
38
43
  return branchName
39
44
  }
@@ -45,7 +50,7 @@ async function detectDefaultBranch(topLevelPath, fallbackCandidates = ['main', '
45
50
  return candidate
46
51
  }
47
52
 
48
- const remoteRef = await commandOutput(topLevelPath, ['show-ref', '--verify', '--quiet', `refs/remotes/origin/${candidate}`], true)
53
+ const remoteRef = await commandOutput(topLevelPath, ['show-ref', '--verify', '--quiet', `refs/remotes/${remoteName}/${candidate}`], true)
49
54
  if (remoteRef.exitCode === 0) {
50
55
  return candidate
51
56
  }
@@ -54,6 +59,54 @@ async function detectDefaultBranch(topLevelPath, fallbackCandidates = ['main', '
54
59
  return 'main'
55
60
  }
56
61
 
62
+ /**
63
+ * 解析本次命令使用的 remote 名称。
64
+ * 优先级:命令参数 > ICODE_REMOTE 环境变量 > 仓库 .icoderc > 全局配置 defaults.remote
65
+ * > 当前分支 upstream 的 remote > origin > 第一个 remote。
66
+ * 显式指定(参数/环境变量)但仓库里不存在时直接报错,配置项不存在时回退到自动识别。
67
+ */
68
+ export async function resolveRemoteName(topLevelPath, currentBranch, sources = {}) {
69
+ const remotesResult = await commandOutput(topLevelPath, ['remote'], true)
70
+ const remotes = cleanOutput(remotesResult.stdout).split('\n').map((item) => item.trim()).filter(Boolean)
71
+
72
+ for (const explicit of [sources.option, sources.env]) {
73
+ const name = String(explicit || '').trim()
74
+ if (!name) {
75
+ continue
76
+ }
77
+ if (!remotes.includes(name)) {
78
+ throw new IcodeError(`remote 不存在: ${name}。当前仓库 remote: ${remotes.join(', ') || '(无)'}`, {
79
+ code: 'GIT_REMOTE_NOT_FOUND',
80
+ exitCode: 2,
81
+ meta: { remote: name, remotes }
82
+ })
83
+ }
84
+ return { remote: name, source: explicit === sources.option ? 'option' : 'env', remotes }
85
+ }
86
+
87
+ for (const [configured, source] of [[sources.repoConfig, 'repo-config'], [sources.globalConfig, 'config']]) {
88
+ const name = String(configured || '').trim()
89
+ if (name && remotes.includes(name)) {
90
+ return { remote: name, source, remotes }
91
+ }
92
+ }
93
+
94
+ if (currentBranch) {
95
+ const upstreamRemote = cleanOutput(
96
+ (await commandOutput(topLevelPath, ['config', '--get', `branch.${currentBranch}.remote`], true)).stdout
97
+ )
98
+ if (upstreamRemote && upstreamRemote !== '.' && remotes.includes(upstreamRemote)) {
99
+ return { remote: upstreamRemote, source: 'upstream', remotes }
100
+ }
101
+ }
102
+
103
+ if (remotes.includes('origin') || !remotes.length) {
104
+ return { remote: 'origin', source: 'default', remotes }
105
+ }
106
+
107
+ return { remote: remotes[0], source: 'first-remote', remotes }
108
+ }
109
+
57
110
  /**
58
111
  * 解析当前 Git 仓库上下文。
59
112
  * 输入为 cwd/repoMode 等选项,输出仓库根路径、git 目录、当前分支和默认分支等稳定上下文。
@@ -96,9 +149,19 @@ export async function resolveGitContext(options = {}) {
96
149
  const hasHuskyFolder = fs.existsSync(path.resolve(topLevelPath, '.husky'))
97
150
  const hasHookPath = fs.existsSync(hookPath)
98
151
 
152
+ const repoConfig = readRepoConfig(topLevelPath)
153
+ const remoteInfo = await resolveRemoteName(topLevelPath, currentBranch, {
154
+ option: options.remote,
155
+ env: process.env.ICODE_REMOTE,
156
+ repoConfig: repoConfig.remote,
157
+ globalConfig: config.defaults?.remote
158
+ })
159
+
99
160
  const defaultBranch = await detectDefaultBranch(
100
161
  topLevelPath,
101
- config.defaults?.defaultMainBranches || ['main', 'master']
162
+ remoteInfo.remote,
163
+ config.defaults?.defaultMainBranches || ['main', 'master'],
164
+ repoConfig.defaultBranch
102
165
  )
103
166
 
104
167
  return {
@@ -109,6 +172,10 @@ export async function resolveGitContext(options = {}) {
109
172
  commonDir,
110
173
  currentBranch,
111
174
  defaultBranch,
175
+ remote: remoteInfo.remote,
176
+ remoteSource: remoteInfo.source,
177
+ remotes: remoteInfo.remotes,
178
+ repoConfig,
112
179
  inheritedFromParent,
113
180
  hasHuskyFolder,
114
181
  hookPath,
@@ -0,0 +1,193 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { IcodeError } from '../errors.js'
4
+
5
+ // 文件名层面的敏感规则(glob 风格,匹配文件 basename 或相对路径)。
6
+ const DEFAULT_SENSITIVE_NAME_PATTERNS = [
7
+ '.env',
8
+ '.env.*',
9
+ '*.pem',
10
+ '*.key',
11
+ '*.p12',
12
+ '*.pfx',
13
+ '*.keystore',
14
+ '*.jks',
15
+ 'id_rsa',
16
+ 'id_rsa.*',
17
+ 'id_dsa',
18
+ 'id_ecdsa',
19
+ 'id_ed25519',
20
+ '.pypirc',
21
+ '.netrc',
22
+ 'credentials',
23
+ 'credentials.json',
24
+ '*.credentials.json',
25
+ 'service-account*.json',
26
+ '*.tfstate',
27
+ '*.kdbx'
28
+ ]
29
+
30
+ // 这些文件即使命中上面的规则也视为安全(模板/示例)。
31
+ const DEFAULT_ALLOW_NAME_PATTERNS = [
32
+ '.env.example',
33
+ '.env.sample',
34
+ '.env.template',
35
+ '.env.dist',
36
+ '*.example',
37
+ '*.sample'
38
+ ]
39
+
40
+ // 内容层面的密钥特征。只取高置信度规则,降低误报。
41
+ const SECRET_CONTENT_RULES = [
42
+ { id: 'private-key', label: '私钥', pattern: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY(?: BLOCK)?-----/ },
43
+ { id: 'aws-access-key', label: 'AWS Access Key', pattern: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/ },
44
+ { id: 'github-token', label: 'GitHub Token', pattern: /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{60,})\b/ },
45
+ { id: 'gitlab-token', label: 'GitLab Token', pattern: /\bglpat-[A-Za-z0-9_-]{20,}\b/ },
46
+ { id: 'slack-token', label: 'Slack Token', pattern: /\bxox[abposr]-[A-Za-z0-9-]{10,}\b/ },
47
+ { id: 'openai-key', label: 'OpenAI/Anthropic Key', pattern: /\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{32,}\b/ },
48
+ { id: 'google-api-key', label: 'Google API Key', pattern: /\bAIza[0-9A-Za-z_-]{35}\b/ },
49
+ { id: 'npm-token', label: 'npm Token', pattern: /\bnpm_[A-Za-z0-9]{36}\b|_authToken\s*=\s*[^\s$]+/ }
50
+ ]
51
+
52
+ const MAX_SCAN_BYTES = 1024 * 1024
53
+
54
+ /**
55
+ * 把简单 glob(只支持 * 和 ?)转成正则。
56
+ * 输入为 glob 字符串,输出 RegExp。
57
+ */
58
+ function globToRegExp(glob) {
59
+ const escaped = String(glob)
60
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
61
+ .replace(/\*/g, '.*')
62
+ .replace(/\?/g, '.')
63
+ return new RegExp(`^${escaped}$`, 'i')
64
+ }
65
+
66
+ function matchesAny(filePath, patterns) {
67
+ const baseName = path.posix.basename(filePath)
68
+ return patterns.some((pattern) => {
69
+ const regexp = globToRegExp(pattern)
70
+ return regexp.test(baseName) || regexp.test(filePath)
71
+ })
72
+ }
73
+
74
+ // 粗略判断二进制:前 8KB 里出现 NUL 字节。
75
+ function looksBinary(buffer) {
76
+ const sample = buffer.subarray(0, Math.min(buffer.length, 8000))
77
+ return sample.includes(0)
78
+ }
79
+
80
+ /**
81
+ * 扫描即将提交的文件,找出敏感文件、疑似密钥和大文件。
82
+ * 输入为仓库根目录、文件列表和规则选项,输出 findings 数组:
83
+ * [{ file, type: 'sensitive-file' | 'secret' | 'large-file', detail }]
84
+ */
85
+ export function scanFilesForRisks(repoRoot, files = [], options = {}) {
86
+ const maxFileSizeMB = Number(options.maxFileSizeMB) > 0 ? Number(options.maxFileSizeMB) : 5
87
+ const maxBytes = maxFileSizeMB * 1024 * 1024
88
+ const sensitivePatterns = [...DEFAULT_SENSITIVE_NAME_PATTERNS, ...(options.sensitivePatterns || [])]
89
+ const allowPatterns = [...DEFAULT_ALLOW_NAME_PATTERNS, ...(options.allowPatterns || [])]
90
+ const findings = []
91
+
92
+ for (const file of files) {
93
+ const normalized = String(file).replace(/\\/g, '/')
94
+ if (matchesAny(normalized, allowPatterns)) {
95
+ continue
96
+ }
97
+
98
+ if (matchesAny(normalized, sensitivePatterns)) {
99
+ findings.push({ file: normalized, type: 'sensitive-file', detail: '文件名命中敏感规则' })
100
+ }
101
+
102
+ const absolutePath = path.resolve(repoRoot, normalized)
103
+ let stats
104
+ try {
105
+ stats = fs.statSync(absolutePath)
106
+ } catch {
107
+ continue
108
+ }
109
+ if (!stats.isFile()) {
110
+ continue
111
+ }
112
+
113
+ if (stats.size > maxBytes) {
114
+ findings.push({
115
+ file: normalized,
116
+ type: 'large-file',
117
+ detail: `${(stats.size / 1024 / 1024).toFixed(1)}MB,超过 ${maxFileSizeMB}MB(可考虑 Git LFS)`
118
+ })
119
+ continue
120
+ }
121
+
122
+ if (stats.size > MAX_SCAN_BYTES) {
123
+ continue
124
+ }
125
+
126
+ let buffer
127
+ try {
128
+ buffer = fs.readFileSync(absolutePath)
129
+ } catch {
130
+ continue
131
+ }
132
+ if (looksBinary(buffer)) {
133
+ continue
134
+ }
135
+
136
+ const content = buffer.toString('utf8')
137
+ for (const rule of SECRET_CONTENT_RULES) {
138
+ if (rule.pattern.test(content)) {
139
+ findings.push({ file: normalized, type: 'secret', detail: `疑似包含 ${rule.label}` })
140
+ break
141
+ }
142
+ }
143
+ }
144
+
145
+ return findings
146
+ }
147
+
148
+ /**
149
+ * 格式化风险列表为多行文本。
150
+ */
151
+ export function formatRiskFindings(findings = []) {
152
+ const typeLabel = {
153
+ 'sensitive-file': '敏感文件',
154
+ secret: '疑似密钥',
155
+ 'large-file': '大文件'
156
+ }
157
+ return findings.map((item) => ` - [${typeLabel[item.type] || item.type}] ${item.file}: ${item.detail}`).join('\n')
158
+ }
159
+
160
+ /**
161
+ * 有风险时抛错阻断提交,allowRisky=true 时放行。
162
+ * 输入为 findings、命令名、错误码与 allowRisky,输出为空。
163
+ */
164
+ export function assertNoRiskyFiles(findings, commandName, code, allowRisky = false) {
165
+ if (!findings.length || allowRisky) {
166
+ return
167
+ }
168
+
169
+ throw new IcodeError(
170
+ `检测到 ${findings.length} 个有风险的文件,${commandName} 已停止:\n${formatRiskFindings(findings)}\n` +
171
+ '确认无误可加 --allow-risky 继续;不想提交的文件请加入 .gitignore,或只提交指定路径。',
172
+ {
173
+ code,
174
+ exitCode: 2,
175
+ meta: { findings }
176
+ }
177
+ )
178
+ }
179
+
180
+ /**
181
+ * 一站式:列出待提交文件并扫描风险。
182
+ * 输入为 GitService、上下文和 { mode, paths },输出 { files, findings }。
183
+ */
184
+ export async function inspectPendingFiles(git, context, options = {}) {
185
+ const files = await git.listPendingFiles({ mode: options.mode, paths: options.paths })
186
+ const saveRules = context.repoConfig?.save || {}
187
+ const findings = scanFilesForRisks(context.topLevelPath, files, {
188
+ maxFileSizeMB: saveRules.maxFileSizeMB,
189
+ sensitivePatterns: saveRules.sensitivePatterns,
190
+ allowPatterns: saveRules.allowPatterns
191
+ })
192
+ return { files, findings }
193
+ }
@@ -0,0 +1,63 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ // 多步骤命令(push 合并多个分支、migrate、sync)被冲突打断时,把剩下的步骤记在这里,
5
+ // 解决冲突后 icode continue 读取它接着做完,icode abort 读取它切回原分支。
6
+ // 放在 gitDir(每个 worktree 独立)下,不会被提交。
7
+
8
+ const STATE_DIR = 'icode'
9
+ const STATE_FILE = 'operation.json'
10
+
11
+ function statePath(context) {
12
+ return path.resolve(context.gitDir, STATE_DIR, STATE_FILE)
13
+ }
14
+
15
+ /**
16
+ * 保存中断的操作。state 至少包含 command 与 originalBranch。
17
+ */
18
+ export function saveOperationState(context, state) {
19
+ const filePath = statePath(context)
20
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
21
+ fs.writeFileSync(filePath, `${JSON.stringify({ ...state, savedAt: new Date().toISOString() }, null, 2)}\n`, 'utf8')
22
+ return filePath
23
+ }
24
+
25
+ /**
26
+ * 读取中断的操作,没有或损坏时返回 null。
27
+ */
28
+ export function loadOperationState(context) {
29
+ const filePath = statePath(context)
30
+ if (!fs.existsSync(filePath)) {
31
+ return null
32
+ }
33
+ try {
34
+ const state = JSON.parse(fs.readFileSync(filePath, 'utf8'))
35
+ return state && typeof state === 'object' && state.command ? state : null
36
+ } catch {
37
+ return null
38
+ }
39
+ }
40
+
41
+ export function clearOperationState(context) {
42
+ fs.rmSync(statePath(context), { force: true })
43
+ }
44
+
45
+ /**
46
+ * 用一句话描述中断的操作,供 ready / 菜单 / continue 展示。
47
+ */
48
+ export function describeOperationState(state) {
49
+ if (!state) {
50
+ return ''
51
+ }
52
+ if (state.command === 'push') {
53
+ const pending = [state.pendingPush, ...(state.remainingTargets || [])].filter(Boolean)
54
+ return `push ${state.sourceBranch}${pending.length ? ` → ${pending.join(', ')}` : ''}`
55
+ }
56
+ if (state.command === 'migrate') {
57
+ return `migrate ${state.sourceBranch} → ${state.targetBranch}`
58
+ }
59
+ if (state.command === 'sync') {
60
+ return `sync ${[state.currentBranch, ...(state.remainingTargets || [])].filter(Boolean).join(', ')}`
61
+ }
62
+ return state.command
63
+ }
@@ -0,0 +1,32 @@
1
+ // 按 Git 操作类型执行 continue / abort,undo 与 continue/abort 命令共用。
2
+
3
+ export const OPERATION_LABELS = {
4
+ merge: '合并 (merge)',
5
+ rebase: '变基 (rebase)',
6
+ 'cherry-pick': '摘取提交 (cherry-pick)',
7
+ revert: '回滚 (revert)'
8
+ }
9
+
10
+ export async function continueOperation(git, operation) {
11
+ if (operation === 'revert') {
12
+ await git.revertContinue()
13
+ } else if (operation === 'merge') {
14
+ await git.mergeContinue()
15
+ } else if (operation === 'rebase') {
16
+ await git.rebaseContinue()
17
+ } else {
18
+ await git.cherryPickContinue()
19
+ }
20
+ }
21
+
22
+ export async function abortOperation(git, operation) {
23
+ if (operation === 'revert') {
24
+ await git.revertAbort()
25
+ } else if (operation === 'merge') {
26
+ await git.mergeAbort()
27
+ } else if (operation === 'rebase') {
28
+ await git.rebaseAbort()
29
+ } else {
30
+ await git.abortCherryPick()
31
+ }
32
+ }
@@ -0,0 +1,194 @@
1
+ import { IcodeError } from '../errors.js'
2
+
3
+ const VERSION_PATTERN = /^(.*?)(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/
4
+
5
+ /**
6
+ * 解析版本 tag,例如 v1.2.3、release-1.2.3、1.2.3-beta.1。
7
+ * 输出 { prefix, major, minor, patch, prerelease } 或 null。
8
+ */
9
+ export function parseVersionTag(tag) {
10
+ const match = String(tag || '').trim().match(VERSION_PATTERN)
11
+ if (!match) {
12
+ return null
13
+ }
14
+ return {
15
+ prefix: match[1],
16
+ major: Number(match[2]),
17
+ minor: Number(match[3]),
18
+ patch: Number(match[4]),
19
+ prerelease: match[5] || ''
20
+ }
21
+ }
22
+
23
+ export function formatVersion(version, prefix = version.prefix ?? 'v') {
24
+ const core = `${version.major}.${version.minor}.${version.patch}`
25
+ return `${prefix}${core}${version.prerelease ? `-${version.prerelease}` : ''}`
26
+ }
27
+
28
+ // 比较两个版本:a>b 返回正数;正式版大于同号预发布版。
29
+ export function compareVersions(a, b) {
30
+ for (const key of ['major', 'minor', 'patch']) {
31
+ if (a[key] !== b[key]) {
32
+ return a[key] - b[key]
33
+ }
34
+ }
35
+ if (a.prerelease === b.prerelease) {
36
+ return 0
37
+ }
38
+ if (!a.prerelease) {
39
+ return 1
40
+ }
41
+ if (!b.prerelease) {
42
+ return -1
43
+ }
44
+ return a.prerelease.localeCompare(b.prerelease, undefined, { numeric: true })
45
+ }
46
+
47
+ /**
48
+ * 从 tag 列表中找最新的版本 tag(可按前缀过滤,默认忽略预发布版)。
49
+ */
50
+ export function findLatestVersionTag(tags = [], options = {}) {
51
+ let latest = null
52
+ for (const tag of tags) {
53
+ const version = parseVersionTag(tag)
54
+ if (!version) {
55
+ continue
56
+ }
57
+ if (options.prefix != null && version.prefix !== options.prefix) {
58
+ continue
59
+ }
60
+ if (version.prerelease && !options.includePrerelease) {
61
+ continue
62
+ }
63
+ if (!latest || compareVersions(version, latest.version) > 0) {
64
+ latest = { tag, version }
65
+ }
66
+ }
67
+ return latest
68
+ }
69
+
70
+ const CONVENTIONAL_PATTERN = /^(\w+)(?:\(([^)]*)\))?(!)?:\s*(.+)$/
71
+
72
+ export function parseConventionalSubject(subject = '') {
73
+ const match = String(subject).match(CONVENTIONAL_PATTERN)
74
+ if (!match) {
75
+ return { type: '', scope: '', breaking: false, description: String(subject).trim() }
76
+ }
77
+ return {
78
+ type: match[1].toLowerCase(),
79
+ scope: match[2] || '',
80
+ breaking: Boolean(match[3]),
81
+ description: match[4].trim()
82
+ }
83
+ }
84
+
85
+ /**
86
+ * 根据 Conventional Commits 推断版本升级类型:破坏性变更 -> major,feat -> minor,其它 -> patch。
87
+ * 0.x 版本的破坏性变更只升 minor(semver 约定)。
88
+ */
89
+ export function inferBump(commits = [], currentVersion = null) {
90
+ let bump = 'patch'
91
+ for (const commit of commits) {
92
+ const parsed = parseConventionalSubject(commit.subject)
93
+ const breaking = parsed.breaking || /BREAKING[ -]CHANGE/.test(commit.body || '')
94
+ if (breaking) {
95
+ return currentVersion && currentVersion.major === 0 ? 'minor' : 'major'
96
+ }
97
+ if (parsed.type === 'feat') {
98
+ bump = 'minor'
99
+ }
100
+ }
101
+ return bump
102
+ }
103
+
104
+ /**
105
+ * 计算下一个版本。kind: major | minor | patch | prerelease;preid 用于预发布版(如 beta)。
106
+ */
107
+ export function bumpVersion(version, kind, preid = '') {
108
+ const base = version || { prefix: 'v', major: 0, minor: 0, patch: 0, prerelease: '' }
109
+ const next = { ...base, prerelease: '' }
110
+
111
+ if (kind === 'major') {
112
+ next.major += 1
113
+ next.minor = 0
114
+ next.patch = 0
115
+ } else if (kind === 'minor') {
116
+ next.minor += 1
117
+ next.patch = 0
118
+ } else if (kind === 'patch') {
119
+ // 从预发布版发正式版时不再额外加 patch。
120
+ if (!base.prerelease) {
121
+ next.patch += 1
122
+ }
123
+ } else if (kind === 'prerelease') {
124
+ const id = preid || 'beta'
125
+ const match = base.prerelease.match(new RegExp(`^${id}\\.(\\d+)$`))
126
+ if (match) {
127
+ next.prerelease = `${id}.${Number(match[1]) + 1}`
128
+ } else {
129
+ next.patch += base.prerelease ? 0 : 1
130
+ next.prerelease = `${id}.0`
131
+ }
132
+ } else {
133
+ throw new IcodeError(`不支持的版本升级类型: ${kind}(可选 major | minor | patch | prerelease | auto)`, {
134
+ code: 'SEMVER_BUMP_INVALID',
135
+ exitCode: 2
136
+ })
137
+ }
138
+
139
+ if (preid && kind !== 'prerelease') {
140
+ next.prerelease = `${preid}.0`
141
+ }
142
+ return next
143
+ }
144
+
145
+ const SECTION_TITLES = [
146
+ ['breaking', '⚠️ 破坏性变更'],
147
+ ['feat', '新功能'],
148
+ ['fix', '问题修复'],
149
+ ['perf', '性能优化'],
150
+ ['refactor', '重构'],
151
+ ['docs', '文档'],
152
+ ['other', '其它']
153
+ ]
154
+
155
+ /**
156
+ * 生成 Markdown changelog 片段。
157
+ * 输入为提交列表与 { version, date, compareUrl, commitUrl },输出字符串。
158
+ */
159
+ export function buildChangelog(commits = [], options = {}) {
160
+ const groups = new Map(SECTION_TITLES.map(([key]) => [key, []]))
161
+ for (const commit of commits) {
162
+ const parsed = parseConventionalSubject(commit.subject)
163
+ if (['chore', 'ci', 'build', 'test', 'style'].includes(parsed.type) && !parsed.breaking) {
164
+ continue
165
+ }
166
+ if (/^Merge (branch|pull request|remote-tracking)/.test(commit.subject)) {
167
+ continue
168
+ }
169
+ const breaking = parsed.breaking || /BREAKING[ -]CHANGE/.test(commit.body || '')
170
+ const key = breaking ? 'breaking' : groups.has(parsed.type) ? parsed.type : 'other'
171
+ const scope = parsed.scope ? `**${parsed.scope}**: ` : ''
172
+ const shortHash = String(commit.hash || '').slice(0, 7)
173
+ const link = shortHash && options.commitUrl ? ` ([${shortHash}](${options.commitUrl(commit.hash)}))` : shortHash ? ` (${shortHash})` : ''
174
+ groups.get(key).push(`- ${scope}${parsed.description}${link}`)
175
+ }
176
+
177
+ const heading = options.compareUrl
178
+ ? `## [${options.version}](${options.compareUrl}) (${options.date})`
179
+ : `## ${options.version} (${options.date})`
180
+ const lines = [heading, '']
181
+ let hasEntries = false
182
+ for (const [key, title] of SECTION_TITLES) {
183
+ const items = groups.get(key)
184
+ if (!items.length) {
185
+ continue
186
+ }
187
+ hasEntries = true
188
+ lines.push(`### ${title}`, '', ...items, '')
189
+ }
190
+ if (!hasEntries) {
191
+ lines.push('- 维护性更新', '')
192
+ }
193
+ return lines.join('\n')
194
+ }