@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.
- package/README.md +287 -24
- package/bin/icode.js +10 -0
- package/package.json +3 -3
- package/src/cli.js +175 -8
- package/src/commands/checkout.js +73 -6
- package/src/commands/clean.js +47 -7
- package/src/commands/config-ux.js +292 -0
- package/src/commands/config.js +199 -48
- package/src/commands/continue.js +104 -0
- package/src/commands/doctor.js +15 -29
- package/src/commands/help.js +54 -34
- package/src/commands/history.js +147 -0
- package/src/commands/info.js +39 -4
- package/src/commands/log.js +142 -0
- package/src/commands/migrate.js +19 -12
- package/src/commands/open.js +138 -0
- package/src/commands/pr.js +219 -0
- package/src/commands/push.js +62 -22
- package/src/commands/ready.js +18 -48
- package/src/commands/release.js +112 -0
- package/src/commands/rescue.js +164 -0
- package/src/commands/save.js +52 -11
- package/src/commands/stash.js +210 -0
- package/src/commands/sw.js +89 -0
- package/src/commands/sync.js +19 -2
- package/src/commands/tag.js +40 -1
- package/src/commands/undo.js +9 -3
- package/src/core/ai/diff-range.js +1 -1
- package/src/core/cli/completion.js +237 -1
- package/src/core/cli/output.js +46 -0
- package/src/core/cli/render.js +121 -0
- package/src/core/commands/shell.js +9 -1
- package/src/core/config/repo-config.js +93 -0
- package/src/core/config/store.js +60 -10
- package/src/core/git/autostash.js +46 -0
- package/src/core/git/backup.js +170 -0
- package/src/core/git/conflicts.js +58 -0
- package/src/core/git/context.js +72 -5
- package/src/core/git/file-guard.js +193 -0
- package/src/core/git/operation-state.js +63 -0
- package/src/core/git/operations.js +32 -0
- package/src/core/git/semver.js +194 -0
- package/src/core/git/service.js +454 -24
- package/src/core/platform/http.js +53 -0
- package/src/core/platform/index.js +165 -0
- package/src/core/platform/providers.js +454 -0
- package/src/core/platform/remote-url.js +92 -0
- package/src/core/remote-merge/client.js +33 -3
- package/src/core/tools/browser.js +28 -0
- package/src/core/tools/commit-prompt.js +137 -0
- package/src/core/tools/interactive.js +272 -40
- package/src/core/tools/logger.js +33 -23
- package/src/core/tools/theme.js +228 -0
- package/src/workflows/ai-commit-workflow.js +52 -20
- package/src/workflows/checkout-workflow.js +24 -10
- package/src/workflows/clean-workflow.js +135 -83
- package/src/workflows/continue-workflow.js +233 -0
- package/src/workflows/doctor-workflow.js +10 -7
- package/src/workflows/history-workflow.js +211 -0
- package/src/workflows/migrate-workflow.js +39 -14
- package/src/workflows/pr-workflow.js +252 -0
- package/src/workflows/push/commit-step.js +123 -0
- package/src/workflows/push/local-merge-step.js +213 -0
- package/src/workflows/push/plan.js +63 -0
- package/src/workflows/push/remote-merge-step.js +224 -0
- package/src/workflows/push-workflow.js +185 -492
- package/src/workflows/ready-workflow.js +14 -10
- package/src/workflows/release-workflow.js +189 -0
- package/src/workflows/rescue-workflow.js +137 -0
- package/src/workflows/save-workflow.js +106 -41
- package/src/workflows/switch-workflow.js +171 -0
- package/src/workflows/sync-workflow.js +143 -44
- package/src/workflows/tag-workflow.js +103 -17
- package/src/workflows/undo-workflow.js +113 -34
- package/src/workflows/rollback-workflow.js +0 -99
package/src/core/git/context.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|