@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,8 @@
1
- import { formatAiCommitSummary } from '../core/ai/commit-summary.js'
2
- import { getPlatformConfig, getRepoPolicy } from '../core/config/store.js'
1
+ import { getRepoPolicy } from '../core/config/store.js'
2
+ import { ui, shortenHome } from '../core/tools/theme.js'
3
3
  import { IcodeError } from '../core/errors.js'
4
+ import { createBackup } from '../core/git/backup.js'
5
+ import { saveOperationState } from '../core/git/operation-state.js'
4
6
  import { resolveGitContext } from '../core/git/context.js'
5
7
  import {
6
8
  assertNoConflictedFiles,
@@ -8,448 +10,134 @@ import {
8
10
  readGitSafetyState
9
11
  } from '../core/git/preconditions.js'
10
12
  import { GitService } from '../core/git/service.js'
11
- import { confirm, input } from '../core/tools/interactive.js'
12
- import { requestRemoteMerge, resolveRemoteMergeProjectUrl } from '../core/remote-merge/client.js'
13
+ import { chooseMany, chooseOne, confirm, isInteractiveTerminal } from '../core/tools/interactive.js'
13
14
  import { logger } from '../core/tools/logger.js'
14
- import { runAiCommitWorkflow } from './ai-commit-workflow.js'
15
+ import { prepareAiCommitIfEnabled, prepareCommitIfNeeded } from './push/commit-step.js'
16
+ import { buildLocalMergePauseError, mergeIntoTargetAndPush, pushCurrentBranch } from './push/local-merge-step.js'
17
+ import { buildPushDryRunPlan } from './push/plan.js'
18
+ import {
19
+ buildRemoteMergeBatchPauseError,
20
+ normalizeRemoteMergePolicy,
21
+ resolveRemoteMergePlatform,
22
+ runConcurrentRemoteMerges
23
+ } from './push/remote-merge-step.js'
24
+
25
+ // push 只负责编排:提交 -> 同步主分支 -> 逐个处理目标分支(推送当前 / 本地 merge / 远程合并)。
26
+ // 各阶段的具体实现见 ./push/ 目录。
15
27
 
16
28
  function uniqueBranches(branches) {
17
29
  return [...new Set(branches.map((item) => item.trim()).filter(Boolean))]
18
30
  }
19
31
 
20
- // 基于 commit 内容构建远程 MR 标题与正文,确保标题和内容都来自真实提交信息。
21
- async function buildRemoteMergeCommitContent(git, sourceBranch, targetBranch) {
22
- const rangeSpec = `origin/${targetBranch}..${sourceBranch}`
23
- let commits = await git.listCommitMessages(rangeSpec)
32
+ // 合并配置里的受保护分支:全局配置 + 仓库 .icoderc。
33
+ function resolveProtectedBranches(policy, context) {
34
+ return new Set(
35
+ [...(policy.protectedBranches || []), ...(context.repoConfig?.protectedBranches || [])]
36
+ .map((item) => String(item).trim())
37
+ .filter(Boolean)
38
+ )
39
+ }
24
40
 
25
- if (!commits.length) {
26
- const latestCommit = await git.getCommitMessage(sourceBranch)
27
- if (latestCommit) {
28
- commits = [latestCommit]
29
- }
41
+ /**
42
+ * 强推只允许作用在当前分支,并拒绝主分支和受保护分支(除非 --force-protected)。
43
+ */
44
+ function validateForcePush(inputOptions, branchTargets, currentBranch, context, protectedBranches) {
45
+ if (!inputOptions.force) {
46
+ return
30
47
  }
31
48
 
32
- if (!commits.length) {
33
- throw new IcodeError(`无法读取远程合并提交内容: ${sourceBranch} -> ${targetBranch}`, {
34
- code: 'PUSH_REMOTE_MERGE_COMMIT_CONTENT_EMPTY',
49
+ if (inputOptions.remoteMerge || branchTargets.some((branch) => branch !== currentBranch)) {
50
+ throw new IcodeError('--force 只能用于推送当前分支,不能和目标分支或远程合并一起使用。', {
51
+ code: 'PUSH_FORCE_SCOPE_INVALID',
35
52
  exitCode: 2
36
53
  })
37
54
  }
38
55
 
39
- const latestCommit = commits[commits.length - 1]
40
- const description = commits
41
- .map((commit) => {
42
- if (commit.body) {
43
- return `${commit.subject}\n\n${commit.body}`
44
- }
45
- return commit.subject
46
- })
47
- .join('\n\n')
48
-
49
- return {
50
- title: latestCommit.subject,
51
- description
52
- }
53
- }
54
-
55
- // 提取远程合并失败原因,优先使用 IcodeError.meta,其次回退到 error.message。
56
- function resolveRemoteMergeFailureReason(error) {
57
- const metaStep = typeof error?.meta?.step === 'string' ? error.meta.step.trim() : ''
58
- const metaReason = typeof error?.meta?.reason === 'string' ? error.meta.reason.trim() : ''
59
- if (metaReason) {
60
- return metaStep ? `${metaReason} [step=${metaStep}]` : metaReason
61
- }
62
-
63
- const message = typeof error?.message === 'string' ? error.message.trim() : ''
64
- return message || '远程合并失败'
65
- }
66
-
67
- // Run AI commit before push and keep the full generated message in follow-up logs.
68
- async function prepareAiCommitIfEnabled(inputOptions) {
69
- if (!inputOptions.aiCommit) {
70
- return {
71
- enabled: false
72
- }
73
- }
74
-
75
- if (inputOptions.message?.trim()) {
76
- logger.warn('--ai-commit 已启用,将优先使用 AI 生成的提交信息。')
77
- }
78
-
79
- try {
80
- const result = await runAiCommitWorkflow({
81
- apply: true,
82
- lang: inputOptions.aiCommitLang || 'zh',
83
- profile: inputOptions.aiProfile,
84
- repoMode: inputOptions.repoMode,
85
- noVerify: inputOptions.noVerify,
86
- yes: inputOptions.yes,
87
- cwd: inputOptions.cwd,
88
- silentContextLog: true
56
+ if (!branchTargets.includes(currentBranch)) {
57
+ throw new IcodeError('--force 需要推送当前分支,不能和 --not-push-current 一起使用。', {
58
+ code: 'PUSH_FORCE_SCOPE_INVALID',
59
+ exitCode: 2
89
60
  })
90
-
91
- if (result.canceled) {
92
- logger.warn('已取消 AI 自动提交。')
93
- return {
94
- enabled: true,
95
- canceled: true
96
- }
97
- }
98
-
99
- logger.success(`AI 自动提交完成:\n${formatAiCommitSummary(result.commitId, result.commitMessage)}`)
100
- return {
101
- enabled: true,
102
- applied: true,
103
- commitId: result.commitId,
104
- commitMessage: result.commitMessage
105
- }
106
- } catch (error) {
107
- if (error?.code === 'AI_COMMIT_EMPTY_DIFF') {
108
- logger.info('未检测到可提交改动,跳过 --ai-commit。')
109
- return {
110
- enabled: true,
111
- skipped: true,
112
- reason: 'no-diff'
113
- }
114
- }
115
-
116
- throw error
117
- }
118
- }
119
-
120
- // 在 push 场景下按需创建本地提交,输入 GitService、命令参数和已读取的改动摘要,输出是否提交。
121
- async function prepareCommitIfNeeded(git, options, changes) {
122
- if (changes.clean) {
123
- logger.info('工作区无改动,跳过 commit。')
124
- return false
125
61
  }
126
62
 
127
- let message = options.message?.trim()
128
- if (!message) {
129
- message = (await input('请输入提交信息', '')).trim()
130
- }
131
-
132
- if (!message) {
133
- throw new IcodeError('检测到代码改动但未提供提交信息,请使用 -m 或 --message。', {
134
- code: 'PUSH_COMMIT_MESSAGE_REQUIRED',
63
+ const isGuarded = currentBranch === context.defaultBranch || protectedBranches.has(currentBranch)
64
+ if (isGuarded && !inputOptions.forceProtected) {
65
+ throw new IcodeError(`拒绝强推主分支/受保护分支: ${currentBranch}。确有必要请加 --force-protected。`, {
66
+ code: 'PUSH_FORCE_PROTECTED',
135
67
  exitCode: 2
136
68
  })
137
69
  }
138
-
139
- // 统一自动暂存,降低同学手动 add 的负担。
140
- await git.stageAll()
141
- await git.commit(message, {
142
- noVerify: options.noVerify
143
- })
144
- logger.success(`提交完成: ${message}`)
145
- return true
146
- }
147
-
148
- // 提取底层 Git 失败细节,输入异常对象,输出用于暂停错误的简洁原因。
149
- function resolveGitFailureReason(error) {
150
- const stderr = typeof error?.meta?.stderr === 'string' ? error.meta.stderr.trim() : ''
151
- const stdout = typeof error?.meta?.stdout === 'string' ? error.meta.stdout.trim() : ''
152
- const output = [stderr, stdout].filter(Boolean).join('\n').trim()
153
- const message = typeof error?.message === 'string' ? error.message.trim() : ''
154
- const reason = output || message || 'Git 操作失败'
155
-
156
- return reason.length > 2000 ? `${reason.slice(0, 2000)}...` : reason
157
- }
158
-
159
- // 判断 Git 失败是否来自合并冲突,输入异常对象,输出布尔结果。
160
- function isGitConflictFailure(error) {
161
- const text = [
162
- error?.meta?.stderr || '',
163
- error?.meta?.stdout || '',
164
- error?.message || ''
165
- ].join('\n')
166
-
167
- return /CONFLICT \(|Automatic merge failed|fix conflicts|unmerged files|needs merge|you need to resolve your current index first/i.test(text)
168
- }
169
-
170
- // 构建本地合并暂停错误,输入分支、阶段和原始异常,输出可被命令层汇总的 IcodeError。
171
- function buildLocalMergePauseError({ sourceBranch, targetBranch, step, error, summary = [] }) {
172
- const reason = resolveGitFailureReason(error)
173
- const conflict = isGitConflictFailure(error)
174
- const prefix = conflict ? '本地合并冲突' : '本地合并失败'
175
- const nextStep = conflict
176
- ? '请解决冲突后执行 `icode undo --recover continue`,或执行 `icode undo --recover abort` 中止本次合并。'
177
- : '请根据上面的 Git 错误修复后重试。'
178
-
179
- return new IcodeError(`${prefix}(${step}): ${sourceBranch} -> ${targetBranch}\n${reason}\n${nextStep}`, {
180
- code: 'PUSH_LOCAL_MERGE_PAUSED',
181
- exitCode: 2,
182
- cause: error,
183
- meta: {
184
- sourceBranch,
185
- targetBranch,
186
- step,
187
- reason,
188
- summary
189
- }
190
- })
191
- }
192
-
193
- async function checkoutTargetBranch(git, targetBranch, sourceBranch, summary = []) {
194
- const localExists = await git.branchExistsLocal(targetBranch)
195
- const remoteExists = await git.branchExistsRemote(targetBranch)
196
- let checkoutMode = 'local'
197
-
198
- if (localExists) {
199
- await git.checkout(targetBranch)
200
- } else if (remoteExists) {
201
- await git.checkoutTracking(targetBranch)
202
- checkoutMode = 'tracking'
203
- } else {
204
- // 目标分支不存在时,默认从 source 分支切出,方便“临时发布分支”场景。
205
- logger.warn(`目标分支 ${targetBranch} 不存在,将从 ${sourceBranch} 创建。`)
206
- await git.checkoutNewBranch(targetBranch, sourceBranch)
207
- checkoutMode = 'created'
208
- }
209
-
210
- if (remoteExists) {
211
- try {
212
- await git.pull(targetBranch, {
213
- noRebase: true
214
- })
215
- } catch (error) {
216
- throw buildLocalMergePauseError({
217
- sourceBranch,
218
- targetBranch,
219
- step: 'pull-target',
220
- error,
221
- summary
222
- })
223
- }
224
- }
225
-
226
- return {
227
- remoteExists,
228
- checkoutMode
229
- }
230
70
  }
231
71
 
232
- function normalizeRemoteMergePolicy(policy = {}) {
233
- const remoteMerge = policy.remoteMerge && typeof policy.remoteMerge === 'object' ? policy.remoteMerge : {}
234
- return {
235
- enabled: remoteMerge.enabled !== false
236
- }
72
+ // 去掉会被跳过的受保护分支,只保留续做时真正要处理的分支。
73
+ function filterRunnable(branches, protectedBranches, inputOptions) {
74
+ return branches.filter((branch) => !(protectedBranches.has(branch) && !inputOptions.forceProtected))
237
75
  }
238
76
 
239
- // 归一化平台级远程合并配置,避免命令层拿到带空白的原始值。
240
- function normalizeRemoteMergePlatformConfig(config = {}) {
241
- return {
242
- provider: typeof config.provider === 'string' ? config.provider.trim() : '',
243
- apiKey: typeof config.apiKey === 'string' ? config.apiKey.trim() : ''
244
- }
245
- }
246
-
247
- function buildRemoteMergePauseError(targetBranch, reason, meta = {}) {
248
- return new IcodeError(`远程合并已暂停(${targetBranch}): ${reason}`, {
249
- code: 'PUSH_REMOTE_MERGE_PAUSED',
250
- exitCode: 2,
251
- meta: {
252
- targetBranch,
253
- reason,
254
- ...meta
255
- }
256
- })
257
- }
258
-
259
- // 统一把 provider 返回的 step 注入错误文案,便于定位 create/merge/inspect 具体失败点。
260
- function buildRemoteMergeResultMessage(prefix, result) {
261
- const reason = typeof result?.reason === 'string' && result.reason.trim()
262
- ? result.reason.trim()
263
- : '未知原因'
264
- const step = typeof result?.step === 'string' && result.step.trim()
265
- ? result.step.trim()
266
- : ''
267
-
268
- return step ? `${prefix}(${step}): ${reason}` : `${prefix}: ${reason}`
269
- }
270
-
271
- // 构建批量远程合并暂停错误,明确指出具体失败分支与原因。
272
- function buildRemoteMergeBatchPauseError(failures, summary) {
273
- const branchesLabel = failures.map((item) => item.branch).join(', ')
274
- const detail = failures.map((item) => `${item.branch}: ${item.reason}`).join(' | ')
275
-
276
- return new IcodeError(`远程合并已暂停(${branchesLabel}): ${detail}`, {
277
- code: 'PUSH_REMOTE_MERGE_PAUSED',
278
- exitCode: 2,
279
- meta: {
280
- failures,
281
- summary
282
- }
77
+ // 保存 push 中断时的进度:pendingPush 是已合并但还没推送的分支,remainingTargets 是还没处理的分支。
78
+ function savePushState(context, inputOptions, sourceBranch, pendingPush, remainingTargets, summary) {
79
+ saveOperationState(context, {
80
+ command: 'push',
81
+ sourceBranch,
82
+ originalBranch: sourceBranch,
83
+ pendingPush,
84
+ remainingTargets,
85
+ options: {
86
+ noVerify: Boolean(inputOptions.noVerify),
87
+ createTarget: Boolean(inputOptions.createTarget),
88
+ yes: true
89
+ },
90
+ summary: summary.slice()
283
91
  })
284
92
  }
285
93
 
286
- // 构建 push dry-run 步骤,输入分支目标和策略,输出不会产生副作用的计划列表。
287
- function buildPushDryRunPlan({ currentBranch, branchTargets, protectedBranches, inputOptions }) {
288
- const steps = []
289
-
290
- if (inputOptions.aiCommit) {
291
- steps.push({
292
- action: 'ai-commit',
293
- branch: currentBranch,
294
- reason: '将生成并提交 AI commit'
295
- })
296
- } else {
297
- steps.push({
298
- action: 'commit-if-needed',
299
- branch: currentBranch,
300
- reason: inputOptions.message ? '检测到改动时使用 -m/--message 提交' : '检测到改动时会要求输入提交信息'
301
- })
302
- }
303
-
304
- if (inputOptions.pullMain) {
305
- steps.push({
306
- action: 'pull-main',
307
- branch: currentBranch,
308
- reason: '将主分支同步到当前分支'
309
- })
310
- }
311
-
312
- branchTargets.forEach((targetBranch) => {
313
- if (protectedBranches.has(targetBranch) && !inputOptions.forceProtected) {
314
- steps.push({
315
- action: 'skip-protected',
316
- branch: targetBranch,
317
- reason: '受保护分支,未传 --force-protected'
318
- })
319
- return
320
- }
321
-
322
- if (targetBranch === currentBranch) {
323
- steps.push({
324
- action: 'push-current',
325
- branch: targetBranch,
326
- reason: '将同步并推送当前分支'
327
- })
328
- return
329
- }
330
-
331
- steps.push({
332
- action: inputOptions.remoteMerge ? 'remote-merge' : 'local-merge',
333
- branch: targetBranch,
334
- reason: inputOptions.remoteMerge ? '将创建并合并远程 MR' : '将切换目标分支、本地 merge 后推送'
335
- })
94
+ /**
95
+ * 交互选择推送目标:只推当前分支(默认)/ 同时本地合并到其他分支 / 通过 PR 合并。
96
+ * 输出 { targets, remoteMerge } 或 null(取消)。
97
+ */
98
+ async function pickPushTargets(git, currentBranch, context, protectedBranches) {
99
+ const unpushed = await git.countCommits(`${git.remoteRef(currentBranch)}..HEAD`)
100
+ const hint = (await git.refExists(git.remoteRef(currentBranch)))
101
+ ? (unpushed ? `${unpushed} 个提交待推送` : '远程已是最新')
102
+ : '远程还没有这个分支'
103
+ const mode = await chooseOne(`推送 ${ui.branch(currentBranch)}`, [
104
+ { value: 'current', label: `只推送 ${currentBranch}`, hint },
105
+ { value: 'local', label: '同时合并到其他分支(本地 merge)', hint: '例如 test / release' },
106
+ { value: 'remote', label: '同时通过 PR/MR 合并到其他分支(远程合并)' },
107
+ { value: 'cancel', label: '取消' }
108
+ ], 0, { filter: false })
109
+
110
+ if (mode === 'cancel') {
111
+ return null
112
+ }
113
+ if (mode === 'current') {
114
+ return { targets: [currentBranch], remoteMerge: false }
115
+ }
116
+
117
+ const names = [...new Set([...(await git.listLocalBranches()), ...(await git.listRemoteBranches())])]
118
+ .filter((name) => name && name !== currentBranch)
119
+ if (!names.length) {
120
+ logger.warn('没有其它分支可选,只推送当前分支。')
121
+ return { targets: [currentBranch], remoteMerge: false }
122
+ }
123
+ // 常见的部署分支排在前面。
124
+ const priority = ['test', 'dev', 'develop', 'staging', 'pre', 'uat', 'release', context.defaultBranch]
125
+ names.sort((a, b) => {
126
+ const pa = priority.indexOf(a) === -1 ? 99 : priority.indexOf(a)
127
+ const pb = priority.indexOf(b) === -1 ? 99 : priority.indexOf(b)
128
+ return pa - pb || a.localeCompare(b)
336
129
  })
337
130
 
338
- return {
339
- currentBranch,
340
- branchTargets,
341
- remoteMerge: Boolean(inputOptions.remoteMerge),
342
- steps
343
- }
344
- }
131
+ const picked = await chooseMany('选择要合并到的分支', names.map((name) => ({
132
+ value: name,
133
+ label: name,
134
+ hint: protectedBranches.has(name) ? '受保护,会被跳过(--force-protected 可覆盖)' : ''
135
+ })), { minSelections: 1, doneLabel: '开始推送' })
345
136
 
346
- // 根据 origin 推导远程合并项目 URL,输入平台配置与 remote,输出项目 URL。
347
- function resolveRemoteMergeProjectUrlFromOrigin(remoteMergePlatform, remoteOriginUrl) {
348
- return {
349
- projectUrl: resolveRemoteMergeProjectUrl({
350
- provider: remoteMergePlatform.provider,
351
- remoteUrl: remoteOriginUrl
352
- }),
353
- source: 'origin'
137
+ if (!picked) {
138
+ return null
354
139
  }
355
- }
356
-
357
- // 基于 origin 推导 GitLab 项目地址并发起固定 API 的 MR 创建请求。
358
- async function performRemoteMerge({ git, currentBranch, targetBranch, remoteExists, inputOptions, remoteMergePolicy, remoteMergePlatform }) {
359
- if (!remoteExists) {
360
- throw buildRemoteMergePauseError(targetBranch, '远程合并要求目标分支已存在于 origin。', {
361
- remoteExists: false
362
- })
363
- }
364
-
365
- if (!remoteMergePolicy.enabled) {
366
- throw buildRemoteMergePauseError(targetBranch, '仓库未启用远程合并配置,请先配置 repositories.<repo>.remoteMerge.enabled=true。')
367
- }
368
-
369
- if (!remoteMergePlatform.apiKey) {
370
- throw buildRemoteMergePauseError(targetBranch, '平台未配置远程合并密钥,请先执行 icode config platform remote-merge set --provider <name> --api-key <key>。')
371
- }
372
-
373
- const remoteOriginUrl = await git.getRemoteUrl('origin')
374
- const { projectUrl, source } = resolveRemoteMergeProjectUrlFromOrigin(remoteMergePlatform, remoteOriginUrl)
375
- if (!projectUrl) {
376
- throw buildRemoteMergePauseError(targetBranch, '无法从 origin 自动识别 GitLab 项目 URL,请检查 origin 是否指向 GitLab 仓库。', {
377
- origin: remoteOriginUrl
378
- })
379
- }
380
- logger.info(`GitLab 项目 URL(${source}): ${logger.color('green', projectUrl)}`)
381
-
382
- logger.info(`发起远程合并: ${logger.color('yellow', currentBranch)} -> ${logger.color('yellow', targetBranch)}`)
383
- const mergeRequestContent = await buildRemoteMergeCommitContent(git, currentBranch, targetBranch)
384
- const result = await requestRemoteMerge({
385
- provider: remoteMergePlatform.provider,
386
- apiKey: remoteMergePlatform.apiKey,
387
- projectUrl,
388
- sourceBranch: currentBranch,
389
- targetBranch,
390
- mergeRequestTitle: mergeRequestContent.title,
391
- mergeRequestDescription: mergeRequestContent.description,
392
- repoRoot: git.cwd,
393
- noVerify: inputOptions.noVerify
394
- })
395
-
396
- if (result.conflict) {
397
- throw buildRemoteMergePauseError(targetBranch, buildRemoteMergeResultMessage('远程合并冲突', result), {
398
- sourceBranch: currentBranch,
399
- step: result.step || '',
400
- result
401
- })
402
- }
403
-
404
- if (!result.ok) {
405
- throw buildRemoteMergePauseError(targetBranch, buildRemoteMergeResultMessage('远程合并失败', result), {
406
- sourceBranch: currentBranch,
407
- step: result.step || '',
408
- result
409
- })
410
- }
411
-
412
- const successReason = typeof result.reason === 'string' && result.reason.trim()
413
- ? result.reason.trim()
414
- : '远程合并已完成'
415
-
416
- if (result.mergeRequestUrl) {
417
- logger.success(`${successReason}: ${logger.color('green', result.mergeRequestUrl)}`)
418
- } else {
419
- logger.success(`${successReason}: ${logger.color('yellow', currentBranch)} -> ${logger.color('yellow', targetBranch)}`)
420
- }
421
-
422
- return result
423
- }
424
-
425
- // 并发执行多个远程合并目标分支,并把冲突/失败精确归档到对应分支。
426
- async function runConcurrentRemoteMerges(options) {
427
- const tasks = options.targetBranches.map(async (targetBranch) => {
428
- try {
429
- logger.info(`处理分支: ${targetBranch}`)
430
- const remoteExists = await options.git.branchExistsRemote(targetBranch)
431
- const remoteMergeResult = await performRemoteMerge({
432
- ...options,
433
- targetBranch,
434
- remoteExists
435
- })
436
-
437
- return {
438
- branch: targetBranch,
439
- status: 'remote-merged-and-pushed',
440
- mergeRequestUrl: remoteMergeResult.mergeRequestUrl || '',
441
- mergeRequestId: remoteMergeResult.mergeRequestId || ''
442
- }
443
- } catch (error) {
444
- return {
445
- branch: targetBranch,
446
- status: 'paused',
447
- reason: resolveRemoteMergeFailureReason(error)
448
- }
449
- }
450
- })
451
-
452
- return Promise.all(tasks)
140
+ return { targets: [currentBranch, ...picked], remoteMerge: mode === 'remote' }
453
141
  }
454
142
 
455
143
  /**
@@ -457,18 +145,20 @@ async function runConcurrentRemoteMerges(options) {
457
145
  * 输入为 push 命令选项,输出推送/合并摘要;关键假设是实际执行前必须没有未完成操作和冲突文件。
458
146
  */
459
147
  export async function runPushWorkflow(inputOptions) {
148
+ // 交互选择可能切换到远程合并,这里复制一份,避免修改调用方传入的对象。
149
+ inputOptions = { ...inputOptions }
460
150
  const context = await resolveGitContext({
461
151
  cwd: inputOptions.cwd,
462
- repoMode: inputOptions.repoMode
152
+ repoMode: inputOptions.repoMode,
153
+ remote: inputOptions.remote
463
154
  })
464
155
 
465
156
  const git = new GitService(context)
466
157
  const policy = getRepoPolicy(context.topLevelPath)
467
- const protectedBranches = new Set((policy.protectedBranches || []).map((item) => item.trim()).filter(Boolean))
158
+ const protectedBranches = resolveProtectedBranches(policy, context)
468
159
  const remoteMergePolicy = normalizeRemoteMergePolicy(policy)
469
- const remoteMergePlatform = normalizeRemoteMergePlatformConfig(getPlatformConfig('remoteMerge'))
470
160
 
471
- logger.info(`仓库根目录: ${context.topLevelPath}`)
161
+ logger.hint(`仓库 ${shortenHome(context.topLevelPath)}`)
472
162
  if (context.inheritedFromParent) {
473
163
  logger.warn('当前目录继承了父级 Git 仓库,命令将基于父仓库根目录执行。')
474
164
  }
@@ -496,17 +186,21 @@ export async function runPushWorkflow(inputOptions) {
496
186
  })
497
187
  }
498
188
 
189
+ validateForcePush(inputOptions, branchTargets, currentBranch, context, protectedBranches)
190
+
499
191
  if (inputOptions.dryRun) {
500
192
  return {
501
193
  dryRun: true,
502
194
  repoRoot: context.topLevelPath,
503
195
  currentBranch,
196
+ remote: git.remote,
504
197
  inheritedFromParent: context.inheritedFromParent,
505
198
  plan: buildPushDryRunPlan({
506
199
  currentBranch,
507
200
  branchTargets,
508
201
  protectedBranches,
509
- inputOptions
202
+ inputOptions,
203
+ remote: git.remote
510
204
  })
511
205
  }
512
206
  }
@@ -515,7 +209,7 @@ export async function runPushWorkflow(inputOptions) {
515
209
  assertNoPendingOperation(safetyState.operation, 'push', 'PUSH_GIT_OPERATION_IN_PROGRESS')
516
210
  assertNoConflictedFiles(safetyState.changes, 'push', 'PUSH_CONFLICTED_FILES')
517
211
 
518
- const aiCommitResult = await prepareAiCommitIfEnabled(inputOptions)
212
+ const aiCommitResult = await prepareAiCommitIfEnabled(git, context, inputOptions, safetyState.changes)
519
213
  if (aiCommitResult.canceled) {
520
214
  return {
521
215
  canceled: true,
@@ -528,33 +222,60 @@ export async function runPushWorkflow(inputOptions) {
528
222
 
529
223
  const shouldRunManualCommit = !aiCommitResult.applied && aiCommitResult.reason !== 'no-diff'
530
224
  if (shouldRunManualCommit) {
531
- await prepareCommitIfNeeded(git, inputOptions, safetyState.changes)
225
+ await prepareCommitIfNeeded(git, context, inputOptions, safetyState.changes)
532
226
  }
227
+
228
+ // 强推的 lease 基准必须是 fetch 之前看到的远程 sha,否则 fetch 后 lease 形同虚设。
229
+ const expectedRemoteSha = inputOptions.force ? await git.getRemoteBranchSha(currentBranch) : ''
533
230
  await git.fetch()
534
231
 
535
232
  const summary = []
536
233
 
537
234
  if (inputOptions.pullMain && context.defaultBranch !== currentBranch) {
538
- logger.info(`先同步主分支 ${context.defaultBranch} 到当前分支 ${currentBranch}`)
235
+ logger.info(`先同步主分支 ${ui.branch(context.defaultBranch)} 到当前分支 ${ui.branch(currentBranch)}`)
539
236
  try {
540
237
  await git.pull(context.defaultBranch, {
541
238
  noRebase: true
542
239
  })
543
240
  } catch (error) {
544
- throw buildLocalMergePauseError({
241
+ const pauseError = buildLocalMergePauseError({
545
242
  sourceBranch: context.defaultBranch,
546
243
  targetBranch: currentBranch,
547
244
  step: 'pull-main',
548
245
  error,
549
246
  summary
550
247
  })
248
+ if (await git.getInProgressOperation()) {
249
+ savePushState(context, inputOptions, currentBranch, null, filterRunnable(branchTargets, protectedBranches, inputOptions), summary)
250
+ }
251
+ throw pauseError
551
252
  }
552
253
  }
553
254
 
554
- if (!inputOptions.yes) {
255
+ // 交互终端里没写目标分支时,用选择代替手输分支名;这一步的选择本身就是确认。
256
+ let confirmedByPicker = false
257
+ if (!inputOptions.yes && !inputOptions.force && !inputOptions.remoteMerge && !(inputOptions.targetBranches || []).length && !inputOptions.notPushCurrent && isInteractiveTerminal()) {
258
+ const picked = await pickPushTargets(git, currentBranch, context, protectedBranches)
259
+ if (!picked) {
260
+ logger.warn('已取消执行。')
261
+ return {
262
+ canceled: true,
263
+ branchTargets,
264
+ repoRoot: context.topLevelPath
265
+ }
266
+ }
267
+ branchTargets = picked.targets
268
+ if (picked.remoteMerge) {
269
+ inputOptions = { ...inputOptions, remoteMerge: true }
270
+ }
271
+ confirmedByPicker = true
272
+ }
273
+
274
+ if (!inputOptions.yes && !confirmedByPicker) {
275
+ const action = inputOptions.force ? '强推(--force-with-lease)' : '推送/合并'
555
276
  const confirmed = await confirm(
556
- `确认将 ${currentBranch} 推送/合并到以下分支: ${branchTargets.join(', ')} ?`,
557
- true
277
+ `确认将 ${currentBranch} ${action}到以下分支: ${branchTargets.join(', ')} ?`,
278
+ !inputOptions.force
558
279
  )
559
280
 
560
281
  if (!confirmed) {
@@ -567,82 +288,52 @@ export async function runPushWorkflow(inputOptions) {
567
288
  }
568
289
  }
569
290
 
291
+ // 本地 merge 会改动目标分支,强推会覆盖远程分支:执行前先记录恢复点。
292
+ const localMergeTargets = inputOptions.remoteMerge ? [] : branchTargets.filter((branch) => branch !== currentBranch)
293
+ const backupBranches = []
294
+ for (const branch of [currentBranch, ...localMergeTargets]) {
295
+ if (await git.branchExistsLocal(branch)) {
296
+ backupBranches.push(branch)
297
+ }
298
+ }
299
+ const backup = (localMergeTargets.length || inputOptions.force)
300
+ ? await createBackup(git, {
301
+ reason: inputOptions.force ? `push --force ${currentBranch}` : `push ${currentBranch} -> ${localMergeTargets.join(',')}`,
302
+ branches: backupBranches,
303
+ remoteBranches: inputOptions.force ? [currentBranch] : []
304
+ })
305
+ : null
306
+
570
307
  const originalBranch = currentBranch
571
308
  const remoteMergeTargets = []
309
+ const branchOptions = { ...inputOptions, expectedRemoteSha }
572
310
 
573
311
  try {
574
- for (const targetBranch of branchTargets) {
312
+ for (let index = 0; index < branchTargets.length; index += 1) {
313
+ const targetBranch = branchTargets[index]
575
314
  if (protectedBranches.has(targetBranch) && !inputOptions.forceProtected) {
576
- logger.warn(`跳过受保护分支: ${targetBranch}(可用 --force-protected 覆盖)`)
315
+ logger.warn(`跳过受保护分支: ${ui.branch(targetBranch)}(可用 --force-protected 覆盖)`)
577
316
  summary.push({ branch: targetBranch, status: 'skipped-protected' })
578
317
  continue
579
318
  }
580
319
 
581
- if (targetBranch === currentBranch) {
582
- logger.info(`处理分支: ${targetBranch}`)
583
- const remoteExists = await git.branchExistsRemote(targetBranch)
584
- if (remoteExists) {
585
- logger.info(`同步远程分支: ${targetBranch}`)
586
- try {
587
- await git.pull(targetBranch, {
588
- noRebase: true
589
- })
590
- } catch (error) {
591
- throw buildLocalMergePauseError({
592
- sourceBranch: `origin/${targetBranch}`,
593
- targetBranch,
594
- step: 'pull-current',
595
- error,
596
- summary
597
- })
598
- }
599
- }
600
-
601
- logger.info(`推送当前分支: ${targetBranch}`)
602
- await git.push(targetBranch, {
603
- setUpstream: !remoteExists,
604
- noVerify: inputOptions.noVerify
605
- })
606
-
607
- logger.success(`推送成功: ${targetBranch}`)
608
- summary.push({ branch: targetBranch, status: 'pushed' })
320
+ if (inputOptions.remoteMerge && targetBranch !== currentBranch) {
321
+ remoteMergeTargets.push(targetBranch)
609
322
  continue
610
323
  }
611
324
 
612
- if (inputOptions.remoteMerge) {
613
- remoteMergeTargets.push(targetBranch)
614
- } else {
615
- logger.info(`处理分支: ${targetBranch}`)
616
- logger.info(`切换到目标分支: ${targetBranch}`)
617
- const { remoteExists, checkoutMode } = await checkoutTargetBranch(git, targetBranch, currentBranch, summary)
618
- logger.info(`目标分支准备完成: ${targetBranch} (${checkoutMode})`)
619
-
620
- // 保留 merge commit,方便后续追溯“从哪个分支合并过来”。
621
- logger.info(`合并分支: ${currentBranch} -> ${targetBranch}`)
622
- try {
623
- await git.merge(currentBranch, {
624
- noFf: true,
625
- noEdit: true
626
- })
627
- } catch (error) {
628
- throw buildLocalMergePauseError({
629
- sourceBranch: currentBranch,
630
- targetBranch,
631
- step: 'merge-target',
632
- error,
633
- summary
634
- })
325
+ try {
326
+ summary.push(targetBranch === currentBranch
327
+ ? await pushCurrentBranch(git, targetBranch, branchOptions, summary)
328
+ : await mergeIntoTargetAndPush(git, currentBranch, targetBranch, branchOptions, summary))
329
+ } catch (error) {
330
+ // 冲突暂停:记下剩余步骤,icode continue 会推送当前目标并处理剩下的分支。
331
+ if (error?.code === 'PUSH_LOCAL_MERGE_PAUSED' && await git.getInProgressOperation()) {
332
+ const mergedTarget = error.meta?.step === 'merge-target'
333
+ const rest = filterRunnable(branchTargets.slice(mergedTarget ? index + 1 : index), protectedBranches, inputOptions)
334
+ savePushState(context, inputOptions, currentBranch, mergedTarget ? targetBranch : null, rest, summary)
635
335
  }
636
- logger.success(`合并成功: ${currentBranch} -> ${targetBranch}`)
637
-
638
- logger.info(`推送目标分支: ${targetBranch}`)
639
- await git.push(targetBranch, {
640
- setUpstream: !remoteExists,
641
- noVerify: inputOptions.noVerify
642
- })
643
-
644
- logger.success(`目标分支推送成功: ${targetBranch}`)
645
- summary.push({ branch: targetBranch, status: 'merged-and-pushed' })
336
+ throw error
646
337
  }
647
338
  }
648
339
 
@@ -654,7 +345,7 @@ export async function runPushWorkflow(inputOptions) {
654
345
  targetBranches: remoteMergeTargets,
655
346
  inputOptions,
656
347
  remoteMergePolicy,
657
- remoteMergePlatform
348
+ remoteMergePlatform: await resolveRemoteMergePlatform(git)
658
349
  })
659
350
  summary.push(...remoteMergeSummary)
660
351
 
@@ -674,12 +365,12 @@ export async function runPushWorkflow(inputOptions) {
674
365
  const operationAfterWorkflow = await git.getInProgressOperation()
675
366
  if (operationAfterWorkflow === 'merge') {
676
367
  logger.warn(`检测到未完成的 merge,已保留在当前分支 ${branchAfterWorkflow || '(unknown)'}。`)
677
- logger.warn('解决冲突后执行 `icode undo --recover continue`,或执行 `icode undo --recover abort` 中止。')
368
+ logger.warn('解决冲突后执行 `icode continue` 接着完成剩下的步骤,或执行 `icode abort` 放弃并切回原分支。')
678
369
  } else if (branchAfterWorkflow && branchAfterWorkflow !== originalBranch) {
679
370
  try {
680
371
  await git.checkout(originalBranch)
681
372
  } catch (error) {
682
- logger.warn(`未能自动切回原分支 ${originalBranch}: ${error.message}`)
373
+ logger.warn(`未能自动切回原分支 ${ui.branch(originalBranch)}: ${error.message}`)
683
374
  }
684
375
  }
685
376
  }
@@ -687,6 +378,8 @@ export async function runPushWorkflow(inputOptions) {
687
378
  return {
688
379
  repoRoot: context.topLevelPath,
689
380
  currentBranch,
381
+ remote: git.remote,
382
+ backupId: backup?.entries.length ? backup.id : '',
690
383
  summary,
691
384
  inheritedFromParent: context.inheritedFromParent
692
385
  }