@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
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { formatAiCommitSummary } from '../../core/ai/commit-summary.js'
|
|
2
|
+
import { IcodeError } from '../../core/errors.js'
|
|
3
|
+
import { assertNoRiskyFiles, inspectPendingFiles } from '../../core/git/file-guard.js'
|
|
4
|
+
import { promptCommitMessage } from '../../core/tools/commit-prompt.js'
|
|
5
|
+
import { isInteractiveTerminal } from '../../core/tools/interactive.js'
|
|
6
|
+
import { logger } from '../../core/tools/logger.js'
|
|
7
|
+
import { runAiCommitWorkflow } from '../ai-commit-workflow.js'
|
|
8
|
+
|
|
9
|
+
// push 前的提交阶段:可选 AI commit,或用 -m 提交;两者都会先做风险扫描。
|
|
10
|
+
|
|
11
|
+
// 提交前扫描敏感文件/疑似密钥/大文件,发现风险时按 --allow-risky 决定是否阻断。
|
|
12
|
+
async function guardPendingFiles(git, context, options) {
|
|
13
|
+
const { findings } = await inspectPendingFiles(git, context, { mode: 'all' })
|
|
14
|
+
assertNoRiskyFiles(findings, 'push', 'PUSH_RISKY_FILES', options.allowRisky === true)
|
|
15
|
+
return findings
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Run AI commit before push and keep the full generated message in follow-up logs.
|
|
19
|
+
export async function prepareAiCommitIfEnabled(git, context, inputOptions, changes) {
|
|
20
|
+
if (!inputOptions.aiCommit) {
|
|
21
|
+
return {
|
|
22
|
+
enabled: false
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (inputOptions.message?.trim()) {
|
|
27
|
+
logger.warn('--ai-commit 已启用,将优先使用 AI 生成的提交信息。')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (changes && !changes.clean) {
|
|
31
|
+
await guardPendingFiles(git, context, inputOptions)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const result = await runAiCommitWorkflow({
|
|
36
|
+
apply: true,
|
|
37
|
+
lang: inputOptions.aiCommitLang || 'zh',
|
|
38
|
+
profile: inputOptions.aiProfile,
|
|
39
|
+
repoMode: inputOptions.repoMode,
|
|
40
|
+
noVerify: inputOptions.noVerify,
|
|
41
|
+
yes: inputOptions.yes,
|
|
42
|
+
cwd: inputOptions.cwd,
|
|
43
|
+
silentContextLog: true
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
if (result.canceled) {
|
|
47
|
+
logger.warn('已取消 AI 自动提交。')
|
|
48
|
+
return {
|
|
49
|
+
enabled: true,
|
|
50
|
+
canceled: true
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
logger.success(`AI 自动提交完成:\n${formatAiCommitSummary(result.commitId, result.commitMessage)}`)
|
|
55
|
+
return {
|
|
56
|
+
enabled: true,
|
|
57
|
+
applied: true,
|
|
58
|
+
commitId: result.commitId,
|
|
59
|
+
commitMessage: result.commitMessage
|
|
60
|
+
}
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (error?.code === 'AI_COMMIT_EMPTY_DIFF') {
|
|
63
|
+
logger.info('未检测到可提交改动,跳过 --ai-commit。')
|
|
64
|
+
return {
|
|
65
|
+
enabled: true,
|
|
66
|
+
skipped: true,
|
|
67
|
+
reason: 'no-diff'
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
throw error
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 在 push 场景下按需创建本地提交,输入 GitService、上下文、命令参数和已读取的改动摘要,输出是否提交。
|
|
76
|
+
export async function prepareCommitIfNeeded(git, context, options, changes) {
|
|
77
|
+
if (changes.clean) {
|
|
78
|
+
logger.info('工作区无改动,跳过 commit。')
|
|
79
|
+
return false
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
await guardPendingFiles(git, context, options)
|
|
83
|
+
|
|
84
|
+
let message = options.message?.trim()
|
|
85
|
+
if (!message && isInteractiveTerminal() && !options.yes) {
|
|
86
|
+
const { files } = await inspectPendingFiles(git, context, { mode: 'all' })
|
|
87
|
+
const answer = await promptCommitMessage({ files })
|
|
88
|
+
if (answer.canceled) {
|
|
89
|
+
throw new IcodeError('已取消提交,push 未执行。', { code: 'PUSH_COMMIT_CANCELED', exitCode: 1 })
|
|
90
|
+
}
|
|
91
|
+
if (answer.useAi) {
|
|
92
|
+
const result = await runAiCommitWorkflow({
|
|
93
|
+
apply: true,
|
|
94
|
+
lang: options.aiCommitLang || 'zh',
|
|
95
|
+
profile: options.aiProfile,
|
|
96
|
+
repoMode: options.repoMode,
|
|
97
|
+
noVerify: options.noVerify,
|
|
98
|
+
cwd: options.cwd,
|
|
99
|
+
silentContextLog: true
|
|
100
|
+
})
|
|
101
|
+
if (result.canceled) {
|
|
102
|
+
throw new IcodeError('已取消提交,push 未执行。', { code: 'PUSH_COMMIT_CANCELED', exitCode: 1 })
|
|
103
|
+
}
|
|
104
|
+
return true
|
|
105
|
+
}
|
|
106
|
+
message = answer.message
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (!message) {
|
|
110
|
+
throw new IcodeError('检测到代码改动但未提供提交信息,请使用 -m 或 --message。', {
|
|
111
|
+
code: 'PUSH_COMMIT_MESSAGE_REQUIRED',
|
|
112
|
+
exitCode: 2
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 统一自动暂存,降低同学手动 add 的负担。
|
|
117
|
+
await git.stageAll()
|
|
118
|
+
await git.commit(message, {
|
|
119
|
+
noVerify: options.noVerify
|
|
120
|
+
})
|
|
121
|
+
logger.success(`已提交 ${message}`)
|
|
122
|
+
return true
|
|
123
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { IcodeError } from '../../core/errors.js'
|
|
2
|
+
import { ui } from '../../core/tools/theme.js'
|
|
3
|
+
import { confirm, isInteractiveTerminal } from '../../core/tools/interactive.js'
|
|
4
|
+
import { logger } from '../../core/tools/logger.js'
|
|
5
|
+
|
|
6
|
+
// 本地 merge 阶段:切到目标分支 -> 同步远程 -> merge 源分支 -> push。
|
|
7
|
+
|
|
8
|
+
// 提取底层 Git 失败细节,输入异常对象,输出用于暂停错误的简洁原因。
|
|
9
|
+
export function resolveGitFailureReason(error) {
|
|
10
|
+
const stderr = typeof error?.meta?.stderr === 'string' ? error.meta.stderr.trim() : ''
|
|
11
|
+
const stdout = typeof error?.meta?.stdout === 'string' ? error.meta.stdout.trim() : ''
|
|
12
|
+
const output = [stderr, stdout].filter(Boolean).join('\n').trim()
|
|
13
|
+
const message = typeof error?.message === 'string' ? error.message.trim() : ''
|
|
14
|
+
const reason = output || message || 'Git 操作失败'
|
|
15
|
+
|
|
16
|
+
return reason.length > 2000 ? `${reason.slice(0, 2000)}...` : reason
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// 判断 Git 失败是否来自合并冲突,输入异常对象,输出布尔结果。
|
|
20
|
+
export function isGitConflictFailure(error) {
|
|
21
|
+
const text = [
|
|
22
|
+
error?.meta?.stderr || '',
|
|
23
|
+
error?.meta?.stdout || '',
|
|
24
|
+
error?.message || ''
|
|
25
|
+
].join('\n')
|
|
26
|
+
|
|
27
|
+
return /CONFLICT \(|Automatic merge failed|fix conflicts|unmerged files|needs merge|you need to resolve your current index first/i.test(text)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// 构建本地合并暂停错误,输入分支、阶段和原始异常,输出可被命令层汇总的 IcodeError。
|
|
31
|
+
export function buildLocalMergePauseError({ sourceBranch, targetBranch, step, error, summary = [] }) {
|
|
32
|
+
const reason = resolveGitFailureReason(error)
|
|
33
|
+
const conflict = isGitConflictFailure(error)
|
|
34
|
+
const prefix = conflict ? '本地合并冲突' : '本地合并失败'
|
|
35
|
+
const nextStep = conflict
|
|
36
|
+
? '解决冲突后执行 `icode continue`(会自动暂存已解决的文件,并接着完成剩下的推送),或执行 `icode abort` 放弃并切回原分支。'
|
|
37
|
+
: '请根据上面的 Git 错误修复后重试。'
|
|
38
|
+
|
|
39
|
+
return new IcodeError(`${prefix}(${step}): ${sourceBranch} -> ${targetBranch}\n${reason}\n${nextStep}`, {
|
|
40
|
+
code: 'PUSH_LOCAL_MERGE_PAUSED',
|
|
41
|
+
exitCode: 2,
|
|
42
|
+
cause: error,
|
|
43
|
+
meta: {
|
|
44
|
+
sourceBranch,
|
|
45
|
+
targetBranch,
|
|
46
|
+
step,
|
|
47
|
+
reason,
|
|
48
|
+
summary
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 目标分支本地和远程都不存在时,是否允许从源分支新建。
|
|
55
|
+
* 显式 --create-target 直接允许;交互终端里询问(默认否);-y 或非交互环境直接报错,避免拼错分支名时悄悄建新分支。
|
|
56
|
+
*/
|
|
57
|
+
async function ensureTargetCreationAllowed(targetBranch, sourceBranch, options) {
|
|
58
|
+
if (options.createTarget) {
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!options.yes && isInteractiveTerminal()) {
|
|
63
|
+
const accepted = await confirm(`目标分支 ${targetBranch} 在本地和远程都不存在,是否从 ${sourceBranch} 新建?`, false)
|
|
64
|
+
if (accepted) {
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
throw new IcodeError(
|
|
70
|
+
`目标分支不存在: ${targetBranch}。如确实要从 ${sourceBranch} 新建,请加 --create-target。`,
|
|
71
|
+
{
|
|
72
|
+
code: 'PUSH_TARGET_MISSING',
|
|
73
|
+
exitCode: 2,
|
|
74
|
+
meta: { targetBranch, sourceBranch }
|
|
75
|
+
}
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function checkoutTargetBranch(git, targetBranch, sourceBranch, options = {}, summary = []) {
|
|
80
|
+
const localExists = await git.branchExistsLocal(targetBranch)
|
|
81
|
+
const remoteExists = await git.branchExistsRemote(targetBranch)
|
|
82
|
+
let checkoutMode = 'local'
|
|
83
|
+
|
|
84
|
+
if (localExists) {
|
|
85
|
+
await git.checkout(targetBranch)
|
|
86
|
+
} else if (remoteExists) {
|
|
87
|
+
await git.checkoutTracking(targetBranch)
|
|
88
|
+
checkoutMode = 'tracking'
|
|
89
|
+
} else {
|
|
90
|
+
await ensureTargetCreationAllowed(targetBranch, sourceBranch, options)
|
|
91
|
+
logger.warn(`目标分支 ${ui.branch(targetBranch)} 不存在,将从 ${ui.branch(sourceBranch)} 创建。`)
|
|
92
|
+
await git.checkoutNewBranch(targetBranch, sourceBranch)
|
|
93
|
+
checkoutMode = 'created'
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (remoteExists) {
|
|
97
|
+
try {
|
|
98
|
+
await git.pull(targetBranch, {
|
|
99
|
+
noRebase: true
|
|
100
|
+
})
|
|
101
|
+
} catch (error) {
|
|
102
|
+
throw buildLocalMergePauseError({
|
|
103
|
+
sourceBranch,
|
|
104
|
+
targetBranch,
|
|
105
|
+
step: 'pull-target',
|
|
106
|
+
error,
|
|
107
|
+
summary
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
remoteExists,
|
|
114
|
+
checkoutMode
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 把源分支本地 merge 到目标分支并推送。
|
|
120
|
+
* 输入为 GitService、源/目标分支、push 选项和摘要数组,输出该分支的摘要项。
|
|
121
|
+
*/
|
|
122
|
+
export async function mergeIntoTargetAndPush(git, sourceBranch, targetBranch, options, summary) {
|
|
123
|
+
logger.info(`处理分支: ${ui.branch(targetBranch)}`)
|
|
124
|
+
logger.info(`切换到目标分支: ${ui.branch(targetBranch)}`)
|
|
125
|
+
const { remoteExists, checkoutMode } = await checkoutTargetBranch(git, targetBranch, sourceBranch, options, summary)
|
|
126
|
+
logger.info(`目标分支准备完成: ${ui.branch(targetBranch)} (${checkoutMode})`)
|
|
127
|
+
|
|
128
|
+
// 保留 merge commit,方便后续追溯“从哪个分支合并过来”。
|
|
129
|
+
logger.info(`合并分支: ${ui.branch(sourceBranch)} -> ${ui.branch(targetBranch)}`)
|
|
130
|
+
try {
|
|
131
|
+
await git.merge(sourceBranch, {
|
|
132
|
+
noFf: true,
|
|
133
|
+
noEdit: true
|
|
134
|
+
})
|
|
135
|
+
} catch (error) {
|
|
136
|
+
throw buildLocalMergePauseError({
|
|
137
|
+
sourceBranch,
|
|
138
|
+
targetBranch,
|
|
139
|
+
step: 'merge-target',
|
|
140
|
+
error,
|
|
141
|
+
summary
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
logger.success(`合并成功: ${ui.branch(sourceBranch)} -> ${ui.branch(targetBranch)}`)
|
|
145
|
+
|
|
146
|
+
logger.info(`推送目标分支: ${ui.branch(targetBranch)}`)
|
|
147
|
+
await git.push(targetBranch, {
|
|
148
|
+
setUpstream: !remoteExists,
|
|
149
|
+
noVerify: options.noVerify
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
logger.success(`目标分支推送成功: ${ui.branch(targetBranch)}`)
|
|
153
|
+
return { branch: targetBranch, status: 'merged-and-pushed' }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* 同步并推送当前分支;force=true 时跳过 pull,使用 --force-with-lease 覆盖远程。
|
|
158
|
+
* expectedRemoteSha 是 fetch 之前记录的远程 sha,用来防止覆盖别人刚推上去的提交。
|
|
159
|
+
*/
|
|
160
|
+
export async function pushCurrentBranch(git, branchName, options, summary) {
|
|
161
|
+
logger.info(`处理分支: ${ui.branch(branchName)}`)
|
|
162
|
+
const remoteExists = await git.branchExistsRemote(branchName)
|
|
163
|
+
|
|
164
|
+
if (options.force) {
|
|
165
|
+
logger.warn(`安全强推当前分支: ${ui.branch(branchName)}(--force-with-lease)`)
|
|
166
|
+
try {
|
|
167
|
+
await git.push(branchName, {
|
|
168
|
+
setUpstream: !remoteExists,
|
|
169
|
+
noVerify: options.noVerify,
|
|
170
|
+
forceWithLease: remoteExists,
|
|
171
|
+
expectedRemoteSha: options.expectedRemoteSha
|
|
172
|
+
})
|
|
173
|
+
} catch (error) {
|
|
174
|
+
const reason = resolveGitFailureReason(error)
|
|
175
|
+
if (/stale info|rejected/i.test(reason)) {
|
|
176
|
+
throw new IcodeError(
|
|
177
|
+
`强推被拒绝:远程 ${git.remoteRef(branchName)} 在你上次 fetch 之后有新提交,已停止以免覆盖别人的改动。\n` +
|
|
178
|
+
'请先查看远程改动(git log HEAD..' + git.remoteRef(branchName) + '),合并或 rebase 后再推。',
|
|
179
|
+
{ code: 'PUSH_FORCE_LEASE_REJECTED', exitCode: 2, cause: error, meta: { reason, summary } }
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
throw error
|
|
183
|
+
}
|
|
184
|
+
logger.success(`强推成功: ${ui.branch(branchName)}`)
|
|
185
|
+
return { branch: branchName, status: 'force-pushed' }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (remoteExists) {
|
|
189
|
+
logger.info(`同步远程分支: ${ui.branch(branchName)}`)
|
|
190
|
+
try {
|
|
191
|
+
await git.pull(branchName, {
|
|
192
|
+
noRebase: true
|
|
193
|
+
})
|
|
194
|
+
} catch (error) {
|
|
195
|
+
throw buildLocalMergePauseError({
|
|
196
|
+
sourceBranch: git.remoteRef(branchName),
|
|
197
|
+
targetBranch: branchName,
|
|
198
|
+
step: 'pull-current',
|
|
199
|
+
error,
|
|
200
|
+
summary
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
logger.info(`推送当前分支: ${ui.branch(branchName)}`)
|
|
206
|
+
await git.push(branchName, {
|
|
207
|
+
setUpstream: !remoteExists,
|
|
208
|
+
noVerify: options.noVerify
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
logger.success(`推送成功: ${ui.branch(branchName)}`)
|
|
212
|
+
return { branch: branchName, status: 'pushed' }
|
|
213
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// 构建 push dry-run 步骤,输入分支目标和策略,输出不会产生副作用的计划列表。
|
|
2
|
+
export function buildPushDryRunPlan({ currentBranch, branchTargets, protectedBranches, inputOptions, remote = 'origin' }) {
|
|
3
|
+
const steps = []
|
|
4
|
+
|
|
5
|
+
if (inputOptions.aiCommit) {
|
|
6
|
+
steps.push({
|
|
7
|
+
action: 'ai-commit',
|
|
8
|
+
branch: currentBranch,
|
|
9
|
+
reason: '将生成并提交 AI commit'
|
|
10
|
+
})
|
|
11
|
+
} else {
|
|
12
|
+
steps.push({
|
|
13
|
+
action: 'commit-if-needed',
|
|
14
|
+
branch: currentBranch,
|
|
15
|
+
reason: inputOptions.message ? '检测到改动时使用 -m/--message 提交' : '检测到改动时会要求输入提交信息'
|
|
16
|
+
})
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (inputOptions.pullMain) {
|
|
20
|
+
steps.push({
|
|
21
|
+
action: 'pull-main',
|
|
22
|
+
branch: currentBranch,
|
|
23
|
+
reason: '将主分支同步到当前分支'
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
branchTargets.forEach((targetBranch) => {
|
|
28
|
+
if (protectedBranches.has(targetBranch) && !inputOptions.forceProtected) {
|
|
29
|
+
steps.push({
|
|
30
|
+
action: 'skip-protected',
|
|
31
|
+
branch: targetBranch,
|
|
32
|
+
reason: '受保护分支,未传 --force-protected'
|
|
33
|
+
})
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (targetBranch === currentBranch) {
|
|
38
|
+
steps.push({
|
|
39
|
+
action: inputOptions.force ? 'force-push-current' : 'push-current',
|
|
40
|
+
branch: targetBranch,
|
|
41
|
+
reason: inputOptions.force
|
|
42
|
+
? `将以 --force-with-lease 覆盖 ${remote}/${targetBranch}`
|
|
43
|
+
: '将同步并推送当前分支'
|
|
44
|
+
})
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
steps.push({
|
|
49
|
+
action: inputOptions.remoteMerge ? 'remote-merge' : 'local-merge',
|
|
50
|
+
branch: targetBranch,
|
|
51
|
+
reason: inputOptions.remoteMerge ? '将创建并合并远程 PR/MR' : '将切换目标分支、本地 merge 后推送'
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
currentBranch,
|
|
57
|
+
branchTargets,
|
|
58
|
+
remote,
|
|
59
|
+
remoteMerge: Boolean(inputOptions.remoteMerge),
|
|
60
|
+
force: Boolean(inputOptions.force),
|
|
61
|
+
steps
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { readConfig } from '../../core/config/store.js'
|
|
2
|
+
import { ui } from '../../core/tools/theme.js'
|
|
3
|
+
import { IcodeError } from '../../core/errors.js'
|
|
4
|
+
import { resolvePlatformToken, resolveProviderName } from '../../core/platform/index.js'
|
|
5
|
+
import { requestRemoteMerge, resolveRemoteMergeProjectUrl } from '../../core/remote-merge/client.js'
|
|
6
|
+
import { logger } from '../../core/tools/logger.js'
|
|
7
|
+
|
|
8
|
+
// 远程合并阶段:通过平台 API 创建 PR/MR 并合并,不在本地切换分支。
|
|
9
|
+
|
|
10
|
+
// 基于 commit 内容构建远程 MR 标题与正文,确保标题和内容都来自真实提交信息。
|
|
11
|
+
export async function buildRemoteMergeCommitContent(git, sourceBranch, targetBranch) {
|
|
12
|
+
const rangeSpec = `${git.remoteRef(targetBranch)}..${sourceBranch}`
|
|
13
|
+
let commits = await git.listCommitMessages(rangeSpec)
|
|
14
|
+
|
|
15
|
+
if (!commits.length) {
|
|
16
|
+
const latestCommit = await git.getCommitMessage(sourceBranch)
|
|
17
|
+
if (latestCommit) {
|
|
18
|
+
commits = [latestCommit]
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (!commits.length) {
|
|
23
|
+
throw new IcodeError(`无法读取远程合并提交内容: ${sourceBranch} -> ${targetBranch}`, {
|
|
24
|
+
code: 'PUSH_REMOTE_MERGE_COMMIT_CONTENT_EMPTY',
|
|
25
|
+
exitCode: 2
|
|
26
|
+
})
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const latestCommit = commits[commits.length - 1]
|
|
30
|
+
const description = commits
|
|
31
|
+
.map((commit) => {
|
|
32
|
+
if (commit.body) {
|
|
33
|
+
return `${commit.subject}\n\n${commit.body}`
|
|
34
|
+
}
|
|
35
|
+
return commit.subject
|
|
36
|
+
})
|
|
37
|
+
.join('\n\n')
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
title: latestCommit.subject,
|
|
41
|
+
description
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 提取远程合并失败原因,优先使用 IcodeError.meta,其次回退到 error.message。
|
|
46
|
+
function resolveRemoteMergeFailureReason(error) {
|
|
47
|
+
const metaStep = typeof error?.meta?.step === 'string' ? error.meta.step.trim() : ''
|
|
48
|
+
const metaReason = typeof error?.meta?.reason === 'string' ? error.meta.reason.trim() : ''
|
|
49
|
+
if (metaReason) {
|
|
50
|
+
return metaStep ? `${metaReason} [step=${metaStep}]` : metaReason
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const message = typeof error?.message === 'string' ? error.message.trim() : ''
|
|
54
|
+
return message || '远程合并失败'
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function normalizeRemoteMergePolicy(policy = {}) {
|
|
58
|
+
const remoteMerge = policy.remoteMerge && typeof policy.remoteMerge === 'object' ? policy.remoteMerge : {}
|
|
59
|
+
return {
|
|
60
|
+
enabled: remoteMerge.enabled !== false
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 解析远程合并使用的平台和 token。
|
|
66
|
+
* provider 优先按 remote 主机名识别(github/gitlab/gitee/gitea),识别不到再用旧配置 platforms.remoteMerge.provider。
|
|
67
|
+
*/
|
|
68
|
+
export async function resolveRemoteMergePlatform(git) {
|
|
69
|
+
const config = readConfig()
|
|
70
|
+
const remoteUrl = await git.getRemoteUrl()
|
|
71
|
+
const provider = resolveProviderName(remoteUrl, {}, config)
|
|
72
|
+
const { token, source } = provider ? resolvePlatformToken(provider, config) : { token: '', source: '' }
|
|
73
|
+
return {
|
|
74
|
+
provider,
|
|
75
|
+
apiKey: token,
|
|
76
|
+
tokenSource: source,
|
|
77
|
+
remoteUrl
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function buildRemoteMergePauseError(targetBranch, reason, meta = {}) {
|
|
82
|
+
return new IcodeError(`远程合并已暂停(${targetBranch}): ${reason}`, {
|
|
83
|
+
code: 'PUSH_REMOTE_MERGE_PAUSED',
|
|
84
|
+
exitCode: 2,
|
|
85
|
+
meta: {
|
|
86
|
+
targetBranch,
|
|
87
|
+
reason,
|
|
88
|
+
...meta
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 统一把 provider 返回的 step 注入错误文案,便于定位 create/merge/inspect 具体失败点。
|
|
94
|
+
function buildRemoteMergeResultMessage(prefix, result) {
|
|
95
|
+
const reason = typeof result?.reason === 'string' && result.reason.trim()
|
|
96
|
+
? result.reason.trim()
|
|
97
|
+
: '未知原因'
|
|
98
|
+
const step = typeof result?.step === 'string' && result.step.trim()
|
|
99
|
+
? result.step.trim()
|
|
100
|
+
: ''
|
|
101
|
+
|
|
102
|
+
return step ? `${prefix}(${step}): ${reason}` : `${prefix}: ${reason}`
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 构建批量远程合并暂停错误,明确指出具体失败分支与原因。
|
|
106
|
+
export function buildRemoteMergeBatchPauseError(failures, summary) {
|
|
107
|
+
const branchesLabel = failures.map((item) => item.branch).join(', ')
|
|
108
|
+
const detail = failures.map((item) => `${item.branch}: ${item.reason}`).join(' | ')
|
|
109
|
+
|
|
110
|
+
return new IcodeError(`远程合并已暂停(${branchesLabel}): ${detail}`, {
|
|
111
|
+
code: 'PUSH_REMOTE_MERGE_PAUSED',
|
|
112
|
+
exitCode: 2,
|
|
113
|
+
meta: {
|
|
114
|
+
failures,
|
|
115
|
+
summary
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 基于当前 remote 推导项目地址并发起 PR/MR 创建 + 合并请求。
|
|
121
|
+
async function performRemoteMerge({ git, currentBranch, targetBranch, remoteExists, inputOptions, remoteMergePolicy, remoteMergePlatform }) {
|
|
122
|
+
if (!remoteExists) {
|
|
123
|
+
throw buildRemoteMergePauseError(targetBranch, `远程合并要求目标分支已存在于 ${git.remote}。`, {
|
|
124
|
+
remoteExists: false
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!remoteMergePolicy.enabled) {
|
|
129
|
+
throw buildRemoteMergePauseError(targetBranch, '仓库未启用远程合并配置,请先配置 repositories.<repo>.remoteMerge.enabled=true。')
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (!remoteMergePlatform.provider) {
|
|
133
|
+
throw buildRemoteMergePauseError(targetBranch, `无法从 ${git.remote} 识别代码托管平台,请执行 icode config platform host set <host> github|gitlab|gitee|gitea。`, {
|
|
134
|
+
remote: remoteMergePlatform.remoteUrl
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (!remoteMergePlatform.apiKey) {
|
|
139
|
+
throw buildRemoteMergePauseError(targetBranch, `未配置 ${remoteMergePlatform.provider} 平台 token,请先执行 icode config platform token set ${remoteMergePlatform.provider} <token>。`)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const projectUrl = resolveRemoteMergeProjectUrl({
|
|
143
|
+
provider: remoteMergePlatform.provider,
|
|
144
|
+
remoteUrl: remoteMergePlatform.remoteUrl
|
|
145
|
+
})
|
|
146
|
+
if (!projectUrl) {
|
|
147
|
+
throw buildRemoteMergePauseError(targetBranch, `无法从 ${git.remote} 自动识别项目 URL,请检查 remote 地址。`, {
|
|
148
|
+
remote: remoteMergePlatform.remoteUrl
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
logger.info(`${remoteMergePlatform.provider} 项目 URL(${git.remote}): ${logger.color('green', projectUrl)}`)
|
|
152
|
+
|
|
153
|
+
logger.info(`发起远程合并: ${logger.color('yellow', currentBranch)} -> ${logger.color('yellow', targetBranch)}`)
|
|
154
|
+
const mergeRequestContent = await buildRemoteMergeCommitContent(git, currentBranch, targetBranch)
|
|
155
|
+
const result = await requestRemoteMerge({
|
|
156
|
+
provider: remoteMergePlatform.provider,
|
|
157
|
+
apiKey: remoteMergePlatform.apiKey,
|
|
158
|
+
projectUrl,
|
|
159
|
+
sourceBranch: currentBranch,
|
|
160
|
+
targetBranch,
|
|
161
|
+
mergeRequestTitle: mergeRequestContent.title,
|
|
162
|
+
mergeRequestDescription: mergeRequestContent.description,
|
|
163
|
+
repoRoot: git.cwd,
|
|
164
|
+
noVerify: inputOptions.noVerify
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
if (result.conflict) {
|
|
168
|
+
throw buildRemoteMergePauseError(targetBranch, buildRemoteMergeResultMessage('远程合并冲突', result), {
|
|
169
|
+
sourceBranch: currentBranch,
|
|
170
|
+
step: result.step || '',
|
|
171
|
+
result
|
|
172
|
+
})
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (!result.ok) {
|
|
176
|
+
throw buildRemoteMergePauseError(targetBranch, buildRemoteMergeResultMessage('远程合并失败', result), {
|
|
177
|
+
sourceBranch: currentBranch,
|
|
178
|
+
step: result.step || '',
|
|
179
|
+
result
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const successReason = typeof result.reason === 'string' && result.reason.trim()
|
|
184
|
+
? result.reason.trim()
|
|
185
|
+
: '远程合并已完成'
|
|
186
|
+
|
|
187
|
+
if (result.mergeRequestUrl) {
|
|
188
|
+
logger.success(`${successReason}: ${logger.color('green', result.mergeRequestUrl)}`)
|
|
189
|
+
} else {
|
|
190
|
+
logger.success(`${successReason}: ${logger.color('yellow', currentBranch)} -> ${logger.color('yellow', targetBranch)}`)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return result
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// 并发执行多个远程合并目标分支,并把冲突/失败精确归档到对应分支。
|
|
197
|
+
export async function runConcurrentRemoteMerges(options) {
|
|
198
|
+
const tasks = options.targetBranches.map(async (targetBranch) => {
|
|
199
|
+
try {
|
|
200
|
+
logger.info(`处理分支: ${ui.branch(targetBranch)}`)
|
|
201
|
+
const remoteExists = await options.git.branchExistsRemote(targetBranch)
|
|
202
|
+
const remoteMergeResult = await performRemoteMerge({
|
|
203
|
+
...options,
|
|
204
|
+
targetBranch,
|
|
205
|
+
remoteExists
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
branch: targetBranch,
|
|
210
|
+
status: 'remote-merged-and-pushed',
|
|
211
|
+
mergeRequestUrl: remoteMergeResult.mergeRequestUrl || '',
|
|
212
|
+
mergeRequestId: remoteMergeResult.mergeRequestId || ''
|
|
213
|
+
}
|
|
214
|
+
} catch (error) {
|
|
215
|
+
return {
|
|
216
|
+
branch: targetBranch,
|
|
217
|
+
status: 'paused',
|
|
218
|
+
reason: resolveRemoteMergeFailureReason(error)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
return Promise.all(tasks)
|
|
224
|
+
}
|